mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
committed by
GitHub
parent
79f9dbff9f
commit
32b9f527ea
@@ -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) => {
|
||||
|
||||
@@ -20,3 +20,8 @@ export {
|
||||
extractPromptVariables,
|
||||
extractPromptVariablesFromString
|
||||
} from './prompt-variables';
|
||||
|
||||
export {
|
||||
jsonToDotenv,
|
||||
DotenvVariable
|
||||
} from './jsonToDotenv';
|
||||
|
||||
151
packages/bruno-common/src/utils/jsonToDotenv.spec.ts
Normal file
151
packages/bruno-common/src/utils/jsonToDotenv.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
38
packages/bruno-common/src/utils/jsonToDotenv.ts
Normal file
38
packages/bruno-common/src/utils/jsonToDotenv.ts
Normal 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');
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user