Skip to content

Commit d4ee337

Browse files
committed
fix(sim-setup): refuse an ambiguous or unstorable shell POSTGRES_PASSWORD
An empty export, one that differs from .env, or one .env cannot hold verbatim now stops setup with instructions instead of silently picking a value the database may not have been created with.
1 parent 991d081 commit d4ee337

2 files changed

Lines changed: 102 additions & 32 deletions

File tree

‎packages/sim-setup/src/compose-database.test.ts‎

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
LEGACY_POSTGRES_PASSWORD,
99
postgresUser,
1010
} from './compose-database'
11+
import { SetupError } from './errors'
1112

1213
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
1314
const COMPOSE_FILES = [
@@ -34,43 +35,77 @@ afterEach(() => {
3435
})
3536

3637
describe('choosePostgresPassword', () => {
38+
const noShell = {}
39+
3740
it('leaves a password already in .env alone without looking for a volume', () => {
3841
const hasDatabaseVolume = vi.fn(() => true)
3942
expect(
40-
choosePostgresPassword('in-env-file', 'sim-abc', {
41-
shellValue: 'in-shell',
42-
hasDatabaseVolume,
43-
})
43+
choosePostgresPassword('in-env-file', 'sim-abc', { shell: noShell, hasDatabaseVolume })
4444
).toBeNull()
4545
expect(hasDatabaseVolume).not.toHaveBeenCalled()
4646
})
4747

48-
it('persists a shell-exported password, which is what Compose is using', () => {
49-
const hasDatabaseVolume = vi.fn(() => true)
50-
expect(
51-
choosePostgresPassword(undefined, 'sim-abc', { shellValue: 'in-shell', hasDatabaseVolume })
52-
).toEqual({ value: 'in-shell', source: 'environment' })
53-
expect(hasDatabaseVolume).not.toHaveBeenCalled()
54-
})
55-
5648
it('generates a password for a project with no database volume yet', () => {
5749
const hasDatabaseVolume = vi.fn(() => false)
58-
const choice = choosePostgresPassword('', 'sim-abc', { shellValue: '', hasDatabaseVolume })
50+
const choice = choosePostgresPassword(undefined, 'sim-abc', {
51+
shell: noShell,
52+
hasDatabaseVolume,
53+
})
5954
expect(hasDatabaseVolume).toHaveBeenCalledWith('sim-abc')
6055
expect(choice?.source).toBe('generated')
6156
expect(choice?.value).toMatch(/^[0-9a-f]{64}$/)
6257
})
6358

6459
it('keeps the legacy password for a volume created before it was required', () => {
60+
expect(
61+
choosePostgresPassword('', 'sim-abc', { shell: noShell, hasDatabaseVolume: () => true })
62+
).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' })
63+
})
64+
65+
it('persists a shell-only password, which is what Compose is using', () => {
66+
const hasDatabaseVolume = vi.fn(() => true)
6567
expect(
6668
choosePostgresPassword(undefined, 'sim-abc', {
67-
shellValue: '',
68-
hasDatabaseVolume: () => true,
69+
shell: { POSTGRES_PASSWORD: 'in-shell' },
70+
hasDatabaseVolume,
6971
})
70-
).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' })
72+
).toEqual({ value: 'in-shell', source: 'environment' })
73+
expect(hasDatabaseVolume).not.toHaveBeenCalled()
74+
})
75+
76+
it('accepts a shell password that matches .env', () => {
77+
expect(
78+
choosePostgresPassword('same', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'same' } })
79+
).toBeNull()
7180
})
7281

