Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
32 changes: 32 additions & 0 deletions src/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<State, Array<[string, State]>> = {
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');
});
});
22 changes: 10 additions & 12 deletions src/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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 >.
Expand Down
Loading