/** App.env parsing and sandbox override helpers for deploy conformance tests. */ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' // ── app.env handling ───────────────────────────────────────────────────────── /** * The assignment KEYS an app.env declares, read from its text. * * Deliberately the FILE's keys and not `compgen -v`'s. Sourcing app.env inside * the test process's own environment would report every inherited variable too * — the first draft of this helper returned 60+ keys for a 30-key app.env, all * but 30 of them BASHOPTS/AGENT/PWD and friends, and every downstream * "this app.env declares X" assertion became a lookup that could not fail. * The declared keys are what an app.env is accountable for. */ function appEnvKeys(appEnvPath: string): string[] { const keys: string[] = [] for (const line of readFileSync(appEnvPath, 'utf8').split('\n')) { const m = /^([A-Z_][A-Z0-9_]*)=/.exec(line) if (m) keys.push(m[1]) } return keys } /** * The VALUES app.env assigns to its own keys, read by SOURCING it in bash. * * The values are not parsed in TypeScript. app.env is shell: a value may be * double-quoted, or single-quoted to keep a `${APP_NAME}` template unexpanded * (ziptask's ASSET_PATTERN and APP_VERSION_CMD both are), and a regexp over that * would have to re-implement shell quoting to be right. Bash is the reference * implementation and is already a dependency of these suites. * * Only the keys the file declares are read back, and the read happens in a * CLEAN environment: an inherited INSTALL_DIR or DIST would otherwise be * reported as one of the app's own values, and a test asserting "this app.env * does not set INSTALL_DIR" would be reading the test runner's. */ export function readAppEnvValues(appEnvPath: string): Record { const keys = appEnvKeys(appEnvPath) if (keys.length === 0) throw new Error(`${appEnvPath} declares no keys`) // NUL-separated, not newline-separated: a value may legitimately contain a // newline (a multi-line PEM in an env file), and a newline-separated read // would silently truncate it and report the tail as an unrelated key. const res = spawnBash(`set -u; . "$1"; for k in $2; do printf '%s=%s\\0' "$k" "\${!k-}"; done`, [ appEnvPath, keys.join(' ') ]) if (res.status !== 0) { throw new Error(`cannot source ${appEnvPath}: ${res.stderr.trim() || res.stdout.trim()}`) } const values: Record = {} for (const record of res.stdout.split('\0')) { if (!record) continue const eq = record.indexOf('=') if (eq < 0) continue values[record.slice(0, eq)] = record.slice(eq + 1) } const absent = keys.filter(k => !(k in values)) if (absent.length > 0) throw new Error(`${appEnvPath}: keys vanished while sourcing: ${absent.join(', ')}`) return values } function spawnBash( script: string, args: string[] ): { status: number | null; stdout: string; stderr: string } { const res = spawnSync('bash', ['-c', script, 'app-targets', ...args], { encoding: 'utf8', timeout: 30_000 }) return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } } /** * The app.env text with `overrides` applied, comments and everything else kept. * * A key already present has its WHOLE line replaced (the assignment, never the * comments around it); a key that is absent is appended. Rewriting the file * rather than the environment is load-bearing — see the header note on * load_app_env() sourcing app.env over the caller's environment. * * New values are double-quoted. `quote()` rejects a value that would change * meaning inside double quotes (`"`, `$`, a backtick), so an override can never * smuggle a substitution into the app.env it is written into. */ export function sandboxAppEnvText(appEnvPath: string, overrides: Record): string { const lines = readFileSync(appEnvPath, 'utf8').split('\n') const pending = new Map() for (const [key, value] of Object.entries(overrides)) { pending.set(key, `${key}=${quote(value)}`) } const out: string[] = [] for (const line of lines) { const m = /^([A-Z_][A-Z0-9_]*)=/.exec(line) const replacement = m ? pending.get(m[1]) : undefined if (replacement !== undefined) { out.push(replacement) pending.delete(m?.[1] ?? '') } else { out.push(line) } } for (const line of pending.values()) out.push(line) return out.join('\n') } /** * POSIX single-quoting for a double-quoted shell value's interior — which is * what every app.env key here uses, and what makes an override's text survive * as literal characters. * * `"`, `$` and a backtick are refused rather than escaped. An escape would * silently change the value the framework reads, and a test asserting on the * app's own keys would then be asserting on a fixture's rewrite of them. The * overrides this helper ever receives are temp paths and health URLs. * * A NEWLINE is deliberately allowed: two of the unit-extra rejection tests set a * value that systemd's parser would fold, and a quote() that refused it would * make those tests assert about a value the app could never actually ship. */ function quote(value: string): string { if (/["$`]/.test(value)) { throw new Error( `sandbox override refuses the value ${JSON.stringify(value)}: " $ \` change meaning inside double quotes` ) } return `"${value}"` } /** * Every path-shaped override must land inside the fixture root. * * The check is against the fixture root AND the host's real application * directories, because those are the two ways a sandbox escapes: an override * computed from the wrong root, and an override copied verbatim out of a shipped * app.env (/opt/, /var/lib/). Throwing at BUILD time is the point — * by the time a script has written to a host path, the write already happened. */ export function assertSandboxed( overrides: Record, root: string, keys: readonly string[], forbidden: readonly string[] ): void { const realRoot = root.endsWith('/') ? root.slice(0, -1) : root for (const key of keys) { const value = overrides[key] if (value === undefined || value === '') continue if (!value.startsWith(`${realRoot}/`) && value !== realRoot) { throw new Error(`sandbox override ${key}=${value} is outside the fixture root ${realRoot}`) } for (const bad of forbidden) { if (value === bad || value.startsWith(`${bad}/`)) { throw new Error(`sandbox override ${key}=${value} points at the host path ${bad}`) } } } }