fix(core): stop sed simulation from misreading backslashes in brackets (#13557)

This commit is contained in:
Mohammed Alkindi authored and GitHub committed 2026-10-23 01:14:27 +08:00
1 parent c61044fae4
commit 3d65e23cf6
2 files changed
+100 -7

No files matched your search

+56 -2
View File
@@ -321,8 +321,8 @@ describe('sedEditParser', () => {
});
it('declines the same bracket expressions under -E', () => {
// The -E path hands the pattern to RegExp untouched, so it diverges the
// same way and has to be declined separately.
// -E goes through the same bracket translation, so the same check
// declines these.
expect(parseSedEditCommand("sed -E -i 's/[]a]/X/g' f.txt")).toBeNull();
expect(parseSedEditCommand("sed -E -i 's/[^]]/X/g' f.txt")).toBeNull();
});
@@ -335,4 +335,58 @@ describe('sedEditParser', () => {
expect(applySed("sed -i 's/[^abc]/X/g' f.txt", 'a]b')).toBe('aXb');
expect(applySed("sed -i 's/a\\[b/X/g' f.txt", 'a[b]c')).toBe('X]c');
});
// A backslash inside a bracket expression is a literal member under -E
// too; JS read it as an escape. Expected values measured with GNU sed 4.9.
it('keeps backslashes literal inside bracket expressions under -E', () => {
// [\[\]] is the class {\, [} followed by a literal ]; sed leaves this
// input alone, the simulation deleted both brackets.
expect(applySed("sed -E -i 's/[\\[\\]]//g' f.txt", 'a[b]c')).toBe('a[b]c');
expect(applySed("sed -E -i 's/[\\+]/X/g' f.txt", 'a + \\ b')).toBe(
'a X X b',
);
expect(applySed("sed -E -i 's/[\\.]/X/g' f.txt", 'a.b\\c')).toBe('aXbXc');
expect(applySed("sed -E -i 's/[\\]]/X/g' f.txt", 'a\\]b]')).toBe('aXb]');
// \-c is a range from backslash to c, so it covers b as well.
expect(applySed("sed -E -i 's/[a\\-c]/X/g' f.txt", 'a-b\\c')).toBe('X-XXX');
});
// GNU sed expands \t, \n and friends inside a bracket expression, so
// [ \t] is a space or a TAB there; the BRE translation read it as a space,
// backslash or t. Other seds disagree with GNU, so these go to real sed.
it('declines character escapes inside bracket expressions', () => {
// On 'bat', sed writes 'bat'; the simulation wrote 'ba'.
expect(parseSedEditCommand("sed -i 's/[ \\t]*$//' f.txt")).toBeNull();
});
it.each(['n', 't', 'r', 'f', 'v', 'a', 'd065', 'o101', 'x41', 'cA'])(
'declines [\\%s] under BRE and -E',
(escape) => {
expect(
parseSedEditCommand(`sed -i 's/[\\${escape}]/X/g' f.txt`),
).toBeNull();
expect(
parseSedEditCommand(`sed -E -i 's/[\\${escape}]/X/g' f.txt`),
).toBeNull();
},
);
it('still simulates escapes outside brackets and escaped backslashes', () => {
// Guards over-correcting. GNU sed reads \t outside a bracket as a TAB,
// as the simulation does; the ] in [a]\t closes the bracket, so that \t
// is simulated (GNU sed 4.9 writes 'at Xb'); a \\ inside a bracket
// leaves the t after it literal; and under -E an escaped [ does not
// open a bracket expression.
expect(applySed("sed -i 's/a\\tb/X/g' f.txt", 'a\tb')).toBe('X');
expect(applySed("sed -i 's/[a]\\t/X/' f.txt", 'at a\tb')).toBe('at Xb');
expect(applySed("sed -E -i 's/[a]\\t/X/' f.txt", 'at a\tb')).toBe('at Xb');
expect(applySed("sed -i 's/[\\\\t]/X/g' f.txt", 'a\tb\\t')).toBe('a\tbXX');
expect(applySed("sed -E -i 's/[\\\\t]/X/g' f.txt", 'a\tb\\t')).toBe(
'a\tbXX',
);
expect(applySed("sed -E -i 's/a\\.b/X/g' f.txt", 'a.b axb')).toBe('X axb');
expect(applySed("sed -E -i 's/\\[a\\]/X/g' f.txt", '[a] a\\]')).toBe(
'X a\\]',
);
});
});
+44 -5
View File
@@ -181,7 +181,8 @@ function canCompileSedPattern(sedInfo: SedEditInfo): boolean {
if (
hasPosixBracketExpression(jsPattern) ||
hasLeadingBracketLiteral(jsPattern) ||
hasSedJavascriptDivergentEscape(jsPattern)
hasSedJavascriptDivergentEscape(jsPattern) ||
hasBracketCharacterEscape(sedInfo.pattern)
) {
return false;
}
@@ -230,6 +231,36 @@ function hasSedJavascriptDivergentEscape(pattern: string): boolean {
return /\\[<>dDwWsS]/u.test(pattern);
}
/**
* True when a bracket expression holds a character escape such as `\t`,
* `\n` or `\x41`. GNU sed expands these, so `[ \t]` is a space or a TAB;
* POSIX reads a backslash and a `t`, as the translation does. The seds
* disagree, so the caller declines the pattern and real sed runs it.
*/
function hasBracketCharacterEscape(pattern: string): boolean {
let inCharacterClass = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]!;
if (char === '\\') {
const next = pattern[i + 1] ?? '';
if (inCharacterClass && /[ntrfvadoxc]/u.test(next)) {
return true;
}
// Inside a bracket only `\\` pairs up, so `[\\t]` ends in a plain `t`.
if (!inCharacterClass || next === '\\') {
i++;
}
continue;
}
if (char === '[') {
inCharacterClass = true;
} else if (char === ']') {
inCharacterClass = false;
}
}
return false;
}
function hasReplacementBackrefBeyondCaptures(
replacement: string,
captureCount: number,
@@ -505,15 +536,13 @@ export function applySedSubstitution(
function toJavascriptPattern(sedInfo: SedEditInfo): string {
const pattern = unescapeSedDelimiter(sedInfo.pattern);
if (sedInfo.extendedRegex) {
return pattern;
}
let jsPattern = '';
let inCharacterClass = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]!;
if (inCharacterClass) {
// A backslash inside a bracket expression is a literal member in BRE
// and ERE alike, not an escape as in JS.
if (char === '\\') {
jsPattern += '\\\\';
continue;
@@ -531,6 +560,16 @@ function toJavascriptPattern(sedInfo: SedEditInfo): string {
continue;
}
if (sedInfo.extendedRegex) {
if (char === '\\' && i + 1 < pattern.length) {
jsPattern += char + pattern[i + 1]!;
i++;
} else {
jsPattern += char;
}
continue;
}
if (char === '\\' && i + 1 < pattern.length) {
const next = pattern[i + 1]!;
if (BRE_OPERATORS.has(next)) {