diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js index e6e80cf56..23a584fb0 100644 --- a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js @@ -28,9 +28,16 @@ export const rawToVariables = (rawContent) => { const name = trimmedLine.substring(0, equalIndex).trim(); let value = trimmedLine.substring(equalIndex + 1); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + if (value.startsWith('\'') && value.endsWith('\'')) { + // Single-quoted values are fully literal in dotenv — no unescaping value = value.slice(1, -1); - value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } else if (value.startsWith('`') && value.endsWith('`')) { + // Backtick-quoted values are fully literal in dotenv — no unescaping + value = value.slice(1, -1); + } else if (value.startsWith('"') && value.endsWith('"')) { + // Double-quoted values: unescape \", \n, and \r (the escapes we produce) + value = value.slice(1, -1); + value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r'); } if (name) { diff --git a/packages/bruno-common/src/utils/jsonToDotenv.spec.ts b/packages/bruno-common/src/utils/jsonToDotenv.spec.ts index 618aba367..98b88aacd 100644 --- a/packages/bruno-common/src/utils/jsonToDotenv.spec.ts +++ b/packages/bruno-common/src/utils/jsonToDotenv.spec.ts @@ -48,50 +48,72 @@ describe('jsonToDotenv', () => { }); describe('special character handling', () => { - test('it should quote values containing hash (#)', () => { + test('it should single-quote values containing hash (#)', () => { const variables = [ { name: 'PASSWORD', value: 'ABC#DEF' }, { name: 'SECRET', value: 'key#123' } ]; const output = jsonToDotenv(variables); - expect(output).toBe('PASSWORD="ABC#DEF"\nSECRET="key#123"'); + expect(output).toBe('PASSWORD=\'ABC#DEF\'\nSECRET=\'key#123\''); }); - test('it should quote values containing newlines and escape them', () => { + test('it should backtick-quote values containing hash and single quote', () => { + const variables = [{ name: 'MIXED', value: 'it\'s#complex' }]; + const output = jsonToDotenv(variables); + expect(output).toBe('MIXED=`it\'s#complex`'); + }); + + test('it should backtick-quote values containing hash, single quote, and double quote', () => { + const variables = [{ name: 'ALL', value: 'it\'s#"complex"' }]; + const output = jsonToDotenv(variables); + expect(output).toBe('ALL=`it\'s#"complex"`'); + }); + + test('it should double-quote values containing newlines and escape them', () => { const variables = [{ name: 'MULTILINE', value: 'line1\nline2' }]; const output = jsonToDotenv(variables); expect(output).toBe('MULTILINE="line1\\nline2"'); }); - test('it should quote and escape values containing double quotes', () => { + test('it should leave values with double quotes unquoted', () => { const variables = [{ name: 'QUOTED', value: 'say "hello"' }]; const output = jsonToDotenv(variables); - expect(output).toBe('QUOTED="say \\"hello\\""'); + expect(output).toBe('QUOTED=say "hello"'); }); - test('it should quote values containing single quotes', () => { + test('it should leave values with single quotes unquoted', () => { const variables = [{ name: 'APOSTROPHE', value: 'it\'s fine' }]; const output = jsonToDotenv(variables); - expect(output).toBe('APOSTROPHE="it\'s fine"'); + expect(output).toBe('APOSTROPHE=it\'s fine'); }); - test('it should quote and escape values containing backslashes', () => { + test('it should leave values with backslashes unquoted', () => { const variables = [{ name: 'PATH', value: 'C:\\Users\\name' }]; const output = jsonToDotenv(variables); - expect(output).toBe('PATH="C:\\\\Users\\\\name"'); + expect(output).toBe('PATH=C:\\Users\\name'); }); - test('it should quote and escape values containing carriage returns', () => { + test('it should double-quote and escape values containing carriage returns', () => { const variables = [{ name: 'CR_VALUE', value: 'line1\rline2' }]; const output = jsonToDotenv(variables); expect(output).toBe('CR_VALUE="line1\\rline2"'); }); - test('it should quote and escape values containing CRLF (Windows line endings)', () => { + test('it should double-quote and escape values containing CRLF', () => { const variables = [{ name: 'CRLF_VALUE', value: 'line1\r\nline2' }]; const output = jsonToDotenv(variables); expect(output).toBe('CRLF_VALUE="line1\\r\\nline2"'); }); + + test('it should quote values with leading or trailing whitespace', () => { + const variables = [ + { name: 'LEADING', value: ' hello' }, + { name: 'TRAILING', value: 'hello ' }, + { name: 'BOTH', value: ' hello ' } + ]; + const output = jsonToDotenv(variables); + expect(output).toBe('LEADING=\' hello\'\nTRAILING=\'hello \'\nBOTH=\' hello \''); + }); }); describe('round-trip with dotenvToJson', () => { @@ -121,6 +143,20 @@ describe('jsonToDotenv', () => { expect(parsed.HASH_SPACE).toBe('value # comment-like'); }); + test('it should preserve values with backslashes through round-trip', () => { + const variables = [{ name: 'PATH', value: 'C:\\Users\\name' }]; + const serialized = jsonToDotenv(variables); + const parsed = dotenvToJson(serialized); + expect(parsed.PATH).toBe('C:\\Users\\name'); + }); + + test('it should preserve values with double quotes through round-trip', () => { + const variables = [{ name: 'QUOTED', value: 'say "hello"' }]; + const serialized = jsonToDotenv(variables); + const parsed = dotenvToJson(serialized); + expect(parsed.QUOTED).toBe('say "hello"'); + }); + test('it should preserve values with single quotes through round-trip', () => { const variables = [{ name: 'APOSTROPHE', value: 'it\'s working' }]; const serialized = jsonToDotenv(variables); @@ -128,6 +164,26 @@ describe('jsonToDotenv', () => { expect(parsed.APOSTROPHE).toBe('it\'s working'); }); + test('it should preserve values with hash, single quote, and double quote through round-trip', () => { + const variables = [{ name: 'COMPLEX', value: 'it\'s#"complex"' }]; + const serialized = jsonToDotenv(variables); + const parsed = dotenvToJson(serialized); + expect(parsed.COMPLEX).toBe('it\'s#"complex"'); + }); + + test('it should preserve values with leading/trailing whitespace through round-trip', () => { + const variables = [ + { name: 'LEADING', value: ' hello' }, + { name: 'TRAILING', value: 'hello ' }, + { name: 'BOTH', value: ' hello ' } + ]; + const serialized = jsonToDotenv(variables); + const parsed = dotenvToJson(serialized); + expect(parsed.LEADING).toBe(' hello'); + expect(parsed.TRAILING).toBe('hello '); + expect(parsed.BOTH).toBe(' hello '); + }); + test('it should preserve empty values through round-trip', () => { const variables = [{ name: 'EMPTY', value: '' }]; const serialized = jsonToDotenv(variables); @@ -147,5 +203,38 @@ describe('jsonToDotenv', () => { expect(parsed.API_SECRET).toBe('abc#def$ghi%jkl'); expect(parsed.JWT_SECRET).toBe('secret-key#with-special_chars'); }); + + test('it should not double-escape backslashes on repeated round-trips', () => { + const variables = [{ name: 'PATH', value: 'C:\\Users\\name' }]; + // Simulate multiple save/load cycles + let serialized = jsonToDotenv(variables); + let parsed = dotenvToJson(serialized); + expect(parsed.PATH).toBe('C:\\Users\\name'); + + // Second round-trip + serialized = jsonToDotenv([{ name: 'PATH', value: parsed.PATH }]); + parsed = dotenvToJson(serialized); + expect(parsed.PATH).toBe('C:\\Users\\name'); + + // Third round-trip + serialized = jsonToDotenv([{ name: 'PATH', value: parsed.PATH }]); + parsed = dotenvToJson(serialized); + expect(parsed.PATH).toBe('C:\\Users\\name'); + }); + + test('it should not double-escape double quotes on repeated round-trips', () => { + const variables = [{ name: 'VAL', value: 'say "hello"' }]; + let serialized = jsonToDotenv(variables); + let parsed = dotenvToJson(serialized); + expect(parsed.VAL).toBe('say "hello"'); + + serialized = jsonToDotenv([{ name: 'VAL', value: parsed.VAL }]); + parsed = dotenvToJson(serialized); + expect(parsed.VAL).toBe('say "hello"'); + + serialized = jsonToDotenv([{ name: 'VAL', value: parsed.VAL }]); + parsed = dotenvToJson(serialized); + expect(parsed.VAL).toBe('say "hello"'); + }); }); }); diff --git a/packages/bruno-common/src/utils/jsonToDotenv.ts b/packages/bruno-common/src/utils/jsonToDotenv.ts index a16b76aae..8786a35fb 100644 --- a/packages/bruno-common/src/utils/jsonToDotenv.ts +++ b/packages/bruno-common/src/utils/jsonToDotenv.ts @@ -9,13 +9,17 @@ export interface DotenvVariable { * This is the inverse of dotenvToJson - it converts a variables array * back to .env file content that can be parsed by the dotenv package. * - * Values containing special characters are wrapped in double quotes: - * - newlines (\n): would break the line-based format - * - carriage returns (\r): would break Windows CRLF handling - * - double quotes ("): need escaping - * - single quotes ('): need escaping - * - backslashes (\): need escaping - * - hash (#): would be interpreted as comment start by dotenv parser + * Quoting strategy based on dotenv parser behavior: + * - Unquoted: preserves \, ", ' literally. Only # is problematic (starts comment). + * - Single-quoted: everything is literal. Cannot contain single quotes. + * - Double-quoted: only \n and \r are expanded. Everything else is literal. + * + * Therefore: + * - Values with actual newlines/carriage returns → double-quote + escape \n/\r + * - Values with # but no ' → single-quote (literal) + * - Values with # and ' → backtick-quote or double-quote fallback + * - Values with leading/trailing whitespace → quote to prevent trimming + * - Everything else → unquoted (preserves \, ", ' as-is) */ export const jsonToDotenv = (variables: DotenvVariable[]): string => { if (!Array.isArray(variables)) { @@ -26,12 +30,41 @@ export const jsonToDotenv = (variables: DotenvVariable[]): string => { .filter((v) => v.name && v.name.trim() !== '') .map((v) => { const value = v.value || ''; - // If value contains special characters, wrap in quotes - if (value.includes('\n') || value.includes('\r') || value.includes('"') || value.includes('\'') || value.includes('\\') || value.includes('#')) { - // Escape backslashes first, then double quotes, then carriage returns, then newlines - const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); + + // Values with actual newlines or carriage returns must use double quotes + // since dotenv expands \n and \r only in double-quoted values + if (value.includes('\n') || value.includes('\r')) { + const escapedValue = value.replace(/\r/g, '\\r').replace(/\n/g, '\\n'); return `${v.name}="${escapedValue}"`; } + + // Hash (#) requires quoting — dotenv treats it as comment start in unquoted values + if (value.includes('#')) { + // Prefer single quotes (fully literal) + if (!value.includes('\'')) { + return `${v.name}='${value}'`; + } + // Fall back to backtick quotes (also fully literal, supports both ' and ") + if (!value.includes('`')) { + return `${v.name}=\`${value}\``; + } + // Extremely rare: value has #, ', and ` — escape " for double quotes + const escapedValue = value.replace(/"/g, '\\"'); + return `${v.name}="${escapedValue}"`; + } + + // Leading/trailing whitespace requires quoting — dotenv trims unquoted values + if (value !== value.trim()) { + if (!value.includes('\'')) { + return `${v.name}='${value}'`; + } + if (!value.includes('`')) { + return `${v.name}=\`${value}\``; + } + return `${v.name}="${value}"`; + } + + // Everything else can be unquoted — dotenv preserves \, ", ' in unquoted values return `${v.name}=${value}`; }) .join('\n');