diff --git a/dotenv/parse.ts b/dotenv/parse.ts index 63d25b3c8fef..28e26e3316c8 100644 --- a/dotenv/parse.ts +++ b/dotenv/parse.ts @@ -8,7 +8,7 @@ type LineParseResult = { }; const KEY_VALUE_REGEXP = - /^\s*(?:export\s+)?(?[^\s=#]+?)\s*=[\ \t]*('\r?\n?(?(.|\r\n|\n)*?)\r?\n?'|"\r?\n?(?(.|\r\n|\n)*?)\r?\n?"|(?[^\r\n#]*)) *#*.*$/gm; + /^\s*(?:export\s+)?(?[^\s=#]+?)\s*=[\ \t]*('\r?\n?(?(?:.|\r\n|\n)*?)\r?\n?'|"\r?\n?(?(?:[^"\\]|\\.)*)\r?\n?"|(?[^\r\n#]*)) *#*.*$/gm; const VALID_KEY_REGEXP = /^[a-zA-Z_][a-zA-Z0-9_]*$/; @@ -19,11 +19,13 @@ const CHARACTERS_MAP: { [key: string]: string } = { "\\n": "\n", "\\r": "\r", "\\t": "\t", + '\\"': '"', + "\\\\": "\\", }; function expandCharacters(str: string): string { return str.replace( - /\\([nrt])/g, + /\\([nrt"\\])/g, ($1: keyof typeof CHARACTERS_MAP): string => CHARACTERS_MAP[$1] ?? "", ); } diff --git a/dotenv/parse_test.ts b/dotenv/parse_test.ts index e825b9728284..2d94d4a45e99 100644 --- a/dotenv/parse_test.ts +++ b/dotenv/parse_test.ts @@ -284,3 +284,42 @@ Deno.test("parse() result is not affected by extended Object.prototype", () => { assertEquals(result.foo, undefined); assertEquals(result.bar, "1"); }); + +Deno.test("parse() round-trips through stringify()", async (t) => { + const { stringify } = await import("./stringify.ts"); + + await t.step("basic value", () => { + const original = { HELLO: "world" }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with spaces", () => { + const original = { GREETING: "hello world" }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with single quote", () => { + const original = { PARSE: "par'se" }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with double quote (JSON-like)", () => { + const original = { JSON: '{"key":"value"}' }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with both quote types", () => { + const original = { MIXED: `a'b"c` }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with newline", () => { + const original = { MULTILINE: "hello\nworld" }; + assertEquals(parse(stringify(original)), original); + }); + + await t.step("value with backslash and quotes", () => { + const original = { BS: String.raw`test\"value` }; + assertEquals(parse(stringify(original)), original); + }); +}); diff --git a/dotenv/stringify.ts b/dotenv/stringify.ts index 63539cf7e3b4..c1bd67ec113c 100644 --- a/dotenv/stringify.ts +++ b/dotenv/stringify.ts @@ -19,7 +19,7 @@ export function stringify(object: Record): string { const lines: string[] = []; for (const [key, value] of Object.entries(object)) { - let quote; + let quote: string | undefined; let escapedValue = value ?? ""; if (key.startsWith("#")) { @@ -28,11 +28,21 @@ export function stringify(object: Record): string { `key starts with a '#' indicates a comment and is ignored: '${key}'`, ); continue; - } else if (escapedValue.includes("\n") || escapedValue.includes("'")) { - // escape inner new lines - escapedValue = escapedValue.replaceAll("\n", "\\n"); + } + + const hasNewline = escapedValue.includes("\n"); + const hasSingleQuote = escapedValue.includes("'"); + const hasDoubleQuote = escapedValue.includes('"'); + + // Use double quotes when the value contains newlines (so they can be + // expanded back) or single quotes (which are safe inside double quotes). + if (hasNewline || hasSingleQuote) { quote = `"`; - } else if (escapedValue.match(/\W/)) { + // Escape backslashes first so that existing backslashes are not + // confused with escape sequences when parsed. + escapedValue = escapedValue.replaceAll("\\", "\\\\"); + if (hasNewline) escapedValue = escapedValue.replaceAll("\n", "\\n"); + } else if (hasDoubleQuote || escapedValue.match(/\W/)) { quote = "'"; } diff --git a/dotenv/stringify_test.ts b/dotenv/stringify_test.ts index b24a17b1794c..dc03e2367793 100644 --- a/dotenv/stringify_test.ts +++ b/dotenv/stringify_test.ts @@ -83,4 +83,24 @@ Deno.test("stringify()", async (t) => { stringify({ PARSE: "par'se" }), `PARSE="par'se"`, )); + await t.step("handles double-quote characters", () => + assertEquals( + stringify({ JSON: '{"key":"value"}' }), + `JSON='{"key":"value"}'`, + )); + await t.step("handles both quote characters", () => + assertEquals( + stringify({ MIXED: `a'b"c` }), + `MIXED="a'b\\"c"`, + )); + await t.step("handles backslash with double quotes", () => + assertEquals( + stringify({ BS: String.raw`test\"value` }), + `BS="test\\\\\\"value"`, + )); + await t.step("handles newline with single quotes", () => + assertEquals( + stringify({ NL: "hello\nit's me" }), + `NL="hello\\nit's me"`, + )); });