From 4babce5b8fa65cc3a881b9b9e01cdd28e5313fad Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 11:51:05 -0400 Subject: [PATCH] test(e2e): add headless bootstrap negative controls Extend the portable bootstrap fixture (#146/#147) with a hosted docker-e2e scenario, `portable-bootstrap-negatives`, for HACK-1143. On the slim path it drives `hack up --detach app` through eleven ordered stages against a locked-v1 dependency cache with a frozen installer and an app-scoped encrypted synthetic token: - missing key, wrong key and an overlay sealed under another key refuse before any owned container, network or volume exists; - two identical bootstraps keep the same consumer, cache and index, initialize once and leave no worktree diff; - a wrong key, a stale pinned image, a lockfile that no longer matches its manifest and a corrupted ready marker each refuse without touching the running consumer, pulling, rewriting the lockfile or reinstalling; - a changed lockfile and a new cache generation select new caches and keep the previous ones. Each stage records a value-free receipt (duration, output bytes, tracked bytes, mounted-cache bytes, cache-volume count, consumer transition). Pure stage expectations and receipt validation live in tests/e2e/portable-bootstrap-controls.ts with unit tests. A local test runs the source CLI on the same fixture against a recording Docker stub to show the env refusals precede any Compose mutation. WU12 storage admission, retention and archives remain out of scope. Co-Authored-By: Claude Opus 5.5 --- .../portable-bootstrap-qualification.md | 12 + scripts/portable-bootstrap-smoke.ts | 5 +- tests/e2e-portable-bootstrap-controls.test.ts | 221 ++++++ tests/e2e/README.md | 58 ++ tests/e2e/portable-bootstrap-controls.ts | 385 +++++++++ tests/e2e/run.ts | 2 + .../scenarios/portable-bootstrap-negatives.ts | 742 ++++++++++++++++++ tests/e2e/shard.ts | 2 + tests/portable-bootstrap-env-refusal.test.ts | 211 +++++ 9 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 tests/e2e-portable-bootstrap-controls.test.ts create mode 100644 tests/e2e/portable-bootstrap-controls.ts create mode 100644 tests/e2e/scenarios/portable-bootstrap-negatives.ts create mode 100644 tests/portable-bootstrap-env-refusal.test.ts diff --git a/docs/guides/portable-bootstrap-qualification.md b/docs/guides/portable-bootstrap-qualification.md index 709490027..218c32162 100644 --- a/docs/guides/portable-bootstrap-qualification.md +++ b/docs/guides/portable-bootstrap-qualification.md @@ -56,6 +56,18 @@ available container capabilities, and any skips. Review the tested Install script and Start skill before publishing the private environment. A local or ordinary Linux-container result does not qualify Codex Cloud by itself. +## Negative controls in Docker CI + +The hosted `docker-e2e` job also runs the `portable-bootstrap-negatives` scenario. +On the slim path, it checks that a missing key, a wrong key, an overlay sealed +under another key, a stale pinned image, a lockfile that no longer matches its +manifest and a corrupted dependency cache each stop the bootstrap without +changing a running app. It also checks that a second identical bootstrap leaves +no diff and does not reinstall. Each stage records its time and byte counts in a +value-free receipt. See +[the e2e harness guide](../../tests/e2e/README.md#portable-bootstrap-negative-controls) +for the stages and what they do not cover. + ## Repeat maintenance `scripts/maintain-codex-slim.sh` installs dependencies with diff --git a/scripts/portable-bootstrap-smoke.ts b/scripts/portable-bootstrap-smoke.ts index 204b102ec..7ba1ac508 100644 --- a/scripts/portable-bootstrap-smoke.ts +++ b/scripts/portable-bootstrap-smoke.ts @@ -204,7 +204,10 @@ function isRecord(value: unknown): value is Record { * v1:base64(iv):base64(tag):base64(ciphertext), AES-256-GCM, SHA-256(key text). * The synthetic key and plaintext never become fixture files or CLI arguments. */ -function encryptSynthetic(opts: { key: string; secret: string }): string { +export function encryptSynthetic(opts: { + key: string; + secret: string; +}): string { const iv = randomBytes(12); const cipher = createCipheriv( "aes-256-gcm", diff --git a/tests/e2e-portable-bootstrap-controls.test.ts b/tests/e2e-portable-bootstrap-controls.test.ts new file mode 100644 index 000000000..bda7f0687 --- /dev/null +++ b/tests/e2e-portable-bootstrap-controls.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { createDecipheriv, createHash } from "node:crypto"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_EXPECTATIONS, + PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + PORTABLE_BOOTSTRAP_STAGES, + renderPortableBootstrapReceipt, + type StageReceipt, + unexpectedWorktreeChanges, +} from "./e2e/portable-bootstrap-controls.ts"; +import { ALL_SCENARIOS } from "./e2e/run.ts"; +import { E2E_DURATION_HINTS_SECONDS } from "./e2e/shard.ts"; + +const running = (id: string, startedAt = "t1"): AppIdentity => ({ + id, + startedAt, + running: true, +}); + +function receipt( + stage: (typeof PORTABLE_BOOTSTRAP_STAGES)[number] +): StageReceipt { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + return { + stage, + outcome: expected.outcome, + app: expected.app, + durationMs: 1200, + stdoutBytes: 10, + stderrBytes: 20, + trackedBytes: 4096, + cacheBytes: expected.app === "absent" ? null : 128, + cacheVolumes: 1, + }; +} + +const complete = () => PORTABLE_BOOTSTRAP_STAGES.map(receipt); + +describe("portable bootstrap negative controls", () => { + test("is registered for the hosted Docker tier with a shard hint", () => { + const scenario = ALL_SCENARIOS.find( + (entry) => entry.name === "portable-bootstrap-negatives" + ); + expect(scenario?.tier).toBe("docker"); + expect(scenario?.requiresExplicitSelection).toBeUndefined(); + expect( + Object.hasOwn(E2E_DURATION_HINTS_SECONDS, "portable-bootstrap-negatives") + ).toBe(true); + }); + + test("every refusal leaves the consumer absent or unchanged", () => { + for (const stage of PORTABLE_BOOTSTRAP_STAGES) { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + expect(stage.startsWith("refuse-")).toBe(expected.outcome === "refused"); + if (expected.outcome === "refused") { + expect(["absent", "unchanged"]).toContain(expected.app); + } + } + expect(PORTABLE_BOOTSTRAP_EXPECTATIONS["bootstrap-repeat"].app).toBe( + "unchanged" + ); + }); + + test("classifies consumer identity by container ID and start time", () => { + expect(classifyAppTransition({ before: null, after: null })).toBe("absent"); + expect(classifyAppTransition({ before: null, after: running("a") })).toBe( + "created" + ); + expect( + classifyAppTransition({ before: running("a"), after: running("a") }) + ).toBe("unchanged"); + expect( + classifyAppTransition({ before: running("a"), after: running("b") }) + ).toBe("replaced"); + // A restart in place is not "unchanged": the refusal touched the consumer. + expect( + classifyAppTransition({ + before: running("a"), + after: running("a", "t2"), + }) + ).toBe("replaced"); + expect(classifyAppTransition({ before: running("a"), after: null })).toBe( + "removed" + ); + }); + + test("checks exit, consumer transition and liveness per stage", () => { + const ok = { + stage: "refuse-stale-image" as const, + exitCode: 1, + timedOut: false, + transition: "unchanged" as const, + running: true, + }; + expect(checkStageExpectation(ok)).toBeNull(); + expect(checkStageExpectation({ ...ok, exitCode: 0 })).toBe( + "expected refused, observed bootstrapped" + ); + expect(checkStageExpectation({ ...ok, transition: "replaced" })).toBe( + "expected consumer unchanged, observed replaced" + ); + expect(checkStageExpectation({ ...ok, running: false })).toBe( + "consumer must be running after the stage" + ); + expect(checkStageExpectation({ ...ok, timedOut: true })).toBe( + "command timed out" + ); + expect( + checkStageExpectation({ + stage: "refuse-missing-key", + exitCode: 1, + timedOut: false, + transition: "absent", + running: false, + }) + ).toBeNull(); + expect( + checkStageExpectation({ + stage: "bootstrap-repeat", + exitCode: 0, + timedOut: false, + transition: "replaced", + running: true, + }) + ).toBe("expected consumer unchanged, observed replaced"); + }); + + test("reports worktree paths outside the allowed set", () => { + const porcelain = [ + "?? .hack/.gitignore", + " M package.json", + "R old.txt -> new.txt", + "", + ].join("\n"); + expect(unexpectedWorktreeChanges({ porcelain: "", allowed: [] })).toEqual( + [] + ); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore", "package.json", "new.txt"], + }) + ).toEqual([]); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore"], + }) + ).toEqual(["new.txt", "package.json"]); + }); + + test("detects forbidden values and ignores empty entries", () => { + expect( + containsForbiddenValue({ texts: ["ok", "has-key-x"], forbidden: ["key"] }) + ).toBe(true); + expect( + containsForbiddenValue({ texts: ["ok"], forbidden: ["", "absent"] }) + ).toBe(false); + }); + + test("renders a complete value-free receipt", () => { + const parsed: unknown = JSON.parse( + renderPortableBootstrapReceipt({ stages: complete(), imageBytes: 7 }) + ); + expect(parsed).toEqual({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: 7, + stages: complete(), + }); + }); + + test("refuses incomplete, reordered or open-ended receipts", () => { + const render = + (stages: readonly unknown[], imageBytes: unknown = 1) => + () => + renderPortableBootstrapReceipt({ stages, imageBytes }); + const message = /Invalid portable bootstrap receipt/; + expect(render(complete().slice(1))).toThrow(message); + expect(render(complete().reverse())).toThrow(message); + expect(render(complete(), -1)).toThrow(message); + expect(render(complete(), 1.5)).toThrow(message); + const mutate = (patch: Record) => + complete().map((entry, index) => + index === 4 ? { ...entry, ...patch } : entry + ); + expect(render(mutate({ outcome: "skipped" }))).toThrow(message); + expect(render(mutate({ app: "restarted" }))).toThrow(message); + expect(render(mutate({ durationMs: -1 }))).toThrow(message); + expect(render(mutate({ stdoutBytes: 0.5 }))).toThrow(message); + expect(render(mutate({ cacheBytes: "128" }))).toThrow(message); + expect(render(mutate({ detail: "free text" }))).toThrow(message); + const missing = complete().map(({ cacheVolumes: _, ...entry }) => entry); + expect(render(missing)).toThrow(message); + expect(render([...complete().slice(0, -1), null])).toThrow(message); + }); + + test("synthetic env values use the project v1 envelope", () => { + const key = "synthetic-key"; + const sealed = encryptSynthetic({ key, secret: "synthetic-value" }); + const [prefix, iv, tag, ciphertext] = sealed.split(":"); + expect(prefix).toBe("v1"); + const decipher = createDecipheriv( + "aes-256-gcm", + createHash("sha256").update(key).digest(), + Buffer.from(iv ?? "", "base64") + ); + decipher.setAuthTag(Buffer.from(tag ?? "", "base64")); + expect( + Buffer.concat([ + decipher.update(Buffer.from(ciphertext ?? "", "base64")), + decipher.final(), + ]).toString("utf8") + ).toBe("synthetic-value"); + expect(sealed).not.toContain("synthetic-value"); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 6ae45edea..332dec79a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -205,6 +205,64 @@ data survive that cleanup. The canary is then removed separately. PostgreSQL use fixture-only trust on this unexposed network. This qualifies the actual execution host, not Codex Cloud capabilities, browser access or native VM support elsewhere. +## Portable bootstrap negative controls + +`portable-bootstrap-negatives` (HACK-1143) adds negative controls to the portable +bootstrap fixture. It runs the selected CLI in slim mode with cached `alpine:3.20` +pinned by immutable ID, `network_mode: none`, no published ports and no pulls. +A `deps` producer fills a `locked-v1` dependency cache. Its install is frozen: it +fails when the lockfile's manifest digest no longer matches `package.json`, and the +workspace is mounted read-only. The `app` consumer mounts the cache read-only. It +copies the cache artifact only after a synthetic encrypted token, scoped to `app`, +matches the expected digest, so a loaded artifact proves env injection without the +scenario reading the value. + +Every stage runs `hack up --detach app` and must give this result: + +| Stage | Change before the stage | Required result | +| --- | --- | --- | +| `refuse-missing-key` | no key | refused; no owned container, network or volume; only the managed `.hack/.gitignore` may appear | +| `refuse-wrong-key` | wrong key | refused; nothing created; no worktree diff | +| `refuse-wrong-overlay` | `--env drift`, an overlay sealed under another key | refused; nothing created; no worktree diff | +| `bootstrap-cold` | correct key | consumer created; one initialization; one ready cache; no diff | +| `bootstrap-repeat` | none | same consumer ID and start time; same cache; still one initialization; same index; no diff | +| `refuse-wrong-key-running` | wrong key | refused; consumer unchanged | +| `refuse-stale-image` | both services pinned to an absent image ID | refused; consumer unchanged; the stale ID is not pulled and the cached tag keeps its ID | +| `refuse-frozen-lockfile` | `package.json` no longer matches the lockfile | refused; consumer unchanged; lockfile bytes kept; the new cache has an attempt but no ready marker | +| `bootstrap-changed-lockfile` | committed lockfile revision | consumer replaced on a new cache initialized once; the previous ready cache is kept | +| `refuse-corrupted-cache` | the current ready marker is overwritten | refused; consumer unchanged; no reinstall; the corrupted marker is not rewritten | +| `bootstrap-new-generation` | `hack.dependencies.cache-generation` bumped | consumer replaced on a new cache; the corrupted cache is kept for inspection | + +Unchanged means the same full container ID and `State.StartedAt`. CLI output and +Git-tracked files must never contain the keys or token values, and the scenario +reads only fixed container fields because the consumer's environment holds the +token. Cleanup checks exact owner and Compose project labels, and an adjacent +canary volume must survive it. + +The scenario ends with one value-free line, `portable-bootstrap receipt {...}`. For +each stage it records the outcome, the consumer transition, the command duration, +stdout/stderr bytes, Git-tracked fixture bytes, the bytes in the consumer's mounted +cache and the number of owned cache volumes. It also records the pinned image +size. It contains no env values, keys, resource IDs, image IDs or paths; +`tests/e2e/portable-bootstrap-controls.ts` refuses any other shape. + +```sh +HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=portable-bootstrap-negatives +``` + +Two local tests need no Docker engine. `tests/portable-bootstrap-env-refusal.test.ts` +runs the source CLI on the same fixture against a recording Docker stub: a missing +key, a wrong key and a wrong overlay key must fail before any Compose `create`, +`run`, `start` or `up`, and the correct key must reach the `deps` bootstrap first. +`tests/e2e-portable-bootstrap-controls.test.ts` covers the stage expectations, +consumer classification, worktree diff check and receipt validation. + +This does not cover WU12 storage: mount and free-space admission, generation holds, +reclamation and archives. Old cache volumes are kept, never reclaimed. A stale +image here is a pinned ID that is absent locally, not a baked-tool manifest +mismatch. A pass qualifies the hosted Linux Docker runner, not native VM support or +a cloud provider. + ## Compose config-hash drift (F03) `compose-config-hash-drift` (HACK-1124) checks that `hack run` and `hack exec` diff --git a/tests/e2e/portable-bootstrap-controls.ts b/tests/e2e/portable-bootstrap-controls.ts new file mode 100644 index 000000000..da949d027 --- /dev/null +++ b/tests/e2e/portable-bootstrap-controls.ts @@ -0,0 +1,385 @@ +/** + * Pure decisions and value-free receipts for the `portable-bootstrap-negatives` + * Docker scenario. Everything here is free of I/O so the stage expectations, + * app-identity classification, worktree-diff checks and receipt shape can be + * exercised (and mutation-tested) without a Docker engine. + * + * Receipts carry only closed stage/outcome/transition names and non-negative + * integers. They never include env values, keys, secrets, command output, + * resource IDs, image IDs or file paths. + */ + +/** Canonical stage order; a receipt must contain each stage exactly once. */ +export const PORTABLE_BOOTSTRAP_STAGES = [ + "refuse-missing-key", + "refuse-wrong-key", + "refuse-wrong-overlay", + "bootstrap-cold", + "bootstrap-repeat", + "refuse-wrong-key-running", + "refuse-stale-image", + "refuse-frozen-lockfile", + "bootstrap-changed-lockfile", + "refuse-corrupted-cache", + "bootstrap-new-generation", +] as const; + +export type PortableBootstrapStage = (typeof PORTABLE_BOOTSTRAP_STAGES)[number]; + +export type StageOutcome = "refused" | "bootstrapped"; + +/** How the single owned consumer container changed across one stage. */ +export type AppTransition = + | "absent" + | "created" + | "unchanged" + | "replaced" + | "removed"; + +export type AppIdentity = { + readonly id: string; + readonly startedAt: string; + readonly running: boolean; +} | null; + +type StageExpectation = { + readonly outcome: StageOutcome; + readonly app: Exclude; +}; + +/** + * Every refusal must leave the consumer exactly as it was: absent before the + * first successful bootstrap, otherwise the same running container. A repeat + * bootstrap must not recreate it; only a changed cache identity may replace it. + */ +export const PORTABLE_BOOTSTRAP_EXPECTATIONS: Readonly< + Record +> = { + "refuse-missing-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-overlay": { outcome: "refused", app: "absent" }, + "bootstrap-cold": { outcome: "bootstrapped", app: "created" }, + "bootstrap-repeat": { outcome: "bootstrapped", app: "unchanged" }, + "refuse-wrong-key-running": { outcome: "refused", app: "unchanged" }, + "refuse-stale-image": { outcome: "refused", app: "unchanged" }, + "refuse-frozen-lockfile": { outcome: "refused", app: "unchanged" }, + "bootstrap-changed-lockfile": { outcome: "bootstrapped", app: "replaced" }, + "refuse-corrupted-cache": { outcome: "refused", app: "unchanged" }, + "bootstrap-new-generation": { outcome: "bootstrapped", app: "replaced" }, +}; + +/** Same container ID and start time means the consumer was not touched. */ +export function classifyAppTransition(opts: { + readonly before: AppIdentity; + readonly after: AppIdentity; +}): AppTransition { + const { before, after } = opts; + if (before === null) { + return after === null ? "absent" : "created"; + } + if (after === null) { + return "removed"; + } + return before.id === after.id && before.startedAt === after.startedAt + ? "unchanged" + : "replaced"; +} + +/** + * Compare one observed stage with its fixed expectation. Returns a fixed, + * value-free failure reason, or null when the stage behaved as required. + */ +export function checkStageExpectation(opts: { + readonly stage: PortableBootstrapStage; + readonly exitCode: number; + readonly timedOut: boolean; + readonly transition: AppTransition; + readonly running: boolean; +}): string | null { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[opts.stage]; + if (opts.timedOut) { + return "command timed out"; + } + const outcome: StageOutcome = + opts.exitCode === 0 ? "bootstrapped" : "refused"; + if (outcome !== expected.outcome) { + return `expected ${expected.outcome}, observed ${outcome}`; + } + if (opts.transition !== expected.app) { + return `expected consumer ${expected.app}, observed ${opts.transition}`; + } + if (expected.app !== "absent" && !opts.running) { + return "consumer must be running after the stage"; + } + return null; +} + +/** + * Paths from `git status --porcelain=v1 --untracked-files=all` that are not in + * `allowed`. Renames report their destination. An empty result means the + * worktree differs from HEAD only by the allowed paths. + */ +export function unexpectedWorktreeChanges(opts: { + readonly porcelain: string; + readonly allowed: readonly string[]; +}): readonly string[] { + const unexpected: string[] = []; + for (const line of opts.porcelain.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const entry = line.slice(3); + const arrow = entry.lastIndexOf(" -> "); + const path = arrow >= 0 ? entry.slice(arrow + 4) : entry; + if (!opts.allowed.includes(path)) { + unexpected.push(path); + } + } + return unexpected.sort(); +} + +/** True when any non-empty forbidden value occurs in any text. */ +export function containsForbiddenValue(opts: { + readonly texts: readonly string[]; + readonly forbidden: readonly string[]; +}): boolean { + return opts.forbidden.some( + (value) => + value.length > 0 && opts.texts.some((text) => text.includes(value)) + ); +} + +export type StageReceipt = { + readonly stage: PortableBootstrapStage; + readonly outcome: StageOutcome; + readonly app: AppTransition; + readonly durationMs: number; + readonly stdoutBytes: number; + readonly stderrBytes: number; + /** Bytes of Git-tracked fixture files after the stage. */ + readonly trackedBytes: number; + /** Content bytes in the consumer's mounted cache; null without a consumer. */ + readonly cacheBytes: number | null; + /** Owned dependency-cache volumes after the stage. */ + readonly cacheVolumes: number; +}; + +export const PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA = + "hack.e2e.portable-bootstrap-negatives.v1"; + +const RECEIPT_KEYS = [ + "stage", + "outcome", + "app", + "durationMs", + "stdoutBytes", + "stderrBytes", + "trackedBytes", + "cacheBytes", + "cacheVolumes", +] as const; + +const OUTCOMES: readonly string[] = ["refused", "bootstrapped"]; +const TRANSITIONS: readonly string[] = [ + "absent", + "created", + "unchanged", + "replaced", + "removed", +]; + +function count(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function invalidReceipt(): Error { + return new Error( + "Invalid portable bootstrap receipt: require each canonical stage once, closed names and non-negative integer measurements" + ); +} + +/** + * Validate a complete receipt and render it as one JSON line. Unknown keys, + * missing or reordered stages, open-ended strings and non-integer + * measurements are refused with one fixed error, so a receipt can carry only + * value-free data. + */ +export function renderPortableBootstrapReceipt(opts: { + readonly stages: readonly unknown[]; + readonly imageBytes: unknown; +}): string { + if ( + !count(opts.imageBytes) || + opts.stages.length !== PORTABLE_BOOTSTRAP_STAGES.length + ) { + throw invalidReceipt(); + } + const stages: StageReceipt[] = []; + for (const [index, value] of opts.stages.entries()) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidReceipt(); + } + const keys = Object.keys(value); + if ( + keys.length !== RECEIPT_KEYS.length || + !RECEIPT_KEYS.every((key) => Object.hasOwn(value, key)) + ) { + throw invalidReceipt(); + } + const record = value as Record<(typeof RECEIPT_KEYS)[number], unknown>; + const stage = PORTABLE_BOOTSTRAP_STAGES[index]; + if ( + stage === undefined || + record.stage !== stage || + typeof record.outcome !== "string" || + !OUTCOMES.includes(record.outcome) || + typeof record.app !== "string" || + !TRANSITIONS.includes(record.app) || + !count(record.durationMs) || + !count(record.stdoutBytes) || + !count(record.stderrBytes) || + !count(record.trackedBytes) || + !(record.cacheBytes === null || count(record.cacheBytes)) || + !count(record.cacheVolumes) + ) { + throw invalidReceipt(); + } + stages.push({ + stage, + outcome: record.outcome as StageOutcome, + app: record.app as AppTransition, + durationMs: record.durationMs, + stdoutBytes: record.stdoutBytes, + stderrBytes: record.stderrBytes, + trackedBytes: record.trackedBytes, + cacheBytes: record.cacheBytes, + cacheVolumes: record.cacheVolumes, + }); + } + return JSON.stringify({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: opts.imageBytes, + stages, + }); +} + +/** Owner label on every fixture container, volume and probe. */ +export const PORTABLE_BOOTSTRAP_OWNER = "hack.e2e.portable-bootstrap-owner"; + +/** + * Frozen install: refuse a lockfile whose manifest digest no longer matches + * `package.json`. The workspace is mounted read-only, so the installer cannot + * repair the lockfile by rewriting it. + */ +export const PORTABLE_BOOTSTRAP_INSTALL_SCRIPT = [ + "set -eu", + "manifest=$(sha256sum /workspace/package.json | cut -d ' ' -f1)", + 'grep -qx "manifest=$manifest" /workspace/bun.lock || { echo "frozen lockfile mismatch" >&2; exit 23; }', + "echo initialized >> /deps/initializations", + "sha256sum /workspace/bun.lock | cut -d ' ' -f1 > /deps/artifact", + "", +].join("\n"); + +export const PORTABLE_BOOTSTRAP_VERIFY_SCRIPT = [ + "set -eu", + 'test "$(cat /deps/artifact)" = "$(sha256sum /workspace/bun.lock | cut -d \' \' -f1)"', + 'test "$(wc -l < /deps/initializations)" -eq 1', + "", +].join("\n"); + +export function sha256Hex(data: string): string { + return new Bun.CryptoHasher("sha256").update(data).digest("hex"); +} + +/** Lockfile bound to one manifest digest; a new revision changes the cache key. */ +export function renderBootstrapLock(opts: { + readonly manifest: string; + readonly revision: number; +}): string { + return `manifest=${sha256Hex(opts.manifest)}\nrevision=${opts.revision}\n`; +} + +/** Project env file whose only value is an app-scoped sealed token. */ +export function renderBootstrapEnvFile(opts: { + readonly environment: string; + readonly sealed: string; +}): string { + return [ + "version: 1", + `environment: ${opts.environment}`, + "secretsprovider: project_key", + "values:", + " app:", + " BOOTSTRAP_TOKEN:", + ` secure: ${opts.sealed}`, + "", + ].join("\n"); +} + +/** + * A locked-v1 producer (`deps`) and a read-only consumer (`app`) on one pinned + * image, with no network, ports or pulls. The consumer exits 41 unless the + * injected token matches `tokenDigest`, and copies the cache artifact only + * after that check, so a loaded artifact proves env injection succeeded + * without the scenario ever reading the value. + */ +export function renderBootstrapCompose(opts: { + readonly name: string; + readonly ref: string; + readonly platform: string; + readonly generation: string; + readonly tokenDigest: string; +}): string { + const base = { + image: opts.ref, + platform: opts.platform, + pull_policy: "never", + network_mode: "none", + }; + const app = [ + "set -eu", + `test "$(printf %s "\${BOOTSTRAP_TOKEN:-}" | sha256sum | cut -d ' ' -f1)" = ${opts.tokenDigest} || exit 41`, + "cp /deps/artifact /tmp/loaded", + "exec sleep 3600", + ].join("\n"); + const labels = { [PORTABLE_BOOTSTRAP_OWNER]: opts.name }; + return `${JSON.stringify( + { + name: opts.name, + services: { + deps: { + ...base, + entrypoint: [], + command: ["sh", "/workspace/deps/install.sh"], + labels: { + ...labels, + "hack.dependencies.cache-volume": "deps", + "hack.dependencies.lockfiles": "bun.lock", + "hack.dependencies.runtime-files": + "package.json,deps/install.sh,deps/verify.sh", + "hack.dependencies.cache-protocol": "locked-v1", + "hack.dependencies.cache-generation": opts.generation, + "hack.dependencies.cache-verify": JSON.stringify([ + "sh", + "/workspace/deps/verify.sh", + ]), + }, + volumes: ["deps:/deps", "..:/workspace:ro"], + }, + app: { + ...base, + labels, + // Compose must pass shell expansions to the container unchanged. + command: ["sh", "-c", app.replaceAll("$", () => "$$")], + depends_on: { + deps: { condition: "service_completed_successfully" }, + }, + volumes: ["deps:/deps:ro"], + }, + }, + volumes: { deps: { labels } }, + }, + null, + 2 + )}\n`; +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 7fdfb7ff4..0b207d71e 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -48,6 +48,7 @@ import { nativeConfigProcessPolicyScenario } from "./scenarios/native-config-pro import { nativeConfigProtectedFilesScenario } from "./scenarios/native-config-protected-files.ts"; import { nativeConfigRoutingScenario } from "./scenarios/native-config-routing.ts"; import { nativeConfigVmFilesScenario } from "./scenarios/native-config-vm-files.ts"; +import { portableBootstrapNegativesScenario } from "./scenarios/portable-bootstrap-negatives.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; import { upDownScenario } from "./scenarios/up-down.ts"; import { worktreeBranchDefaultScenario } from "./scenarios/worktree-branch-default.ts"; @@ -97,6 +98,7 @@ export const ALL_SCENARIOS: readonly Scenario[] = [ upDownScenario, execStaleEnvScenario, portableMultiserviceScenario, + portableBootstrapNegativesScenario, composeConfigHashDriftScenario, nativeConfigComposeScenario, nativeConfigBuildScenario, diff --git a/tests/e2e/scenarios/portable-bootstrap-negatives.ts b/tests/e2e/scenarios/portable-bootstrap-negatives.ts new file mode 100644 index 000000000..35646de44 --- /dev/null +++ b/tests/e2e/scenarios/portable-bootstrap-negatives.ts @@ -0,0 +1,742 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { encryptSynthetic } from "../../../scripts/portable-bootstrap-smoke.ts"; +import { commitAll, createMonorepoFixture } from "../fixture.ts"; +import { + type CliResult, + expect, + expectExit, + runCommand, + type Scenario, +} from "../harness.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_OWNER as OWNER, + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + type PortableBootstrapStage, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + renderPortableBootstrapReceipt, + type StageReceipt, + sha256Hex, + unexpectedWorktreeChanges, +} from "../portable-bootstrap-controls.ts"; + +const PROJECT = "com.docker.compose.project"; +const TIMEOUT = 180_000; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const META = "/deps/.hack-dependency-cache-v1"; +const OVERLAY = "drift"; +const ARCHITECTURES: Readonly> = { + aarch64: "arm64", + arm64: "arm64", + amd64: "amd64", + x86_64: "amd64", +}; +type ResourceKind = "container" | "network" | "volume"; + +function words(text: string): string[] { + return text.split(/\s+/).filter(Boolean); +} + +/** + * Headless bootstrap negative controls on the portable slim path (HACK-1143). + * + * One app consumes a locked-v1 dependency cache initialized by a frozen-install + * producer, with an encrypted synthetic env value scoped to the app. Missing, + * wrong and wrong-overlay keys refuse before any Docker resource exists. Two + * identical bootstraps produce one initialization, one consumer and no + * worktree diff. A wrong key, a stale pinned image, a lockfile that no longer + * matches its manifest and a corrupted ready marker each refuse without + * touching the running consumer, pulling, rewriting the lockfile or + * reinstalling. A changed lockfile and a new cache generation select new + * caches and keep the old ones. Each stage records value-free time and bytes. + * + * No published ports, networks, pulls or real credentials. WU12 storage + * admission, mounts and retention are outside this fixture. + */ +export const portableBootstrapNegativesScenario: Scenario = { + name: "portable-bootstrap-negatives", + tier: "docker", + summary: + "bootstrap refusals: key/overlay, stale image, lockfile, corrupted cache; no-diff repeat", + run: async (ctx) => { + const docker = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["docker", ...args], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + expectExit({ + result, + codes: [0], + message: `Docker ${args[0]} must succeed`, + }); + return result.stdout.trim(); + }; + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "A Linux Docker daemon is required", + }); + await docker(["compose", "version"]); + const arch = + ARCHITECTURES[await docker(["info", "--format", "{{.Architecture}}"])]; + expect({ + that: arch !== undefined, + message: "A supported Docker architecture is required", + }); + const image = await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ]); + expect({ + that: IMAGE_ID.test(image), + message: "alpine:3.20 must already be cached with an immutable ID", + }); + const imageBytes = Number( + await docker(["image", "inspect", image, "--format", "{{.Size}}"]) + ); + // A pinned ID that is not present: what a toolchain bump leaves behind. + const staleImage = `sha256:${sha256Hex(`hack-e2e-stale-${randomBytes(16).toString("hex")}`)}`; + const imagePresent = async (ref: string) => + ( + await runCommand({ + argv: ["docker", "image", "inspect", ref, "--format", "{{.Id}}"], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode === 0; + expect({ + that: !(await imagePresent(staleImage)), + message: "The stale pinned image must be absent before the control", + }); + + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const name = fixture.name; + const canary = `${name}-canary`; + const cachePrefix = `hack-cache-${name}-deps-`; + // Synthetic values only; never logged, written in plaintext or passed as arguments. + const key = randomBytes(32).toString("base64url"); + const wrongKey = randomBytes(32).toString("base64url"); + const driftKey = randomBytes(32).toString("base64url"); + const secret = randomBytes(24).toString("base64url"); + const driftSecret = randomBytes(24).toString("base64url"); + const forbidden = [key, wrongKey, driftKey, secret, driftSecret]; + const compose = (opts: { + readonly ref: string; + readonly generation: string; + }) => + renderBootstrapCompose({ + name, + ref: opts.ref, + platform: `linux/${arch}`, + generation: opts.generation, + tokenDigest: sha256Hex(secret), + }); + const composePath = join(fixture.hackDir, "docker-compose.yml"); + const lockPath = join(fixture.root, "bun.lock"); + const manifestPath = join(fixture.root, "package.json"); + const manifest = await Bun.file(manifestPath).text(); + const lock = (revision: number) => + renderBootstrapLock({ manifest, revision }); + await Bun.write( + join(fixture.hackDir, "hack.config.json"), + `${JSON.stringify( + { + name, + internal: { dns: false, tls: false }, + logs: { + follow_backend: "compose", + snapshot_backend: "compose", + clear_on_down: false, + }, + }, + null, + 2 + )}\n` + ); + await Bun.write(composePath, compose({ ref: image, generation: "0" })); + await Bun.write( + join(fixture.hackDir, "hack.env.default.yaml"), + renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key, secret }), + }) + ); + // The overlay is valid YAML but sealed under a key the runner never has. + await Bun.write( + join(fixture.hackDir, `hack.env.${OVERLAY}.yaml`), + renderBootstrapEnvFile({ + environment: OVERLAY, + sealed: encryptSynthetic({ key: driftKey, secret: driftSecret }), + }) + ); + await Bun.write( + join(fixture.root, "deps/install.sh"), + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT + ); + await Bun.write( + join(fixture.root, "deps/verify.sh"), + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT + ); + await Bun.write(lockPath, lock(1)); + await commitAll({ root: fixture.root, message: "bootstrap negatives" }); + + const git = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["git", "-C", fixture.root, ...args], + cwd: fixture.root, + }); + expectExit({ result, codes: [0], message: `git ${args[0]}` }); + return result.stdout; + }; + const worktree = async (allowed: readonly string[], message: string) => { + const unexpected = unexpectedWorktreeChanges({ + porcelain: await git([ + "status", + "--porcelain=v1", + "--untracked-files=all", + ]), + allowed, + }); + expect({ + that: unexpected.length === 0, + message: `${message}; unexpected paths: ${unexpected.join(", ")}`, + }); + }; + const trackedFiles = async () => + (await git(["ls-files", "-z"])).split("\0").filter(Boolean); + const trackedBytes = async () => { + let total = 0; + for (const path of await trackedFiles()) { + total += Bun.file(join(fixture.root, path)).size; + } + return total; + }; + + const list = async (kind: ResourceKind, owner: string) => { + const args = kind === "container" ? ["ps", "-aq"] : [kind, "ls", "-q"]; + return words( + await docker([...args, "--filter", `label=${OWNER}=${owner}`]) + ); + }; + const owned = async (kind: ResourceKind, id: string, owner: string) => { + const expression = + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}"; + const labels: unknown = JSON.parse( + await docker([kind, "inspect", id, "--format", expression]) + ); + expect({ + that: + typeof labels === "object" && + labels !== null && + (labels as Record)[OWNER] === owner && + (labels as Record)[PROJECT] === owner, + message: `Refuse ${kind} access outside exact fixture ownership`, + }); + }; + const clean = async (owner: string) => { + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind, owner)) { + await owned(kind, id, owner); + await docker([ + kind, + "rm", + ...(kind === "container" ? ["--force"] : []), + id, + ]); + } + expect({ + that: (await list(kind, owner)).length === 0, + message: `Owned ${kind} cleanup must leave no resources`, + }); + } + }; + const cacheVolumes = async () => { + const names = (await list("volume", name)).filter((volume) => + volume.startsWith(cachePrefix) + ); + for (const volume of names) { + await owned("volume", volume, name); + } + return names.sort(); + }; + // Inspect only fixed fields: the consumer's Config.Env holds the secret. + const appIdentity = async (): Promise => { + const ids = words( + await docker([ + "ps", + "-aq", + "--filter", + `label=${OWNER}=${name}`, + "--filter", + "label=com.docker.compose.service=app", + ]) + ); + const id = ids[0]; + if (id === undefined) { + return null; + } + expect({ + that: ids.length === 1, + message: "Expected at most one owned consumer container", + }); + await owned("container", id, name); + const [fullId, running, startedAt] = words( + await docker([ + "inspect", + id, + "--format", + "{{.Id}} {{.State.Running}} {{.State.StartedAt}}", + ]) + ); + if (!(fullId && running && startedAt)) { + throw new Error("Consumer identity is incomplete"); + } + return { id: fullId, running: running === "true", startedAt }; + }; + const appExec = async (id: string, script: string) => + await runCommand({ + argv: ["docker", "exec", id, "sh", "-c", script], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + // The consumer copies the artifact only after its env digest matched. + const loaded = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const result = await appExec(identity.id, "cat /tmp/loaded"); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + const state = await docker([ + "inspect", + identity.id, + "--format", + "{{.State.Running}}", + ]); + expect({ + that: state === "true", + message: + "Consumer exited before loading the cache (env digest or artifact missing)", + }); + await Bun.sleep(250); + } + throw new Error("Consumer did not load its dependency cache in time"); + }; + const mountedCache = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const volume = await docker([ + "inspect", + identity.id, + "--format", + '{{range .Mounts}}{{if eq .Destination "/deps"}}{{.Name}} {{.RW}}{{end}}{{end}}', + ]); + const [mounted, rw] = words(volume); + expect({ + that: Boolean(mounted?.startsWith(cachePrefix)) && rw === "false", + message: "Consumer must mount a fingerprinted cache read-only", + }); + return mounted as string; + }; + const cacheFacts = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const result = await appExec( + identity.id, + "wc -l < /deps/initializations; find /deps -type f -exec cat {} + | wc -c" + ); + expectExit({ result, codes: [0], message: "Read mounted cache facts" }); + const [initializations, bytes] = words(result.stdout).map(Number); + if (initializations === undefined || bytes === undefined) { + throw new Error("Cache facts are incomplete"); + } + return { initializations, bytes }; + }; + // Read-only, network-less probe of one exact owned volume. + const probeVolume = async (volume: string, script: string) => { + await owned("volume", volume, name); + return ( + await runCommand({ + argv: [ + "docker", + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${volume}:/deps:ro`, + image, + "sh", + "-c", + script, + ], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode; + }; + const readyMarker = (volume: string) => + `printf 'locked-v1\\n%s\\n' '${volume.slice(cachePrefix.length)}' | cmp -s - ${META}/ready`; + + const cli = async (opts: { + readonly args: readonly string[]; + readonly key: string | null; + }): Promise => { + const result = await ctx.cli({ + args: opts.args, + cwd: fixture.root, + timeoutMs: TIMEOUT, + env: { + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }); + // Checked before any assertion can attach this output to a failure. + expect({ + that: !containsForbiddenValue({ + texts: [result.stdout, result.stderr], + forbidden, + }), + message: `hack ${opts.args[0]} output must not contain keys or env values`, + }); + return result; + }; + const receipts: StageReceipt[] = []; + let consumer: AppIdentity = null; + let expectedArtifact = ""; + const stage = async (opts: { + readonly stage: PortableBootstrapStage; + readonly key: string | null; + readonly overlay?: string; + }) => { + const before = await appIdentity(); + const result = await cli({ + args: [ + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + key: opts.key, + }); + const after = await appIdentity(); + const transition = classifyAppTransition({ before, after }); + const failure = checkStageExpectation({ + stage: opts.stage, + exitCode: result.exitCode, + timedOut: result.timedOut, + transition, + running: after?.running === true, + }); + expect({ + that: failure === null, + message: `${opts.stage}: ${failure}`, + result, + }); + if (after !== null) { + // A new consumer must load its cache and stay the same container. + await loaded(after); + expect({ + that: + classifyAppTransition({ + before: after, + after: await appIdentity(), + }) === "unchanged", + message: `${opts.stage}: consumer must stay running without a restart`, + }); + } + const facts = after === null ? null : await cacheFacts(after); + receipts.push({ + stage: opts.stage, + outcome: result.exitCode === 0 ? "bootstrapped" : "refused", + app: transition, + durationMs: result.durationMs, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + trackedBytes: await trackedBytes(), + cacheBytes: facts?.bytes ?? null, + cacheVolumes: (await cacheVolumes()).length, + }); + ctx.log( + `${opts.stage}: ${result.exitCode === 0 ? "bootstrapped" : "refused"}, consumer ${transition}, ${result.durationMs} ms` + ); + consumer = after; + return { result, facts }; + }; + // Refusals and repeats must leave the consumer reading the same artifact. + const consumerKeeps = async (message: string) => { + expect({ + that: (await loaded(consumer)) === expectedArtifact, + message, + }); + }; + const nothingCreated = async (message: string) => { + for (const kind of ["container", "network", "volume"] as const) { + expect({ + that: (await list(kind, name)).length === 0, + message: `${message}: no owned ${kind} may exist`, + }); + } + }; + + try { + await docker([ + "volume", + "create", + "--label", + `${OWNER}=${canary}`, + "--label", + `${PROJECT}=${canary}`, + canary, + ]); + + // 1-3: env refusals happen before any Docker resource exists. + await stage({ stage: "refuse-missing-key", key: null }); + await nothingCreated("Missing key refusal"); + // The first lifecycle command converges Hack's managed ignore block. + await worktree( + [".hack/.gitignore"], + "A refused bootstrap may only add the managed .hack ignore block" + ); + if ((await git(["status", "--porcelain=v1"])).trim().length > 0) { + await commitAll({ + root: fixture.root, + message: "managed ignore block", + }); + } + await stage({ stage: "refuse-wrong-key", key: wrongKey }); + await nothingCreated("Wrong key refusal"); + await worktree([], "Wrong key refusal must leave no diff"); + await stage({ stage: "refuse-wrong-overlay", key, overlay: OVERLAY }); + await nothingCreated("Wrong overlay refusal"); + await worktree([], "Wrong overlay refusal must leave no diff"); + + // 4-5: two identical bootstraps; one initialization and no diff. + const cold = await stage({ stage: "bootstrap-cold", key }); + expectedArtifact = sha256Hex(lock(1)); + await consumerKeeps("Cold bootstrap must load the locked artifact"); + expect({ + that: cold.facts?.initializations === 1, + message: "Cold bootstrap must initialize the cache exactly once", + }); + const firstCache = await mountedCache(consumer); + expect({ + that: + (await probeVolume(firstCache, readyMarker(firstCache))) === 0 && + (await cacheVolumes()).length === 1, + message: "Cold bootstrap must publish one ready cache", + }); + await worktree([], "Cold bootstrap must leave no diff"); + const trackedBefore = await git(["ls-files", "-s"]); + const repeat = await stage({ stage: "bootstrap-repeat", key }); + await consumerKeeps("Repeat bootstrap must keep the loaded artifact"); + expect({ + that: + repeat.facts?.initializations === 1 && + (await mountedCache(consumer)) === firstCache && + (await cacheVolumes()).length === 1, + message: + "Repeat bootstrap must reuse the ready cache without reinstalling", + }); + await worktree([], "Repeat bootstrap must leave no diff"); + expect({ + that: (await git(["ls-files", "-s"])) === trackedBefore, + message: "Repeat bootstrap must leave the tracked index unchanged", + }); + + // 6: a wrong key cannot disturb a running generation. + await stage({ stage: "refuse-wrong-key-running", key: wrongKey }); + await consumerKeeps("Wrong key refusal must keep the consumer"); + await worktree([], "Wrong key refusal must leave no diff"); + + // 7: a stale pinned image is refused, never pulled or retagged. + const composeBytes = await Bun.file(composePath).text(); + await Bun.write( + composePath, + compose({ ref: staleImage, generation: "0" }) + ); + await stage({ stage: "refuse-stale-image", key }); + await consumerKeeps("Stale image refusal must keep the consumer"); + expect({ + that: + !(await imagePresent(staleImage)) && + (await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ])) === image, + message: "Stale image refusal must not pull, build or retag images", + }); + await worktree( + [".hack/docker-compose.yml"], + "Stale image refusal must change only the edited compose file" + ); + await Bun.write(composePath, composeBytes); + await worktree([], "Restored compose must match HEAD"); + + // 8: a lockfile that no longer matches its manifest is never rewritten + // and never publishes a ready cache. + const lockBytes = await Bun.file(lockPath).text(); + const quarantineBefore = await cacheVolumes(); + const unfrozen = manifest.replace( + /\n\}\n?$/, + ',\n "hackE2eFrozenProbe": true\n}\n' + ); + expect({ + that: unfrozen !== manifest, + message: "The frozen lockfile control must change the manifest", + }); + await Bun.write(manifestPath, unfrozen); + await stage({ stage: "refuse-frozen-lockfile", key }); + await consumerKeeps("Frozen lockfile refusal must keep the consumer"); + const quarantined = (await cacheVolumes()).filter( + (volume) => !quarantineBefore.includes(volume) + ); + expect({ + that: + (await Bun.file(lockPath).text()) === lockBytes && + quarantined.length === 1 && + (await probeVolume( + quarantined[0] as string, + `test -f ${META}/attempt && test ! -e ${META}/ready && test ! -e /deps/artifact` + )) === 0, + message: + "Frozen refusal must keep the lockfile and quarantine its cache without a ready marker", + }); + await worktree( + ["package.json"], + "Frozen lockfile refusal must change only the edited manifest" + ); + await Bun.write(manifestPath, manifest); + await worktree([], "Restored manifest must match HEAD"); + + // 9: a changed lockfile selects and initializes a new cache. + await Bun.write(lockPath, lock(2)); + await commitAll({ root: fixture.root, message: "lockfile revision 2" }); + const changed = await stage({ + stage: "bootstrap-changed-lockfile", + key, + }); + expectedArtifact = sha256Hex(lock(2)); + await consumerKeeps("Changed lockfile must load the new artifact"); + const secondCache = await mountedCache(consumer); + expect({ + that: + changed.facts?.initializations === 1 && + secondCache !== firstCache && + (await probeVolume(firstCache, readyMarker(firstCache))) === 0, + message: + "Changed lockfile must initialize a new cache once and retain the previous ready cache", + }); + await worktree([], "Changed lockfile bootstrap must leave no diff"); + + // 10: a corrupted ready marker fails closed without reinstalling. + const corrupt = `printf 'locked-v1\\n%s\\n' '${"0".repeat(16)}'`; + await owned("volume", secondCache, name); + await docker([ + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${secondCache}:/deps`, + image, + "sh", + "-c", + `${corrupt} > ${META}/ready`, + ]); + const corrupted = await stage({ stage: "refuse-corrupted-cache", key }); + await consumerKeeps("Corrupted cache refusal must keep the consumer"); + expect({ + that: + corrupted.facts?.initializations === 1 && + (await probeVolume( + secondCache, + `${corrupt} | cmp -s - ${META}/ready` + )) === 0, + message: + "Corrupted cache refusal must neither reinstall nor rewrite the marker", + }); + await worktree([], "Corrupted cache refusal must leave no diff"); + + // 11: the documented recovery is a new generation, not in-place repair. + await Bun.write(composePath, compose({ ref: image, generation: "1" })); + await commitAll({ root: fixture.root, message: "cache generation 1" }); + const recovered = await stage({ + stage: "bootstrap-new-generation", + key, + }); + await consumerKeeps("New generation must load the current artifact"); + const thirdCache = await mountedCache(consumer); + expect({ + that: + recovered.facts?.initializations === 1 && + thirdCache !== secondCache && + (await cacheVolumes()).includes(secondCache), + message: + "New generation must initialize once and retain the corrupted cache for inspection", + }); + await worktree([], "New generation bootstrap must leave no diff"); + + const tracked: string[] = []; + for (const path of await trackedFiles()) { + tracked.push(await Bun.file(join(fixture.root, path)).text()); + } + expect({ + that: !containsForbiddenValue({ texts: tracked, forbidden }), + message: "Tracked fixture files must not contain keys or env values", + }); + + expectExit({ + result: await cli({ args: ["down"], key }), + codes: [0], + message: "hack down must succeed", + }); + expect({ + that: (await list("container", name)).length === 0, + message: "hack down must remove the fixture containers", + }); + await clean(name); + await owned("volume", canary, canary); + ctx.log( + `portable-bootstrap receipt ${renderPortableBootstrapReceipt({ stages: receipts, imageBytes })}` + ); + } finally { + try { + await clean(name); + } finally { + await clean(canary); + } + } + }, +}; diff --git a/tests/e2e/shard.ts b/tests/e2e/shard.ts index 907b364b6..4041ee065 100644 --- a/tests/e2e/shard.ts +++ b/tests/e2e/shard.ts @@ -52,6 +52,8 @@ export const E2E_DURATION_HINTS_SECONDS: Readonly> = { "native-compose-adoption-plural-network-worktrees": 33, "native-compose-adoption-branch-worktrees": 33, "portable-multiservice": 30, + // Estimate until a hosted docker-e2e summary measures it: eleven cached-alpine stages. + "portable-bootstrap-negatives": 45, // Estimate (no measured run yet): two cached-alpine ups plus four run/exec probes. "compose-config-hash-drift": 25, "dependency-cache-package": 30, diff --git a/tests/portable-bootstrap-env-refusal.test.ts b/tests/portable-bootstrap-env-refusal.test.ts new file mode 100644 index 000000000..78ead7e3b --- /dev/null +++ b/tests/portable-bootstrap-env-refusal.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + sha256Hex, +} from "./e2e/portable-bootstrap-controls.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); +// Synthetic, process-local values; never real credentials. +const KEY = "portable-refusal-key-0123456789abcdef"; +const WRONG_KEY = "portable-refusal-wrong-key-0123456789"; +const DRIFT_KEY = "portable-refusal-drift-key-0123456789"; +const SECRET = "portable-refusal-secret-value"; +const DRIFT_SECRET = "portable-refusal-drift-value"; +const FORBIDDEN = [KEY, WRONG_KEY, DRIFT_KEY, SECRET, DRIFT_SECRET]; +const MUTATIONS = new Set(["create", "run", "start", "up"]); + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** + * The hosted `portable-bootstrap-negatives` fixture against a recording Docker + * stub: env refusals must happen before Hack asks Compose to create, run or + * start anything. The stub refuses every Compose mutation, so the positive + * control shows the same command reaches the dependency bootstrap with a + * valid key. + */ +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-portable-refusal-")); + roots.push(root); + const project = join(root, "project"); + const bin = join(root, "bin"); + const home = join(root, "home"); + for (const path of [ + join(project, ".hack"), + join(project, "deps"), + bin, + home, + ]) { + await mkdir(path, { recursive: true }); + } + const manifest = '{\n "name": "portable-refusal",\n "private": true\n}\n'; + const files: Record = { + ".gitignore": ".hack/.internal/\n.hack.secret.key\n", + "package.json": manifest, + "bun.lock": renderBootstrapLock({ manifest, revision: 1 }), + "deps/install.sh": PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + "deps/verify.sh": PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + ".hack/hack.config.json": `${JSON.stringify({ + name: "portable-refusal", + internal: { dns: false, tls: false }, + })}\n`, + ".hack/docker-compose.yml": renderBootstrapCompose({ + name: "portable-refusal", + ref: `sha256:${"a".repeat(64)}`, + platform: "linux/arm64", + generation: "0", + tokenDigest: sha256Hex(SECRET), + }), + ".hack/hack.env.default.yaml": renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key: KEY, secret: SECRET }), + }), + ".hack/hack.env.drift.yaml": renderBootstrapEnvFile({ + environment: "drift", + sealed: encryptSynthetic({ key: DRIFT_KEY, secret: DRIFT_SECRET }), + }), + }; + for (const [path, content] of Object.entries(files)) { + await writeFile(join(project, path), content); + } + const git = (args: string[]) => { + const result = Bun.spawnSync(["git", "-C", project, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + }; + git(["init", "-q", "-b", "main"]); + git(["add", "."]); + git([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "fixture", + ]); + const log = join(root, "docker.jsonl"); + await writeFile(log, ""); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath} +import { appendFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +const mutations = ${JSON.stringify([...MUTATIONS])}; +if (args[0] === "compose" && args.some((arg) => mutations.includes(arg))) process.exit(97); +` + ); + await chmod(docker, 0o755); + const up = async (opts: { + readonly key: string | null; + readonly overlay?: string; + }) => { + const child = Bun.spawn( + [ + process.execPath, + cli, + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + { + cwd: project, + timeout: 20_000, + env: privateCliEnv({ + root, + home, + env: { + HACK_HOME: join(home, ".hack"), + PATH: `${bin}:${process.env.PATH ?? ""}`, + HACK_NO_INTERACTIVE: "1", + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + NO_COLOR: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const calls = (await readFile(log, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + await writeFile(log, ""); + return { exitCode, stdout, stderr, calls }; + }; + return { up }; +} + +function composeMutations(calls: readonly (readonly string[])[]) { + return calls.filter( + (args) => args[0] === "compose" && args.some((arg) => MUTATIONS.has(arg)) + ); +} + +describe("portable bootstrap env refusals", () => { + test.each([ + { name: "missing key", key: null, reason: ".hack.secret.key" }, + { name: "wrong key", key: WRONG_KEY, reason: "Invalid secure env value." }, + { + name: "overlay sealed under another key", + key: KEY, + overlay: "drift", + reason: "Invalid secure env value.", + }, + ])("$name refuses before any Compose mutation", async (entry) => { + const { up } = await fixture(); + const result = await up(entry); + expect(result.exitCode).not.toBe(0); + expect(composeMutations(result.calls)).toEqual([]); + expect(`${result.stdout}${result.stderr}`).toContain(entry.reason); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); + + test("the valid key reaches the dependency bootstrap first", async () => { + const { up } = await fixture(); + const result = await up({ key: KEY }); + expect(result.exitCode).not.toBe(0); + const mutations = composeMutations(result.calls); + expect(mutations.length).toBe(1); + expect(mutations[0]).toContain("run"); + expect(mutations[0]?.at(-1)).toBe("deps"); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); +});