diff --git a/CHANGELOG.md b/CHANGELOG.md index 89540a9..02ce01b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project are documented in this file. ### Fixed +- Strip 8-bit terminal control strings and CSI sequences without swallowing text after 8-bit ST (#418). + - `stripTerminalEscapes` now removes ECMA-35 nF ESC sequences with intermediate bytes (ESC # 8, ESC % G, ESC SP F); previously only the ESC byte was removed and the payload leaked into the output (#416) - `stripTerminalEscapes` now removes Fs/Fp two-byte ESC sequences (ESC c, ESC 7, ESC 8) matching the full ECMA-35 final range (0x30–0x7E) covered by its docblock; the generic rule previously only accepted Fe finals (0x40–0x5F), leaving the final byte in the output (#414) diff --git a/src/sanitize.test.ts b/src/sanitize.test.ts index 8e5ae5c..1a6f061 100644 --- a/src/sanitize.test.ts +++ b/src/sanitize.test.ts @@ -142,3 +142,35 @@ describe('stringifyForTerminal', () => { assert.strictEqual(stringifyForTerminal([1, 2]), '[1,2]'); }); }); + +// Generate sequences by the ECMA-48 byte classes (entry, parameters, +// intermediates, final), independent of the sanitizer's recognisers. +describe('ECMA-48 conformance corpus', () => { + it('removes complete sequences while preserving surrounding text', () => { + // Enumerate paths through a small reference grammar state machine. + type State = 'entry' | 'csiParams' | 'csiIntermediate' | 'escIntermediate' | 'string' | 'done'; + const transitions: Record> = { + entry: [['\x1b[', 'csiParams'], ['\x9b', 'csiParams'], ['\x1b', 'escIntermediate'], ...['\x1b]', '\x1bP', '\x1b_', '\x1b^', '\x1bX', '\x9d', '\x90', '\x9f', '\x9e', '\x98'].map(intro => [intro, 'string'] as [string, State])], + csiParams: [['', 'csiIntermediate'], ['1;23', 'csiIntermediate'], ['?25', 'csiIntermediate']], + csiIntermediate: ['', ' ', ' !'].flatMap(bytes => ['@', 'm', '~'].map(final => [bytes + final, 'done'] as [string, State])), + escIntermediate: [' ', '#', ' /'].flatMap(bytes => ['0', '8', 'G', '~'].map(final => [bytes + final, 'done'] as [string, State])), + string: ['\x07', '\x1b\\', '\x9c'].map(end => ['0;payload' + end, 'done']), + done: [], + }; + const sequences: string[] = []; + function generate(state: State, prefix: string): void { + if (state === 'done') { sequences.push(prefix); return; } + for (const [bytes, next] of transitions[state]) generate(next, prefix + bytes); + } + generate('entry', ''); + for (const sequence of sequences) { + assert.strictEqual(stripTerminalEscapes(`before${sequence}after`, ''), 'beforeafter', JSON.stringify(sequence)); + assert.strictEqual(stripTerminalEscapes(`before${sequence}after`), 'before after', JSON.stringify(sequence)); + } + }); + + it('does not swallow text between different terminators', () => { + const input = '\x1b]0;title\x9c I cannot run the tests \x1b]2;x\x07 tail'; + assert.strictEqual(stripTerminalEscapes(input, ''), ' I cannot run the tests tail'); + }); +}); diff --git a/src/sanitize.ts b/src/sanitize.ts index 58fbd80..6512414 100644 --- a/src/sanitize.ts +++ b/src/sanitize.ts @@ -4,14 +4,14 @@ // Unterminated introducers are left in place, like the regex form did. function replaceControlStrings(text: string, intro: RegExp, replacement: string): string { const introducer = new RegExp(intro.source, 'g'); - const terminator = /\x07|\x1b\\/g; + const terminator = /\x07|\x9c|\x1b\\/g; let out = ''; let pos = 0; for (;;) { introducer.lastIndex = pos; const start = introducer.exec(text); if (!start) break; - terminator.lastIndex = start.index + 2; + terminator.lastIndex = start.index + start[0].length; const end = terminator.exec(text); if (!end) break; out += text.slice(pos, start.index) + replacement; @@ -31,7 +31,9 @@ function replaceControlStrings(text: string, intro: RegExp, replacement: string) * regex chain at the call site. * * Covers OSC (window-title spoofing, OSC 52 clipboard writes), CSI (cursor - * movement, screen clear/overwrite), DCS, charset-designation and + * movement, screen clear/overwrite), and DCS, SOS, PM and APC control strings, + * including their 7-bit ESC and 8-bit C1 forms. Control strings end at BEL, + * ST or 8-bit ST. Also covers charset-designation and * keypad-mode two-byte ESC forms, nF sequences with intermediate bytes (e.g. * ESC # 8, ESC % G, ESC SP F), any other two-byte ESC sequence (e.g. * ESC c full reset), and the raw C0/DEL/C1 control-character ranges (with @@ -43,15 +45,11 @@ function replaceControlStrings(text: string, intro: RegExp, replacement: string) * sequences removed with no trace. */ export function stripTerminalEscapes(text: string, replacement = ' '): string { - // OSC (Operating System Command): ESC ] ... terminated by BEL or ST (ESC \). - const withoutOsc = replaceControlStrings(text, /\x1b\]/, replacement); - const withoutDcs = withoutOsc - // CSI (Control Sequence Introducer): ESC [ ... followed by a final byte in @-~. - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, replacement) - // DCS (Device Control String): ESC P ... terminated by ST (ESC \). - .replace(/\x1bP[^\x1b]*\x1b\\/g, replacement); - // APC (Application Program Command) / PM (Privacy Message): ESC _ / ESC ^ ... terminated by BEL or ST (ESC \), like OSC. - return replaceControlStrings(withoutDcs, /\x1b[_^]/, replacement) + // Consume all control strings together so their terminators retain input order. + const withoutStrings = replaceControlStrings(text, /\x1b[\]P_^X]|[\x90\x98\x9d\x9e\x9f]/, replacement); + return withoutStrings + // CSI accepts both the ESC [ and C1 forms. + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, replacement) // Charset designation, e.g. ESC ( B. .replace(/\x1b[()][A-Z0-9]/g, replacement) // Keypad mode (application/numeric), e.g. ESC = / ESC >.