Skip to content

Commit 03bf275

Browse files
authored
fix(sim-setup): quote env values containing $ so dotenv, Bun, and Compose don't expand them (#9861)
* fix(sim-setup): quote env values containing $ so dotenv, Bun, and Compose don't expand them * fix(sim-setup): write $ as \$ in double quotes, the one form Next, Bun, and Compose all read literally
1 parent a12832d commit 03bf275

2 files changed

Lines changed: 34 additions & 6 deletions

File tree

‎packages/sim-setup/src/env-files.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import path from 'node:path'
4+
import { loadEnvConfig } from '@next/env'
45
import { afterEach, describe, expect, it } from 'vitest'
56
import { parseEnv, reconcileEnvContent, upsertEnv, writeEnvFile } from './env-files'
67

@@ -21,13 +22,29 @@ describe('upsertEnv', () => {
2122
).toThrow('Duplicate active RESEND_API_KEY entries')
2223
})
2324

24-
it.each(['ab #cd', 'a#b', ' padded ', "'starts-with-quote", "it's #1"])(
25+
it.each(['ab #cd', 'a#b', ' padded ', "'starts-with-quote", "it's #1", "it's $5 #1"])(
2526
'reads back %j exactly as it was written',
2627
(value) => {
2728
expect(parseEnv(upsertEnv('', 'SECRET', value)).get('SECRET')).toBe(value)
2829
}
2930
)
3031

32+
it.each(['pa$$word', 'key$HOME', `key\${HOME}suffix`, "it's $5"])(
33+
'Next loads %j back exactly as it was written',
34+
(value) => {
35+
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-env-expand-'))
36+
roots.push(root)
37+
writeFileSync(path.join(root, '.env'), upsertEnv('', 'SIM_SETUP_EXPAND_PROBE', value))
38+
const silent = { info: () => {}, error: () => {} }
39+
try {
40+
const { parsedEnv } = loadEnvConfig(root, false, silent, true)
41+
expect(parsedEnv?.SIM_SETUP_EXPAND_PROBE).toBe(value)
42+
} finally {
43+
Reflect.deleteProperty(process.env, 'SIM_SETUP_EXPAND_PROBE')
44+
}
45+
}
46+
)
47+
3148
it('writes an ordinary value without quotes', () => {
3249
const dsn = 'postgresql://postgres:p%23w@127.0.0.1:5432/simstudio'
3350
expect(upsertEnv('', 'DATABASE_URL', dsn)).toBe(`DATABASE_URL=${dsn}\n`)

‎packages/sim-setup/src/env-files.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ function parseValue(raw: string): string {
6767
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
6868
const quote = trimmed[0]
6969
const end = trimmed.indexOf(quote, 1)
70-
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
70+
const inner = end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
71+
return quote === '"' ? inner.replaceAll('\\$', '$') : inner
7172
}
7273
return trimmed.replace(/\s+#.*$/, '').trim()
7374
}
@@ -89,17 +90,27 @@ export function readEnvFile(target: EnvTarget): EnvFile {
8990
}
9091

9192
/**
92-
* Quotes a value only when `#`, edge whitespace, or a leading quote would change
93-
* it for `parseValue`, Next's dotenv, Bun, or Docker Compose. Single quotes are
94-
* preferred because all four read them literally.
93+
* Quotes a value only when `#`, `$`, edge whitespace, or a leading quote would
94+
* change it for `parseValue`, Next's dotenv, Bun, or Docker Compose. Single quotes
95+
* are preferred because all four read them literally, except `$`: Next and Bun
96+
* expand `$NAME` even inside single quotes, and the one spelling all four read as a
97+
* literal `$` is `\$` inside double quotes.
9598
*/
9699
function formatEnvValue(key: string, value: string): string {
97100
if (/[\r\n]/.test(value)) {
98101
throw new Error(`${key} cannot be written to an environment file: it contains a line break`)
99102
}
103+
if (value.includes('$')) {
104+
if (/["\\`]/.test(value)) {
105+
throw new Error(
106+
`${key} cannot be written to an environment file: it mixes $ with a double quote, backslash, or backtick`
107+
)
108+
}
109+
return `"${value.replaceAll('$', '\\$')}"`
110+
}
100111
if (value === value.trim() && !value.includes('#') && !/^["'`]/.test(value)) return value
101112
if (!value.includes("'")) return `'${value}'`
102-
if (!/["\\$`]/.test(value)) return `"${value}"`
113+
if (!/["\\`]/.test(value)) return `"${value}"`
103114
throw new Error(
104115
`${key} cannot be written to an environment file: it mixes quotes with a # or edge whitespace`
105116
)

0 commit comments

Comments
 (0)