fix: quote values containing hash (#) in .env file serialization (#7380)

* fix: quote values containing hash (#) in .env file serialization

Values containing # characters were being truncated when saved to .env
files because the dotenv parser interprets # as a comment character.

This fix adds a shared jsonToDotenv utility in bruno-common that properly
quotes values containing special characters (#, \n, ", ', \) to ensure
they are preserved through serialization and parsing.

- Add jsonToDotenv utility with comprehensive test coverage
- Update bruno-electron and bruno-app to use shared utility
- Remove duplicate serialization logic from multiple locations

Fixes https://github.com/usebruno/bruno/issues/7375
Fixes https://github.com/usebruno/bruno/issues/7327

* fix: escape carriage returns in .env file serialization for Windows CRLF handling

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
Chirag Chandrashekhar
2026-03-23 20:58:28 +05:30
committed by GitHub
parent 79f9dbff9f
commit 32b9f527ea
6 changed files with 200 additions and 43 deletions

View File

@@ -1,17 +1,8 @@
import { uuid } from 'utils/common';
import { utils } from '@usebruno/common';
export const variablesToRaw = (variables) => {
return variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
return utils.jsonToDotenv(variables);
};
export const rawToVariables = (rawContent) => {

View File

@@ -20,3 +20,8 @@ export {
extractPromptVariables,
extractPromptVariablesFromString
} from './prompt-variables';
export {
jsonToDotenv,
DotenvVariable
} from './jsonToDotenv';

View File

@@ -0,0 +1,151 @@
import { jsonToDotenv } from './jsonToDotenv';
import * as dotenv from 'dotenv';
// Helper to parse .env content using dotenv package
const dotenvToJson = (content: string): Record<string, string> => {
return dotenv.parse(Buffer.from(content));
};
describe('jsonToDotenv', () => {
describe('basic serialization', () => {
test('it should serialize simple variables', () => {
const variables = [
{ name: 'FOO', value: 'bar' },
{ name: 'BAZ', value: 'qux' }
];
const output = jsonToDotenv(variables);
expect(output).toBe('FOO=bar\nBAZ=qux');
});
test('it should filter out variables with empty names', () => {
const variables = [
{ name: 'VALID', value: 'value' },
{ name: '', value: 'ignored' },
{ name: ' ', value: 'also ignored' }
];
const output = jsonToDotenv(variables);
expect(output).toBe('VALID=value');
});
test('it should handle empty values', () => {
const variables = [
{ name: 'EMPTY', value: '' },
{ name: 'UNDEFINED', value: undefined }
];
const output = jsonToDotenv(variables);
expect(output).toBe('EMPTY=\nUNDEFINED=');
});
test('it should return empty string for empty array', () => {
expect(jsonToDotenv([])).toBe('');
});
test('it should return empty string for non-array input', () => {
expect(jsonToDotenv(null as any)).toBe('');
expect(jsonToDotenv(undefined as any)).toBe('');
expect(jsonToDotenv({} as any)).toBe('');
});
});
describe('special character handling', () => {
test('it should 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"');
});
test('it should 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', () => {
const variables = [{ name: 'QUOTED', value: 'say "hello"' }];
const output = jsonToDotenv(variables);
expect(output).toBe('QUOTED="say \\"hello\\""');
});
test('it should quote values containing single quotes', () => {
const variables = [{ name: 'APOSTROPHE', value: 'it\'s fine' }];
const output = jsonToDotenv(variables);
expect(output).toBe('APOSTROPHE="it\'s fine"');
});
test('it should quote and escape values containing backslashes', () => {
const variables = [{ name: 'PATH', value: 'C:\\Users\\name' }];
const output = jsonToDotenv(variables);
expect(output).toBe('PATH="C:\\\\Users\\\\name"');
});
test('it should 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)', () => {
const variables = [{ name: 'CRLF_VALUE', value: 'line1\r\nline2' }];
const output = jsonToDotenv(variables);
expect(output).toBe('CRLF_VALUE="line1\\r\\nline2"');
});
});
describe('round-trip with dotenvToJson', () => {
test('it should preserve simple values through round-trip', () => {
const variables = [
{ name: 'FOO', value: 'bar' },
{ name: 'BAZ', value: 'qux123' }
];
const serialized = jsonToDotenv(variables);
const parsed = dotenvToJson(serialized);
expect(parsed.FOO).toBe('bar');
expect(parsed.BAZ).toBe('qux123');
});
test('it should preserve values with hash (#) through round-trip', () => {
const variables = [
{ name: 'PASSWORD', value: 'ABC#DEF' },
{ name: 'API_KEY', value: 'key#123#456' },
{ name: 'HASH_START', value: '#startsWithHash' },
{ name: 'HASH_SPACE', value: 'value # comment-like' }
];
const serialized = jsonToDotenv(variables);
const parsed = dotenvToJson(serialized);
expect(parsed.PASSWORD).toBe('ABC#DEF');
expect(parsed.API_KEY).toBe('key#123#456');
expect(parsed.HASH_START).toBe('#startsWithHash');
expect(parsed.HASH_SPACE).toBe('value # comment-like');
});
test('it should preserve values with single quotes through round-trip', () => {
const variables = [{ name: 'APOSTROPHE', value: 'it\'s working' }];
const serialized = jsonToDotenv(variables);
const parsed = dotenvToJson(serialized);
expect(parsed.APOSTROPHE).toBe('it\'s working');
});
test('it should preserve empty values through round-trip', () => {
const variables = [{ name: 'EMPTY', value: '' }];
const serialized = jsonToDotenv(variables);
const parsed = dotenvToJson(serialized);
expect(parsed.EMPTY).toBe('');
});
test('it should handle complex real-world passwords', () => {
const variables = [
{ name: 'DB_PASSWORD', value: 'P@ss#w0rd!123' },
{ name: 'API_SECRET', value: 'abc#def$ghi%jkl' },
{ name: 'JWT_SECRET', value: 'secret-key#with-special_chars' }
];
const serialized = jsonToDotenv(variables);
const parsed = dotenvToJson(serialized);
expect(parsed.DB_PASSWORD).toBe('P@ss#w0rd!123');
expect(parsed.API_SECRET).toBe('abc#def$ghi%jkl');
expect(parsed.JWT_SECRET).toBe('secret-key#with-special_chars');
});
});
});

View File

@@ -0,0 +1,38 @@
export interface DotenvVariable {
name: string;
value?: string;
}
/**
* Serializes an array of environment variables to .env file format.
*
* 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
*/
export const jsonToDotenv = (variables: DotenvVariable[]): string => {
if (!Array.isArray(variables)) {
return '';
}
return variables
.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');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
};

View File

@@ -21,6 +21,7 @@ const {
DEFAULT_COLLECTION_FORMAT
} = require('@usebruno/filestore');
const { dotenvToJson } = require('@usebruno/lang');
const { utils } = require('@usebruno/common');
const brunoConverters = require('@usebruno/converters');
const { postmanToBruno } = brunoConverters;
const { cookiesStore } = require('../store/cookies');
@@ -671,22 +672,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
const dotEnvPath = path.join(collectionPathname, filename);
// Convert variables array to .env format
const content = variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
// If value contains newlines or special characters, wrap in quotes
if (value.includes('\n') || value.includes('"') || value.includes('\'') || value.includes('\\')) {
// Escape backslashes first, then double quotes
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
const content = utils.jsonToDotenv(variables);
await writeFile(dotEnvPath, content);
return { success: true };

View File

@@ -2,6 +2,7 @@ require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { ipcMain } = require('electron');
const { utils: { jsonToDotenv } } = require('@usebruno/common');
const { globalEnvironmentsStore } = require('../store/global-environments');
const { generateUniqueName, sanitizeName, writeFile, isValidDotEnvFilename } = require('../utils/filesystem');
@@ -114,22 +115,7 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager)
}
const dotEnvPath = path.join(workspacePath, filename);
// Convert variables array to .env format
const content = variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
// If value contains newlines or special characters, wrap in quotes
if (value.includes('\n') || value.includes('"') || value.includes('\'') || value.includes('\\')) {
// Escape backslashes first, then double quotes
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
const content = jsonToDotenv(variables);
await writeFile(dotEnvPath, content);
return { success: true };