diff --git a/packages/helpers/src/strings.js b/packages/helpers/src/strings.js index 9b070769..e73dce85 100644 --- a/packages/helpers/src/strings.js +++ b/packages/helpers/src/strings.js @@ -1,13 +1,9 @@ const invisibleCharRegex = '[\\s\\p{C}\u034f\u17b4\u17b5\u2800\u115f\u1160\u3164\uffa0]'; -const trimStartRegex = new RegExp('^' + invisibleCharRegex, 'u'); +const trimStartRegex = new RegExp('^' + invisibleCharRegex + '+', 'u'); const trimEndRegex = new RegExp(invisibleCharRegex + '$', 'u'); export function trimStart(str) { - while (trimStartRegex.test(str)) { - str = str.replace(trimStartRegex, ''); - } - - return str; + return str.replace(trimStartRegex, ''); } export function trimEnd(str) { @@ -29,8 +25,15 @@ export function truncate(str, maxLength) { const ellipsis = ELLIPSIS.slice(0, maxLength); - const graphemes = Array.from(graphemeSegmenter.segment(str), (s) => s.segment); - if (graphemes.length < maxLength) return str; + const graphemes = []; + + for (const s of graphemeSegmenter.segment(str)) { + if (graphemes.length > maxLength) { + return trimEnd(graphemes.slice(0, maxLength - ellipsis.length).join('')) + ellipsis; + } - return trimEnd(graphemes.slice(0, maxLength - ellipsis.length).join('')) + ellipsis; + graphemes.push(s.segment); + } + + return str; } diff --git a/packages/helpers/src/strings.test.js b/packages/helpers/src/strings.test.js index b9817796..5303cabe 100644 --- a/packages/helpers/src/strings.test.js +++ b/packages/helpers/src/strings.test.js @@ -3,9 +3,18 @@ import { trimEnd, trimStart, truncate } from './strings.js'; describe('helpers', () => { describe('trimStart', () => { it('should remove invisible characters from the start of a string', () => { - const input = '\u034f\u034fHello'; - const result = trimStart(input); - expect(result).toBe('Hello'); + expect(trimStart(' A')).toBe('A'); + expect(trimStart('\nB')).toBe('B'); + expect(trimStart('\tC')).toBe('C'); + expect(trimStart('\rD')).toBe('D'); + expect(trimStart('\u034fE')).toBe('E'); + expect(trimStart('\u17b4F')).toBe('F'); + expect(trimStart('\u17b5G')).toBe('G'); + expect(trimStart('\u2800F')).toBe('F'); + expect(trimStart('\u115fH')).toBe('H'); + expect(trimStart('\u1160I')).toBe('I'); + expect(trimStart('\u3164J')).toBe('J'); + expect(trimStart('\uffa0K')).toBe('K'); }); it('should return the same string if no invisible characters are at the start', () => { @@ -13,13 +22,28 @@ describe('helpers', () => { const result = trimStart(input); expect(result).toBe('Hello'); }); + + it('should not hang (ReDoS) on long string of invisible chars', () => { + const long = '\u034f'.repeat(1_000_000) + 'Hello'; + const result = trimStart(long); + expect(result).toBe('Hello'); + }, 200); }); describe('trimEnd', () => { it('should remove invisible characters from the end of a string', () => { - const input = 'Hello\u034f\u034f'; - const result = trimEnd(input); - expect(result).toBe('Hello'); + expect(trimEnd('A ')).toBe('A'); + expect(trimEnd('B\n')).toBe('B'); + expect(trimEnd('C\t')).toBe('C'); + expect(trimEnd('D\r')).toBe('D'); + expect(trimEnd('E\u034f')).toBe('E'); + expect(trimEnd('F\u17b4')).toBe('F'); + expect(trimEnd('G\u17b5')).toBe('G'); + expect(trimEnd('F\u2800')).toBe('F'); + expect(trimEnd('H\u115f')).toBe('H'); + expect(trimEnd('I\u1160')).toBe('I'); + expect(trimEnd('J\u3164')).toBe('J'); + expect(trimEnd('K\uffa0')).toBe('K'); }); it('should return the same string if no invisible characters are at the end', () => { @@ -27,6 +51,20 @@ describe('helpers', () => { const result = trimEnd(input); expect(result).toBe('Hello'); }); + + it('should not hang (ReDoS) on long string of invisible chars', () => { + const long1 = 'Hello' + '\u3164'.repeat(1_000_000); + const long2 = 'A'.repeat(1_000_000) + '\u17b5'.repeat(1_000_000); + const long3 = 'B' + '\u034f'.repeat(1_000_000) + 'C'; + const long4 = '\u2800'.repeat(1_000_000) + 'D'; + const long5 = 'E'.repeat(1_000_000) + '\u034f' + 'F'; + + expect.soft(trimEnd(long1)).toBe('Hello'); + expect.soft(trimEnd(long2)).toBe('A'.repeat(1_000_000)); + expect.soft(trimEnd(long3)).toBe(long3); + expect.soft(trimEnd(long4)).toBe(long4); + expect.soft(trimEnd(long5)).toBe(long5); + }, 1_000); }); describe('truncate', () => { @@ -116,5 +154,22 @@ describe('helpers', () => { expect(truncate(input, 19)).toBe('Truncate this ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļ...'); expect(truncate(input, 20)).toBe('Truncate this ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļ s...'); }); + + it('should handle very long strings efficiently and correctly', () => { + const longString = 'ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļa'.repeat(100_000); // > 1MB + const result = truncate(longString, 100); + + expect.soft(result.endsWith('...')).toBeTruthy(); + expect.soft(result.startsWith('ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļa'.repeat(48))).toBeTruthy(); + expect.soft(result === 'ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļa'.repeat(48) + 'ðŸ‘Đ‍âĪïļâ€ðŸ’‹â€ðŸ‘Ļ' + '...').toBeTruthy(); + + // Truncate to a length less than ellipsis + const result2 = truncate(longString, 2); + expect.soft(result2).toBe('..'); + + // Truncate to a length greater than the string + const result3 = truncate(longString, 200_000); + expect(result3).toBe(longString); + }, 100); }); });