73-
it('reads the shell environment by default', () => {
82+
it('refuses a shell password that differs from .env', () => {
83+
expect(() =>
84+
choosePostgresPassword('in-env-file', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'other' } })
85+
).toThrow(SetupError)
86+
})
87+
88+
it('refuses an empty shell export, which Compose would use over .env', () => {
89+
for (const envFileValue of [undefined, 'in-env-file']) {
90+
expect(() =>
91+
choosePostgresPassword(envFileValue, 'sim-abc', { shell: { POSTGRES_PASSWORD: '' } })
92+
).toThrow(/exported but empty/)
93+
}
94+
})
95+
96+
it.each(['pa ss', 'pass#word', 'pa"ss', "pa'ss", 'pa\\ss', 'pa$ss'])(
97+
'refuses to persist %s, which .env cannot store verbatim',
98+
(value) => {
99+
expect(() =>
100+
choosePostgresPassword(undefined, 'sim-abc', {
101+
shell: { POSTGRES_PASSWORD: value },
102+
hasDatabaseVolume: () => false,
103+
})
104+
).toThrow(/cannot store verbatim/)
105+
}
106+
)
107+
108+
it('reads the process environment by default', () => {
74109
vi.stubEnv('POSTGRES_PASSWORD', 'from-process')
75110
expect(choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true })).toEqual(
76111
{ value: 'from-process', source: 'environment' }

‎packages/sim-setup/src/compose-database.ts‎

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,37 +32,72 @@ export interface PostgresPasswordChoice {
3232
source: PostgresPasswordSource
3333
}
3434

35+
/**
36+
* Characters that `.env` would reinterpret — whitespace, comments, quotes,
37+
* escapes, and Compose's `$` interpolation — so a value containing one cannot
38+
* be written unquoted and read back unchanged.
39+
*/
40+
const DOTENV_UNSAFE = /[\s#'"\\$]/
41+
3542
interface ChooseOptions {
36-
/** `POSTGRES_PASSWORD` from the shell, which Compose interpolates over `.env`. */
37-
shellValue?: string
43+
/** The shell environment, whose `POSTGRES_PASSWORD` Compose interpolates over `.env`. */
44+
shell?: NodeJS.ProcessEnv
3845
hasDatabaseVolume?: (project: string) => boolean
3946
}
4047

4148
/**
4249
* Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null
43-
* when `.env` already has one. The production Compose file requires the
44-
* variable, and the value must match what the data volume was created with —
50+
* when `.env` already has the right one. The production Compose file requires
51+
* the variable, and the value must match what the data volume was created with —
4552
* Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a
4653
* wrong value locks the app out of its own database:
4754
*
48-
* - A value exported in the shell is what Compose is using, so it is persisted;
49-
* otherwise a later run without the export would fall back to a guess.
55+
* - A value exported in the shell is what Compose is using. It is persisted when
56+
* `.env` has none, so a later run without the export does not fall back to a
57+
* guess. An empty export, one that differs from `.env`, or one `.env` cannot
58+
* hold verbatim is refused: which value the volume was created with cannot be
59+
* known, and either silent choice can lock the app out.
5060
* - With no value anywhere, an existing volume was created with the legacy
5161
* password, and a project with no volume yet gets a generated one.
5262
*/
5363
export function choosePostgresPassword(
5464
envFileValue: string | undefined,
5565
project: string,
56-
{
57-
shellValue = process.env.POSTGRES_PASSWORD,
58-
hasDatabaseVolume = composeDatabaseVolumeExists,
59-
}: ChooseOptions = {}
66+
{ shell = process.env, hasDatabaseVolume = composeDatabaseVolumeExists }: ChooseOptions = {}
6067
): PostgresPasswordChoice | null {
61-
if (envFileValue) return null
62-
if (shellValue) return { value: shellValue, source: 'environment' }
63-
return hasDatabaseVolume(project)
64-
? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }
65-
: { value: generateSecret(), source: 'generated' }
68+
const shellValue = shell.POSTGRES_PASSWORD
69+
if (shellValue === undefined) {
70+
if (envFileValue) return null
71+
return hasDatabaseVolume(project)
72+
? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }
73+
: { value: generateSecret(), source: 'generated' }
74+
}
75+
if (shellValue === '') {
76+
throw new SetupError(
77+
'POSTGRES_PASSWORD is exported but empty, and Compose uses it over .env.',
78+
['unset it (unset POSTGRES_PASSWORD) so the value in .env applies']
79+
)
80+
}
81+
if (envFileValue) {
82+
if (envFileValue === shellValue) return null
83+
throw new SetupError(
84+
'POSTGRES_PASSWORD in the shell differs from the one in .env, so it is unclear which one the database was created with.',
85+
[
86+
'unset the exported POSTGRES_PASSWORD if .env holds the database password',
87+
'or set the exported value in .env if that is the database password',
88+
]
89+
)
90+
}
91+
if (DOTENV_UNSAFE.test(shellValue)) {
92+
throw new SetupError(
93+
'POSTGRES_PASSWORD is exported only in the shell, and contains characters .env cannot store verbatim.',
94+
[
95+
'add it to .env yourself, quoted, so later runs without the export still use it',
96+
'new installs: use a value from openssl rand -hex 24',
97+
]
98+
)
99+
}
100+
return { value: shellValue, source: 'environment' }
66101
}
67102

68103
/** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */

0 commit comments

Comments
 (0)