fix(env-file): collapse escaped backslashes in quoted values (#1773)

* fix(env-file): collapse escaped backslashes in quoted values

unescapeQuotedValue only collapsed an escaped quote (\" -> "), but the
closing-quote scanner (findClosingQuote/isEscapedQuote) already treats a
quoted value as backslash-escaped: an odd-length backslash run escapes the
following quote, so a \\ pair is consumed as a single escaped backslash
when locating the closing quote. Because the unescaper left \\ untouched,
the two stages disagreed and a value such as "a\\b" round-tripped to the
doubled "a\\b" instead of "a\b".

Collapse \\ to \ in unescapeQuotedValue so both stages share one escaping
rule. Lone backslashes before ordinary characters are still preserved.

* test(env-file): cover escaped backslash adjacent to closing quote

Locks down the terminator interaction between findClosingQuote/isEscapedQuote
and unescapeQuotedValue: a value written as "a\\" (two backslashes before
the closing quote) parses to a single trailing backslash.
This commit is contained in:
0xfandom
2026-06-25 23:23:43 +08:00
committed by GitHub
parent 13f7401541
commit 82fd23798e
2 changed files with 32 additions and 2 deletions
+24
View File
@@ -149,6 +149,30 @@ BAZ=qux
})
})
it('collapses escaped backslashes inside quoted values', () => {
// The value content is C:\\Users\\me — escaped backslashes that the
// closing-quote scanner already treats as single backslashes, so the
// unescaper must collapse them too.
const result = parseEnvFile('FOO="C:\\\\Users\\\\me"')
expect(result).toEqual({ FOO: 'C:\\Users\\me' })
})
it('keeps lone backslashes in quoted values intact', () => {
// A single backslash before an ordinary character is not an escape and
// must survive verbatim (e.g. a Windows path written without doubling).
const result = parseEnvFile('FOO="C:\\Users\\me"')
expect(result).toEqual({ FOO: 'C:\\Users\\me' })
})
it('collapses an escaped backslash adjacent to the closing quote', () => {
// The value content is a\\ — the escaped backslash sits right before the
// terminator, the trickiest interaction between findClosingQuote (which
// counts the even backslash run and keeps scanning) and unescapeQuotedValue
// (which must collapse the pair to one trailing backslash).
const result = parseEnvFile('FOO="a\\\\"')
expect(result).toEqual({ FOO: 'a\\' })
})
it('strips inline comments from unquoted values', () => {
const result = parseEnvFile('FOO=bar # comment\nBAZ=qux')
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' })
+8 -2
View File
@@ -165,13 +165,19 @@ function findClosingQuote(value: string, quote: string): number {
/**
* Unescapes the active quote delimiter in a quoted env value.
*
* findClosingQuote/isEscapedQuote treat the value as backslash-escaped: an
* odd-length backslash run escapes the following quote, so `\\` is already
* consumed as a single escaped backslash when locating the closing quote.
* Collapse `\\` to `\` here too, otherwise the two stages disagree and a
* value like "a\\b" round-trips to the doubled "a\\b" instead of "a\b".
*/
function unescapeQuotedValue(raw: string, quote: string): string {
let result = ''
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '\\' && raw[i + 1] === quote) {
result += quote
if (raw[i] === '\\' && (raw[i + 1] === quote || raw[i + 1] === '\\')) {
result += raw[i + 1]
i++
continue
}