diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js index 422adbcf3..e6e80cf56 100644 --- a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js @@ -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) => { diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index af51d5274..0527715df 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -20,3 +20,8 @@ export { extractPromptVariables, extractPromptVariablesFromString } from './prompt-variables'; + +export { + jsonToDotenv, + DotenvVariable +} from './jsonToDotenv'; diff --git a/packages/bruno-common/src/utils/jsonToDotenv.spec.ts b/packages/bruno-common/src/utils/jsonToDotenv.spec.ts new file mode 100644 index 000000000..618aba367 --- /dev/null +++ b/packages/bruno-common/src/utils/jsonToDotenv.spec.ts @@ -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 => { + 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'); + }); + }); +}); diff --git a/packages/bruno-common/src/utils/jsonToDotenv.ts b/packages/bruno-common/src/utils/jsonToDotenv.ts new file mode 100644 index 000000000..a16b76aae --- /dev/null +++ b/packages/bruno-common/src/utils/jsonToDotenv.ts @@ -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'); +}; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index a25902207..b195d3582 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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 }; diff --git a/packages/bruno-electron/src/ipc/global-environments.js b/packages/bruno-electron/src/ipc/global-environments.js index 91ecdddab..d2d8ec018 100644 --- a/packages/bruno-electron/src/ipc/global-environments.js +++ b/packages/bruno-electron/src/ipc/global-environments.js @@ -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 };