mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix: refine dotenv serialization for special characters handling (#7592)
This commit is contained in:
committed by
GitHub
parent
8338f91487
commit
ce105aea58
@@ -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) {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user