diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh index 0b2806ce..d30737cb 100644 --- a/docker/Start-Codeman.sh +++ b/docker/Start-Codeman.sh @@ -209,8 +209,9 @@ generate_password() { # Compose reads .env values with its own dotenv rules: `$` interpolates and an # unquoted ` #` starts a comment. A single-quoted value is taken literally, so -# anything beyond plain path characters is written that way (the accept_* -# checks below refuse the one character it cannot hold, a single quote). +# anything beyond plain path characters is written that way. It cannot hold a +# single quote, and a backslash just before the closing quote escapes that quote, +# so the whole file fails to parse: the accept_* checks below refuse both. env_quote() { local re='^[A-Za-z0-9._/@:+-]*$' if [[ "$1" =~ $re ]]; then @@ -237,6 +238,10 @@ accept_appdata_path() { printf ' The path cannot contain a single quote or a line break.\n' >&2 return 1 ;; + *\\) + printf ' The path cannot end in a backslash.\n' >&2 + return 1 + ;; esac if [[ "$p" != /* ]]; then printf ' Use an absolute path, one that starts with /.\n' >&2 @@ -277,6 +282,10 @@ accept_password() { printf ' The password cannot contain a single quote or a line break.\n' >&2 return 1 ;; + *\\) + printf ' The password cannot end in a backslash.\n' >&2 + return 1 + ;; esac if ((${#1} < 8)); then printf ' Use at least 8 characters.\n' >&2 diff --git a/test/docker-start-setup.test.ts b/test/docker-start-setup.test.ts index bc71b740..05e13824 100644 --- a/test/docker-start-setup.test.ts +++ b/test/docker-start-setup.test.ts @@ -228,6 +228,21 @@ function envValue(text: string, key: string): string | undefined { return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw; } +/** + * What Compose reads from a single-quoted dotenv value, or null when it refuses + * the file with `unterminated quoted value`. A backslash there escapes the next + * character, so `'longpass\\'` never closes, while bash (and so the stub) ends + * the same value at that quote. Measured on Compose 2.33.0 and 5.5.0. + */ +function composeReads(quoted: string): string | null { + let escaped = false; + for (let i = 1; i < quoted.length; i++) { + if (quoted[i] === "'" && !escaped) return i === quoted.length - 1 ? quoted.slice(1, i) : null; + escaped = quoted[i] === '\\' && !escaped; + } + return null; +} + describe('Start-Codeman.sh first run (no docker/.env yet)', () => { it('parses under bash -n', () => { execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]); @@ -316,12 +331,32 @@ describe('Start-Codeman.sh first run (no docker/.env yet)', () => { ['a single quote', "it's-a-password"], ['the example placeholder', 'changeme'], ['fewer than 8 characters', 'short'], + ['a trailing backslash, which would escape the closing quote', 'longpass\\'], ])('refuses a preset password with %s', (_name, password) => { const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } }); expect(r.status).toBe(1); expect(r.env).toBeNull(); }); + it('refuses a data folder ending in a backslash, even behind a trailing slash', () => { + for (const path of ['/srv/data\\', '/srv/data\\/']) { + const r = runStart(['--setup-only'], { env: { CODEMAN_APPDATA_PATH: path } }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/cannot end in a backslash/); + expect(r.env).toBeNull(); + } + }); + + it.each(['mid\\dle-pass', 'pa$$ #word', 'back`tick"quote', 'tab\there'])( + 'writes %j so Compose reads it back exactly', + (password) => { + const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } }); + expect(r.status, r.stderr).toBe(0); + const line = (r.env as string).split('\n').find((l) => l.startsWith('CODEMAN_PASSWORD=')) as string; + expect(composeReads(line.slice('CODEMAN_PASSWORD='.length))).toBe(password); + } + ); + it('never rewrites an existing docker/.env', () => { const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n'; const r = runStart(['--setup-only'], { existingEnv: existing });