fix: refuse a value ending in a backslash on the first run (release review)

In a single-quoted dotenv value a backslash escapes the closing quote, so a
password or data path ending in one passed validation and produced a docker/.env
Compose cannot parse. The error named an unrelated line, printed the password,
and a rerun never asked again. The env_quote comment claimed a single quote was
the only character such a value cannot hold, and the test stub's comment said
bash reads these values the way Compose does: both corrected, and a test now
reads generated values back by Compose's rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:28:01 +01:00
1 parent af6fcb624b
commit d539a7a3a2
2 files changed
+46 -2

No files matched your search

+11 -2
View File
@@ -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
+35
View File
@@ -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 });