From 96379d69bb6822be33deb4614e02f45ce15890f7 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 13:11:53 -0400 Subject: [PATCH 01/26] feat: preview basic Compose builds in native config import --- docs/reference/native-config-import.md | 60 +- scripts/check-native-config-import-build.ts | 463 +++++++++++++++ src/lib/native-config-import-build.ts | 118 ++++ src/lib/native-config-import-literal.ts | 19 + src/lib/native-config-import-plan.ts | 79 ++- tests/native-config-import-build.test.ts | 615 ++++++++++++++++++++ 6 files changed, 1329 insertions(+), 25 deletions(-) create mode 100644 scripts/check-native-config-import-build.ts create mode 100644 src/lib/native-config-import-build.ts create mode 100644 src/lib/native-config-import-literal.ts create mode 100644 tests/native-config-import-build.test.ts diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index 91a18f06b..661fb85a1 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -25,9 +25,10 @@ reported as successful. | Boolean `worktree.auto_branch` / `autoBranch` and `inherit_local` / `inheritLocal` | The equivalent native worktree policy; conflicting aliases refuse | | Optional Compose `name` | Must exactly equal the explicit project name | | Image-only services | The same logical service names and image strings | +| Basic build-only services | String context or a closed object containing `context`, `dockerfile`, `target`; legacy `.hack` context rebased to the checkout root | | Array or bounded string `command` and `entrypoint` | Explicit native exec arrays; Compose string words are split without an implicit shell, complete `$$` pairs become literal `$` arguments, and an empty entrypoint remains explicit | | `working_dir`, boolean `init` | `working_directory`, `init` | -| `pull_policy` of `always`, `never`, `missing` | The same authored acquisition intent | +| `pull_policy` of `always`, `never`, `missing` for images, or `build` for builds | The same authored acquisition intent; omitted policy stays omitted | | `restart` of `no`, `always`, `unless-stopped`, `on-failure[:N]` | Native restart intent; retry counts must fit a positive u32 | | String `stop_signal`, `stop_grace_period` | Native shutdown signal/grace, validated by the compiler | | String environment map or `KEY=value` list | Native `default` bindings, preserving managed-value precedence and empty values | @@ -48,16 +49,67 @@ empty executable word, single or odd dollars, `$VAR` and `${VAR}` refuse. Omitted or explicit null command/entrypoint keeps Compose's image-default behavior. Empty or whitespace-only entrypoint explicitly clears the image entrypoint; empty or whitespace-only command refuses because the native command model cannot express -Compose's explicit empty override. Dollars in other runtime strings and NUL in runtime values refuse -rather than inheriting ambient environment. Environment list entries without `=`, duplicate +Compose's explicit empty override. Outside argv and basic build paths, dollars in +runtime strings refuse rather than inheriting ambient environment. NUL in runtime +values refuses. Environment list entries without `=`, duplicate names, nulls and non-string values refuse. Every unknown field remains a refusal, including fields in inactive profiles. -Builds, volumes/bind mounts, networks, ports, dependencies, health checks, labels, +Advanced builds, volumes/bind mounts, networks, ports, dependencies, health checks, labels, routes, host/lifecycle settings, `env_file`, deployment options and extensions are outside the first slice. They cannot be silently omitted from a complete conversion. +## Pure basic build preview + +The [Compose build contract](https://docs.docker.com/reference/compose-file/build/) +allows a short context string or an object. This preview accepts only relative +local context paths and optional relative `dockerfile` and canonical `target`. +It converts the raw `.hack/docker-compose.yml` declaration: legacy context paths +start at `.hack/`, while native contexts start at the checkout root. Thus +`build: ..` maps to native context `.`, `build: .` maps to `.hack`, and +`context: ../app` maps to `app`. An object without `context` uses the legacy +default directory `.hack`. An omitted Dockerfile remains omitted for the native +compiler's `Dockerfile` default; an explicit Dockerfile stays relative to the +build context. Lexical path normalization and complete `$$` pair decoding do not +read files or evaluate environment variables. Source pointers and positions +remain those of the raw declaration, and the report contains no path values. + +Context paths escaping the checkout root, Dockerfiles escaping their context, +absolute/home-relative/remote paths, ambiguous dollar expressions and malformed +fields refuse. Build arguments, cache options, SSH, secrets, labels, network, +inline Dockerfiles, platforms, tags and every other build option remain refused, +even when empty or in an inactive profile. `build.pull` is unsupported; the +service's `pull_policy: build` is a separate supported acquisition requirement. +Combined `build` and `image` refuse because the native model requires exactly one +source. Invalid builds cannot fall back to an authored image or a default policy. + +This expands read-only preview only. Retained-container adoption still uses its +separate image-only mapping and refuses builds. The mapper runs no Compose +normalization, builder or runtime command, and does not validate Dockerfile +contents, path existence or filesystem identity. The authoritative compiler still +must validate the whole private candidate before a complete CLI preview; actual +build import and adoption acceptance remain open. + +The maintained config-only correspondence gate is +`bun scripts/check-native-config-import-build.ts`. Select the prepared matching +sidecar with an absolute `HACK_CONFIG_COMPILER_BINARY` and the installed standalone +Compose plugin with an absolute `HACK_IMPORT_COMPOSE_BINARY`. It compares the +actual normalized Compose projections of the raw legacy and compiled/rendered +native inputs for default context, checkout-root context, nested Dockerfile/stage +and literal dollars, including an inactive profile selected explicitly for the +comparison. Context and lexically resolved Dockerfile paths must match; stage, source and +policy presence must remain exact. These are Compose's serialized config strings, +including its escaped-dollar representation; the comparison does not decode them +again or certify the builder's actual filesystem paths. No builder or engine command is admitted. +Compose receives an isolated empty Docker configuration and a nonexistent engine +socket. The aggregate gate is bounded to 90 seconds with bounded child captures. +An optional fresh absolute `HACK_IMPORT_BUILD_EVIDENCE_DIR` retains private +captures and the result; a failure keeps its temporary evidence and returns +nonzero. The result records matched projections and requires the command's final +zero exit; it cannot independently certify completion after a late write or +cancellation. This proves config correspondence, not build or adoption acceptance. + ## Parsing and input boundary The pinned maintained `yaml` AST parser checks decoded key uniqueness, strict diff --git a/scripts/check-native-config-import-build.ts b/scripts/check-native-config-import-build.ts new file mode 100644 index 000000000..9453be910 --- /dev/null +++ b/scripts/check-native-config-import-build.ts @@ -0,0 +1,463 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { + lstat, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, posix, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { renderNativeCompose } from "../src/lib/native-compose-renderer.ts"; +import { compileNativeConfig } from "../src/lib/native-config-compiler.ts"; +import { mapLegacyNativeImport } from "../src/lib/native-config-import-plan.ts"; +import { resolveTestConfigCompilerBinary } from "./check-test-config-compiler.ts"; + +const CASES = { + default: { build: {} }, + root: { build: ".." }, + nested: { + build: { + context: "../app", + dockerfile: "./docker/Dockerfile", + target: "selected", + }, + pull_policy: "build", + }, + literal: { + build: { + context: "../literal-$${AMBIENT}", + dockerfile: "docker/Dockerfile-$${AMBIENT}", + }, + profiles: ["later"], + }, +}; +const LIMIT = 256 * 1024; +const RUNTIME = "import-build-fixture"; + +function requireValue(value: unknown): asserts value { + if (!value) { + throw new Error("Build import config-only correspondence refused."); + } +} + +function record(value: unknown): Record { + requireValue(isRecord(value)); + return value; +} + +/** Exact source/stage/policy and resolved path projection; Compose keeps some lexical Dockerfile spelling. */ +export function nativeImportBuildProjection(value: unknown) { + const services = record(record(value).services); + requireValue( + Object.keys(services).sort().join(",") === + Object.keys(CASES).sort().join(",") + ); + return Object.fromEntries( + Object.entries(services) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => { + const service = record(value); + requireValue( + Object.hasOwn(service, "build") && !Object.hasOwn(service, "image") + ); + const build = record(service.build); + requireValue( + Object.hasOwn(build, "context") && + Object.hasOwn(build, "dockerfile") && + Object.keys(build).every((key) => + ["context", "dockerfile", "target"].includes(key) + ) && + typeof build.context === "string" && + posix.isAbsolute(build.context) && + typeof build.dockerfile === "string" && + !posix.isAbsolute(build.dockerfile) && + !/[\\\0\r\n]/.test(build.context + build.dockerfile) && + (!Object.hasOwn(build, "target") || + typeof build.target === "string") && + (!Object.hasOwn(service, "pull_policy") || + service.pull_policy === "build") + ); + return [ + name, + { + context: posix.normalize(build.context), + dockerfile: posix.join(build.context, build.dockerfile), + ...(Object.hasOwn(build, "target") ? { target: build.target } : {}), + ...(Object.hasOwn(service, "pull_policy") + ? { pull_policy: service.pull_policy } + : {}), + }, + ]; + }) + ); +} + +function expected(project: string) { + const value = (context: string, dockerfile = "Dockerfile") => ({ + context: posix.join(project, context), + dockerfile: posix.join(project, context, dockerfile), + }); + return { + default: value(".hack"), + // Compose config serializes the literal dollar as an escaped dollar pair. + literal: value("literal-$${AMBIENT}", "docker/Dockerfile-$${AMBIENT}"), + nested: { + ...value("app", "docker/Dockerfile"), + target: "selected", + pull_policy: "build", + }, + root: value("."), + }; +} + +function capture( + stream: ReadableStream, + stop: () => void, + afterRead?: () => void +) { + const reader = stream.getReader(); + const value = (async () => { + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + size += next.value.length; + requireValue(size <= LIMIT); + afterRead?.(); + chunks.push(next.value); + } + return Buffer.concat(chunks); + } catch { + stop(); + throw new Error("Bounded config-only capture refused."); + } finally { + reader.releaseLock(); + } + })(); + return { + value, + cancel: async () => { + try { + await reader.cancel(); + } catch { + // The owned read may already have settled and released its lock. + } + }, + }; +} + +/** Bounded config-only command owner. Leader exit alone never disarms pending-pipe cleanup. */ +export async function runNativeImportBuildConfig(opts: { + readonly binary: string; + readonly file: string; + readonly cwd: string; + readonly env: Readonly>; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + /** Focused capture-failure control; never selected from authored or CLI input. */ + readonly afterRead?: () => void; + /** Private bounded captures, delivered only after the direct child and both pipes settle. */ + readonly onSettled?: (result: { + readonly exitCode: number; + readonly stdout: Uint8Array; + readonly stderr: Uint8Array; + }) => Promise; +}) { + requireValue( + isAbsolute(opts.binary) && + opts.timeoutMs > 0 && + opts.timeoutMs <= 15_000 && + !opts.signal?.aborted + ); + const deadline = Date.now() + opts.timeoutMs; + const child = Bun.spawn( + [ + opts.binary, + "--project-name", + RUNTIME, + "--project-directory", + opts.cwd, + "--env-file", + "/dev/null", + "--file", + opts.file, + "--profile", + "*", + "config", + "--format", + "json", + ], + { + cwd: opts.cwd, + env: { ...opts.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: true, + } + ); + let complete = false; + let stopped = false; + let output: ReturnType | undefined; + let errors: ReturnType | undefined; + const stop = () => { + if (!(complete || stopped)) { + stopped = true; + try { + process.kill(-child.pid, "SIGKILL"); + } catch (error: unknown) { + if (!(isRecord(error) && error.code === "ESRCH")) { + try { + child.kill("SIGKILL"); + } catch { + // Unknown cleanup cannot succeed; the direct child remains awaited. + } + } + } + } + // Cancellation closes descendant-held pipes independently of the signal guard. + void output?.cancel(); + void errors?.cancel(); + }; + const timer = setTimeout(stop, opts.timeoutMs); + opts.signal?.addEventListener("abort", stop, { once: true }); + const exit = child.exited; + output = capture(child.stdout, stop, opts.afterRead); + errors = capture(child.stderr, stop, opts.afterRead); + const pending = [exit, output.value, errors.value] as const; + try { + const [code, out, err] = await Promise.all(pending); + // Disarm former-group signals before decoding, publication or final assertions. + complete = true; + await opts.onSettled?.({ exitCode: code, stdout: out, stderr: err }); + requireValue( + code === 0 && !stopped && !opts.signal?.aborted && Date.now() < deadline + ); + const decoder = new TextDecoder("utf-8", { fatal: true }); + return { stdout: decoder.decode(out), stderr: decoder.decode(err) }; + } catch { + stop(); + await Promise.allSettled(pending); + throw new Error( + "Config-only Compose correspondence failed; values omitted." + ); + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", stop); + } +} + +async function main() { + const deadline = Date.now() + 90_000; + const binary = resolveTestConfigCompilerBinary({ + override: process.env.HACK_CONFIG_COMPILER_BINARY, + }); + const compose = process.env.HACK_IMPORT_COMPOSE_BINARY; + requireValue(compose && isAbsolute(compose)); + const physical = await realpath(compose); + const composeInfo = await lstat(physical); + const compilerInfo = await lstat(binary); + requireValue( + composeInfo.isFile() && + composeInfo.nlink === 1 && + composeInfo.mode & 0o111 && + compilerInfo.isFile() && + compilerInfo.mode & 0o111 + ); + const pins = { + compiler: createHash("sha256") + .update(Buffer.from(await Bun.file(binary).arrayBuffer())) + .digest("hex"), + compose: createHash("sha256") + .update(Buffer.from(await Bun.file(physical).arrayBuffer())) + .digest("hex"), + }; + const evidence = process.env.HACK_IMPORT_BUILD_EVIDENCE_DIR; + let directory: string; + if (evidence) { + requireValue(isAbsolute(evidence) && resolve(evidence) === evidence); + requireValue( + (await realpath(resolve(evidence, ".."))) === resolve(evidence, "..") + ); + await mkdir(evidence, { mode: 0o700 }); + directory = evidence; + } else { + directory = await realpath( + await mkdtemp(join(tmpdir(), "native-build-import-")) + ); + } + const project = join(directory, "project"); + const home = join(directory, "home"); + await mkdir(join(project, ".hack"), { recursive: true }); + await mkdir(join(home, ".docker"), { recursive: true }); + await writeFile(join(home, ".docker", "config.json"), "{}\n", { + mode: 0o600, + flag: "wx", + }); + const composeFile = join(project, ".hack", "docker-compose.yml"); + const raw = JSON.stringify({ services: CASES }); + await writeFile(composeFile, raw, { mode: 0o600, flag: "wx" }); + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); + const remaining = () => { + requireValue(!controller.signal.aborted && Date.now() < deadline); + return Math.min(15_000, deadline - Date.now()); + }; + const assertBinaries = async () => { + remaining(); + requireValue((await realpath(compose)) === physical); + for (const [path, original, hash] of [ + [binary, compilerInfo, pins.compiler], + [physical, composeInfo, pins.compose], + ] as const) { + const same = async () => { + const current = await lstat(path); + requireValue( + current.isFile() && + current.dev === original.dev && + current.ino === original.ino && + current.size === original.size && + current.mode === original.mode && + current.nlink === original.nlink && + current.mtimeMs === original.mtimeMs && + current.ctimeMs === original.ctimeMs + ); + }; + await same(); + requireValue( + createHash("sha256") + .update(Buffer.from(await Bun.file(path).arrayBuffer())) + .digest("hex") === hash + ); + await same(); + } + remaining(); + }; + const config = async (file: string, cwd: string, label: string) => { + await assertBinaries(); + const captured = await runNativeImportBuildConfig({ + binary: compose, + file, + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + DOCKER_CONFIG: join(home, ".docker"), + DOCKER_HOST: `unix://${directory}/no-engine.sock`, + COMPOSE_DISABLE_ENV_FILE: "1", + AMBIENT: "must-not-expand", + }, + timeoutMs: remaining(), + signal: controller.signal, + onSettled: async (result) => { + await writeFile(join(directory, `${label}.stdout`), result.stdout, { + mode: 0o600, + flag: "wx", + }); + await writeFile(join(directory, `${label}.stderr`), result.stderr, { + mode: 0o600, + flag: "wx", + }); + }, + }); + remaining(); + requireValue(!captured.stdout.includes("must-not-expand")); + return JSON.parse(captured.stdout) as unknown; + }; + let passed = false; + try { + const mapped = mapLegacyNativeImport({ + configText: '{"name":"fixture"}', + composeText: raw, + }); + requireValue(mapped.report.complete && mapped.candidate); + await assertBinaries(); + const result = await compileNativeConfig({ + binary, + input: new TextEncoder().encode(JSON.stringify(mapped.candidate)), + profiles: ["later"], + signal: controller.signal, + timeoutMs: remaining(), + }); + requireValue(result.ok); + const workloads = Object.keys(record(result.plan.services)); + requireValue( + workloads.sort().join(",") === Object.keys(CASES).sort().join(",") + ); + const rendered = renderNativeCompose({ + plan: result.plan, + environmentPlan: { + plan_version: 1, + overlay: null, + overlay_exists: false, + complete: true, + workloads: Object.fromEntries(workloads.map((name) => [name, {}])), + warnings: [], + diagnostics: [], + }, + projectRoot: project, + runtimeIdentity: RUNTIME, + ownerToken: "a".repeat(32), + generationIdentity: "b".repeat(32), + declaredWorkloads: result.declared_workloads, + managedValues: Object.fromEntries(workloads.map((name) => [name, {}])), + }); + const generated = join(directory, "generated.json"); + await writeFile(generated, rendered.json, { mode: 0o600, flag: "wx" }); + const legacy = nativeImportBuildProjection( + await config(composeFile, join(project, ".hack"), "legacy") + ); + const native = nativeImportBuildProjection( + await config(generated, project, "native") + ); + requireValue(JSON.stringify(legacy) === JSON.stringify(expected(project))); + requireValue(JSON.stringify(native) === JSON.stringify(legacy)); + await assertBinaries(); + // A receipt write can finish after cancellation/deadline. It records matching + // projections only; the caller's final zero exit is required for acceptance. + await writeFile( + join(directory, "result.json"), + JSON.stringify({ + comparison: "matched", + completion: "requires_final_zero_exit", + cases: 4, + pins, + legacy, + native, + }), + { mode: 0o600, flag: "wx" } + ); + remaining(); + passed = true; + process.stdout.write( + "Basic build import: 4 compiler/Compose config-only cases passed; no engine or build actions.\n" + ); + } finally { + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); + if (passed && !evidence) { + await rm(directory, { recursive: true }); + } + } +} + +if (import.meta.main) { + try { + await main(); + } catch { + process.stderr.write( + "Basic build import correspondence failed; private captures retained, values omitted.\n" + ); + process.exitCode = 1; + } +} diff --git a/src/lib/native-config-import-build.ts b/src/lib/native-config-import-build.ts new file mode 100644 index 000000000..031a36a7e --- /dev/null +++ b/src/lib/native-config-import-build.ts @@ -0,0 +1,118 @@ +import { posix } from "node:path"; +import type { Build } from "../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "./guards.ts"; +import { literalComposeArg } from "./native-config-import-literal.ts"; + +const TARGET = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const UNSAFE_PATH = /[\\\0\r\n:]/; +const TRAILING_SLASH = /\/$/; +type BuildField = { + readonly source: "" | "context" | "dockerfile" | "target"; + readonly target: "" | "context" | "dockerfile" | "target"; + readonly code: string; +}; + +/** Private authored mapping; its path values must never enter public reports. */ +export type LegacyComposeBuildMapping = { + readonly build: Build; + readonly fields: readonly BuildField[]; +}; + +function relativeLiteral(value: unknown): string | undefined { + const decoded = literalComposeArg(value); + return decoded !== undefined && + decoded.length > 0 && + !UNSAFE_PATH.test(decoded) && + !decoded.startsWith("/") && + !decoded.startsWith("~") + ? decoded + : undefined; +} + +function inside(path: string): boolean { + return path !== ".." && !path.startsWith("../"); +} + +/** + * Pure raw Compose build conversion for `.hack/docker-compose.yml` only. Context + * is rebased from the legacy `.hack` directory to the native checkout root; + * Dockerfile remains relative to that context. No paths or image caches are read. + * Extra fields refuse the entire mapping, including explicitly empty options. + */ +export function mapLegacyComposeBuild( + value: unknown +): LegacyComposeBuildMapping | undefined { + const shorthand = typeof value === "string"; + if ( + !( + shorthand || + (isRecord(value) && + Object.keys(value).every((key) => + ["context", "dockerfile", "target"].includes(key) + )) + ) + ) { + return undefined; + } + const source = shorthand ? { context: value } : value; + if (!isRecord(source)) { + return undefined; + } + const context = relativeLiteral( + Object.hasOwn(source, "context") ? source.context : "." + ); + if (context === undefined) { + return undefined; + } + const rebased = posix + .normalize(`.hack/${context}`) + .replace(TRAILING_SLASH, ""); + if (!inside(rebased)) { + return undefined; + } + const build: Build = { context: rebased }; + const objectContextCode = Object.hasOwn(source, "context") + ? "exact" + : "compose_build_context_default"; + const fields: BuildField[] = [ + { + source: "", + target: shorthand ? "context" : "", + code: shorthand ? "compose_short_build_context" : objectContextCode, + }, + ]; + if (!shorthand && Object.hasOwn(source, "context")) { + fields.push({ + source: "context", + target: "context", + code: "compose_build_context_rebased", + }); + } + if (Object.hasOwn(source, "dockerfile")) { + const dockerfile = relativeLiteral(source.dockerfile); + if (dockerfile === undefined || dockerfile.endsWith("/")) { + return undefined; + } + const normalized = posix.normalize(dockerfile); + if (normalized === "." || !inside(normalized)) { + return undefined; + } + build.dockerfile = normalized; + fields.push({ + source: "dockerfile", + target: "dockerfile", + code: + normalized === source.dockerfile + ? "exact" + : "compose_build_path_literal", + }); + } + if (Object.hasOwn(source, "target")) { + if (typeof source.target !== "string" || !TARGET.test(source.target)) { + return undefined; + } + build.target = source.target; + fields.push({ source: "target", target: "target", code: "exact" }); + } + return { build, fields }; +} diff --git a/src/lib/native-config-import-literal.ts b/src/lib/native-config-import-literal.ts new file mode 100644 index 000000000..1c57ba610 --- /dev/null +++ b/src/lib/native-config-import-literal.ts @@ -0,0 +1,19 @@ +/** Decode complete Compose dollar pairs without evaluating caller environment. */ +export function literalComposeArg(value: unknown): string | undefined { + if (typeof value !== "string" || value.includes("\0")) { + return undefined; + } + let decoded = ""; + for (let index = 0; index < value.length; index++) { + if (value[index] !== "$") { + decoded += value[index]; + continue; + } + if (value[index + 1] !== "$") { + return undefined; + } + decoded += "$"; + index++; + } + return decoded; +} diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 46dc199c2..887a254f2 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -1,4 +1,6 @@ import { isRecord } from "./guards.ts"; +import { mapLegacyComposeBuild } from "./native-config-import-build.ts"; +import { literalComposeArg } from "./native-config-import-literal.ts"; import { type ImportDocument, type ImportField, @@ -167,7 +169,13 @@ function mapLegacyNativeInput(opts: { const context = { config: config.value, candidate, mark, refuse }; mapOverlay(context); mapWorktree(context); - mapServices({ source: compose.value.services, candidate, mark, refuse }); + mapServices({ + source: compose.value.services, + candidate, + mark, + refuse, + buildPreview: !opts.storageAdoption, + }); if (opts.storageAdoption) { mapStorageCandidate({ config: config.value, @@ -414,24 +422,6 @@ function staticText(value: unknown): value is string { !value.includes("\0") ); } -function literalComposeArg(value: unknown): string | undefined { - if (typeof value !== "string" || value.includes("\0")) { - return undefined; - } - let decoded = ""; - for (let index = 0; index < value.length; index++) { - if (value[index] !== "$") { - decoded += value[index]; - continue; - } - if (value[index + 1] !== "$") { - return undefined; - } - decoded += "$"; - index++; - } - return decoded; -} function composeWordSpace(character: string | undefined): boolean { return ( character === " " || @@ -657,10 +647,12 @@ function mapService( readonly source: Record; readonly pointer: string; readonly profiles: Set; + readonly buildPreview: boolean; } ) { const service: Record = {}; opts.mark("compose", opts.pointer, opts.pointer); + const hasBuild = Object.hasOwn(opts.source, "build"); for (const [key, raw] of Object.entries(opts.source)) { if (!Object.hasOwn(SERVICE_RULES, key)) { continue; @@ -675,7 +667,12 @@ function mapService( opts.refuse("compose", pointer, "empty_command_unrepresentable"); continue; } - const value = SERVICE_RULES[key]?.(raw); + let value: unknown; + if (key === "pull_policy" && opts.buildPreview && hasBuild) { + value = raw === "build" ? raw : undefined; + } else { + value = SERVICE_RULES[key]?.(raw); + } if (value === undefined) { opts.refuse("compose", pointer, "invalid_or_ambiguous_value"); continue; @@ -696,7 +693,12 @@ function mapService( } } } - if (!Object.hasOwn(opts.source, "image")) { + if (opts.buildPreview && hasBuild) { + mapServiceBuild({ ...opts, service }); + } + if ( + !(Object.hasOwn(opts.source, "image") || (opts.buildPreview && hasBuild)) + ) { opts.refuse( "compose", `${opts.pointer}/image`, @@ -705,9 +707,44 @@ function mapService( } return service; } + +function mapServiceBuild( + opts: Pick & { + readonly source: Record; + readonly service: Record; + readonly pointer: string; + } +): void { + const pointer = importPointer(opts.pointer, "build"); + if (Object.hasOwn(opts.source, "image")) { + opts.refuse("compose", pointer, "image_build_exclusive"); + opts.refuse( + "compose", + importPointer(opts.pointer, "image"), + "image_build_exclusive" + ); + return; + } + const mapped = mapLegacyComposeBuild(opts.source.build); + if (!mapped) { + opts.refuse("compose", pointer, "invalid_or_unsupported_build"); + return; + } + opts.service.build = mapped.build; + for (const field of mapped.fields) { + opts.mark( + "compose", + field.source === "" ? pointer : importPointer(pointer, field.source), + field.target === "" ? pointer : importPointer(pointer, field.target), + field.code + ); + } +} + function mapServices( opts: Pick & { readonly source: unknown; + readonly buildPreview: boolean; } ) { if (!(isRecord(opts.source) && Object.keys(opts.source).length)) { diff --git a/tests/native-config-import-build.test.ts b/tests/native-config-import-build.test.ts new file mode 100644 index 000000000..8128076da --- /dev/null +++ b/tests/native-config-import-build.test.ts @@ -0,0 +1,615 @@ +import { expect, spyOn, test } from "bun:test"; +import { + chmod, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + nativeImportBuildProjection, + runNativeImportBuildConfig, +} from "../scripts/check-native-config-import-build.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { mapLegacyComposeBuild } from "../src/lib/native-config-import-build.ts"; +import { + mapLegacyNativeImport, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; + +const CANARY = "synthetic-private-build-import"; +function mapped(service: unknown, inactive = false) { + return mapLegacyNativeImport({ + configText: '{"name":"fixture"}', + composeText: JSON.stringify({ + services: { + web: { image: "fixture:1" }, + builder: { + ...(typeof service === "object" && service !== null ? service : {}), + ...(inactive ? { profiles: ["later"] } : {}), + }, + }, + }), + }); +} +function refused(result: ReturnType, code: string) { + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ status: "refused", code }) + ); + expect(JSON.stringify(result)).not.toContain(CANARY); +} + +test.each([ + [".", ".hack"], + ["./", ".hack"], + ["..", "."], + ["../apps/api", "apps/api"], + ["./context//nested/", ".hack/context/nested"], + ["../apps/./api/../web", "apps/web"], + ["build-$${AMBIENT}", ".hack/build-${AMBIENT}"], + ["../literal-$$$$/percent-%{value}", "literal-$$/percent-%{value}"], +])("short build %p preserves its legacy base as native context %p", (input, context) => { + const result = mapped({ build: input }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + services: { builder: { build: { context } } }, + }); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/builder/build", + target: "/services/builder/build/context", + status: "normalized", + code: "compose_short_build_context", + }) + ); + expect(JSON.stringify(result)).not.toContain(context); +}); + +test("object context/default Dockerfile/stage/policy preserve absence and authored values", () => { + const source = { + build: { context: "../app", target: "stage.v1_selected" }, + pull_policy: "build", + command: ["echo", "$${NOT_EXPANDED}"], + }; + const before = JSON.stringify(source); + const result = mapped(source, true); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + profiles: ["later"], + services: { + builder: { + build: { context: "app", target: "stage.v1_selected" }, + pull_policy: "build", + command: { exec: ["echo", "${NOT_EXPANDED}"] }, + profiles: ["later"], + }, + }, + }); + expect(JSON.stringify(source)).toBe(before); + expect(result.candidate).not.toHaveProperty( + "services.builder.build.dockerfile" + ); + expect(Object.keys(result)).toEqual(["report"]); + expect(Object.isFrozen(result.candidate)).toBe(true); + expect(JSON.stringify(result)).not.toContain("${NOT_EXPANDED}"); +}); + +test.each([ + {}, + { dockerfile: "docker/Dockerfile" }, + { target: "selected" }, +])("object build %p with omitted context retains the Compose default directory", (build) => { + const result = mapped({ build }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + services: { builder: { build: { context: ".hack", ...build } } }, + }); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/builder/build", + code: "compose_build_context_default", + status: "normalized", + }) + ); + expect(result.candidate).not.toHaveProperty("services.builder.pull_policy"); +}); + +test("explicit nested Dockerfile dollars decode once and remain context relative", () => { + const result = mapped({ + build: { + context: "../app-$${AMBIENT}", + dockerfile: "./docker//Dockerfile-$$$$-$${AMBIENT}", + target: "constructor", + }, + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + services: { + builder: { + build: { + context: "app-${AMBIENT}", + dockerfile: "docker/Dockerfile-$$-${AMBIENT}", + target: "constructor", + }, + }, + }, + }); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/builder/build/dockerfile", + target: "/services/builder/build/dockerfile", + code: "compose_build_path_literal", + status: "normalized", + }) + ); +}); + +test("report binds authored build leaf locations without emitting private paths", () => { + const result = mapLegacyNativeImport({ + configText: '{"name":"fixture"}', + composeText: `services:\n builder:\n build:\n context: ../${CANARY}\n dockerfile: Dockerfile\n target: selected\n`, + }); + expect(result.report.complete).toBe(true); + expect(result.report.fields).toContainEqual({ + document: "compose", + pointer: "/services/builder/build/context", + line: 4, + column: 7, + status: "normalized", + code: "compose_build_context_rebased", + target: "/services/builder/build/context", + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(JSON.stringify({ ...result })).not.toContain(CANARY); +}); + +test.each([ + "args", + "cache_from", + "cache_to", + "ssh", + "secrets", + "labels", + "network", + "platform", + "platforms", + "additional_contexts", + "pull", + "no_cache", + "dockerfile_inline", + "tags", + "extra_hosts", + "entitlements", + "privileged", + "provenance", + "sbom", + "shm_size", + "ulimits", + "isolation", + "constructor", +])("unknown build field %s refuses selected and inactive inputs without image fallback", (field) => { + for (const inactive of [false, true]) { + for (const extra of [null, false, [], {}, CANARY]) { + const result = mapped( + { build: { context: "..", [field]: extra } }, + inactive + ); + refused(result, "invalid_or_unsupported_build"); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: `/services/builder/build/${field}`, + status: "refused", + code: "unsupported_field", + }) + ); + } + } +}); + +test.each( + [ + null, + false, + 7, + [], + [".."], + "", + "../../outside", + "/absolute", + "~/home", + "https://host/repo.git", + "git@host:repo", + "C:\\build", + "../nul\0path", + "../line\npath", + "../line\rpath", + "../$VAR", + "../${VAR}", + "../$$$", + { context: null }, + { context: [] }, + { context: false }, + { context: "..", dockerfile: null }, + { context: "..", dockerfile: "../Dockerfile" }, + { context: "..", dockerfile: "docker/../../Dockerfile" }, + { context: "..", dockerfile: "." }, + { context: "..", dockerfile: "Dockerfile/" }, + { context: "..", dockerfile: "/Dockerfile" }, + { context: "..", dockerfile: "~/Dockerfile" }, + { context: "..", dockerfile: "${PRIVATE}" }, + { context: "..", target: null }, + { context: "..", target: "Upper" }, + { context: "..", target: "${PRIVATE}" }, + { context: "..", target: "a".repeat(64) }, + ].map((build) => [build] as const) +)("invalid build shape/path %p refuses in an inactive profile", (build) => { + const result = mapped({ build }, true); + refused(result, "invalid_or_unsupported_build"); +}); + +test.each([ + "always", + "never", + "missing", + "weekly", + null, + true, +])("build policy %p cannot become a default or image policy", (pull_policy) => { + refused( + mapped({ build: "..", pull_policy }, true), + "invalid_or_ambiguous_value" + ); +}); + +test.each([ + "fixture:1", + null, + "", + CANARY, +])("combined image %p and build refuse both source fields instead of falling back", (image) => { + const result = mapped({ image, build: "..", pull_policy: "build" }); + refused(result, "image_build_exclusive"); + for (const field of ["image", "build"]) { + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: `/services/builder/${field}`, + code: "image_build_exclusive", + status: "refused", + }) + ); + } +}); + +test("image-only mapping and acquisition intent remain unchanged", () => { + for (const policy of [undefined, "always", "never", "missing"]) { + const service = { + image: "fixture:1", + ...(policy ? { pull_policy: policy } : {}), + }; + const result = mapped(service); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ services: { builder: service } }); + } + refused( + mapped({ image: "fixture:1", pull_policy: "build" }), + "invalid_or_ambiguous_value" + ); +}); + +test("preview build capability never grants retained storage adoption", () => { + for (const service of [ + { build: ".." }, + { image: "fixture:1", build: ".." }, + ]) { + const result = mapLegacyNativeStorageAdoption({ + configText: '{"name":"fixture"}', + composeText: JSON.stringify({ services: { builder: service } }), + }); + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/builder/build", + code: "unsupported_field", + status: "refused", + }) + ); + } +}); + +test("pure helper ignores inherited source keys and never mutates its input", () => { + const build = { + context: "..", + dockerfile: "./docker/Dockerfile", + target: "selected", + }; + const before = JSON.stringify(build); + expect(mapLegacyComposeBuild(build)?.build).toEqual({ + context: ".", + dockerfile: "docker/Dockerfile", + target: "selected", + }); + expect(JSON.stringify(build)).toBe(before); + expect( + mapLegacyComposeBuild( + Object.create({ context: "../../foreign", secrets: CANARY }) + )?.build + ).toEqual({ context: ".hack" }); +}); + +test("basic-build mapping cannot invoke a compiler, Docker, Compose or builder", () => { + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Pure mapping must not spawn"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("Pure mapping must not spawn"); + }); + try { + expect( + mapped({ build: { context: "..", target: "selected" } }).report.complete + ).toBe(true); + refused( + mapped({ build: { context: "..", args: { PRIVATE: CANARY } } }, true), + "invalid_or_unsupported_build" + ); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } finally { + spawn.mockRestore(); + spawnSync.mockRestore(); + } +}); + +test("config-only correspondence projection rejects altered source/stage/policy and extra build options", () => { + const base = { + services: Object.fromEntries( + ["default", "root", "nested", "literal"].map((name) => [ + name, + { + build: { + context: "/verified/context", + dockerfile: "./docker/Dockerfile", + }, + }, + ]) + ), + }; + const expected = Object.fromEntries( + ["default", "literal", "nested", "root"].map((name) => [ + name, + { + context: "/verified/context", + dockerfile: "/verified/context/docker/Dockerfile", + }, + ]) + ); + expect(nativeImportBuildProjection(base)).toEqual(expected); + for (const changed of [ + { build: undefined }, + { image: "fallback", build: base.services.root?.build }, + { build: { context: "relative", dockerfile: "Dockerfile" } }, + { + build: { + context: "/verified/context", + dockerfile: "/foreign/Dockerfile", + }, + }, + { + build: { + context: "/verified/context", + dockerfile: "Dockerfile", + args: {}, + }, + }, + { + build: { + context: "/verified/context", + dockerfile: "Dockerfile", + target: null, + }, + }, + { + build: Object.create({ + context: "/verified/context", + dockerfile: "Dockerfile", + }), + }, + { build: base.services.root?.build, pull_policy: "never" }, + ]) { + expect(() => + nativeImportBuildProjection({ + services: { ...base.services, root: changed }, + }) + ).toThrow(); + } + for (const changed of [ + { ...base.services, extra: base.services.root }, + { default: base.services.default }, + ]) { + expect(() => nativeImportBuildProjection({ services: changed })).toThrow(); + } + for (const build of [ + { context: "/verified/foreign", dockerfile: "Dockerfile" }, + { context: "/verified/context", dockerfile: "wrong/Dockerfile" }, + { + context: "/verified/context", + dockerfile: "./docker/Dockerfile", + target: "foreign", + }, + ]) { + expect( + nativeImportBuildProjection({ + services: { ...base.services, root: { build } }, + }) + ).not.toEqual(expected); + } +}); + +async function commandFixture( + body: string, + check: ( + opts: { + readonly binary: string; + readonly cwd: string; + readonly file: string; + readonly env: Readonly>; + readonly timeoutMs: number; + }, + root: string + ) => Promise +) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "build-import-capture-")) + ); + const binary = join(root, "compose"); + try { + await writeFile(binary, `#!${process.execPath}\n${body}\n`, { + mode: 0o700, + }); + await chmod(binary, 0o700); + await check( + { + binary, + cwd: root, + file: join(root, "input"), + env: { PATH: "/usr/bin:/bin" }, + timeoutMs: 500, + }, + root + ); + } finally { + await rm(root, { recursive: true }); + } +} + +test("config projection preserves escaped dollar serialization without another decode or ambient expansion", () => { + const source = (context: string, dockerfile: string) => ({ + services: Object.fromEntries( + ["default", "root", "nested", "literal"].map((name) => [ + name, + { build: { context, dockerfile } }, + ]) + ), + }); + const context = "/verified/literal-$${AMBIENT}"; + const dockerfile = "docker/Dockerfile-$${AMBIENT}"; + const expected = Object.fromEntries( + ["default", "literal", "nested", "root"].map((name) => [ + name, + { context, dockerfile: `${context}/${dockerfile}` }, + ]) + ); + expect(nativeImportBuildProjection(source(context, dockerfile))).toEqual( + expected + ); + for (const altered of [ + source("/verified/literal-${AMBIENT}", dockerfile), + source(context, "docker/Dockerfile-${AMBIENT}"), + source("/verified/literal-$$$${AMBIENT}", dockerfile), + source( + "/verified/literal-must-not-expand", + "docker/Dockerfile-must-not-expand" + ), + ]) { + expect(nativeImportBuildProjection(altered)).not.toEqual(expected); + } +}); + +test("normal completed config capture and later publication refusal never signal a former group", async () => { + await commandFixture('console.log("bounded");', async (opts) => { + const signal = spyOn(process, "kill"); + try { + expect((await runNativeImportBuildConfig(opts)).stdout).toBe("bounded\n"); + expect(signal).not.toHaveBeenCalled(); + await expect( + runNativeImportBuildConfig({ + ...opts, + onSettled: async () => { + throw new Error("private publication failure"); + }, + }) + ).rejects.toThrow("values omitted"); + expect(signal).not.toHaveBeenCalled(); + } finally { + signal.mockRestore(); + } + }); +}); + +test("leader exit leaves descendant-held capture cleanup armed until bounded refusal", async () => { + await commandFixture( + ` + const keeper = Bun.spawn([process.execPath, "-e", 'process.on("SIGTERM",()=>{}); await Bun.sleep(1500); await Bun.write("late-marker","unexpected");'], {stdin:"ignore",stdout:"inherit",stderr:"inherit"}); + await Bun.write("keeper",String(keeper.pid)); + keeper.unref(); + process.exit(0); + `, + async (opts, root) => { + const started = performance.now(); + await expect(runNativeImportBuildConfig(opts)).rejects.toThrow( + "values omitted" + ); + expect(performance.now() - started).toBeLessThan(2500); + const pid = Number(await readFile(join(root, "keeper"), "utf8")); + expect(Number.isInteger(pid) && pid > 0).toBe(true); + const deadline = Date.now() + 1000; + while (true) { + try { + process.kill(pid, 0); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ESRCH") { + break; + } + throw new Error("Owned descendant absence was not proven"); + } + if (Date.now() >= deadline) { + throw new Error("Owned descendant remained live"); + } + await Bun.sleep(5); + } + expect(await Bun.file(join(root, "late-marker")).exists()).toBe(false); + } + ); +}); + +test.each([ + "overflow", + "read-failure", +])("capture %s kills and reaps before fixture cleanup", async (mode) => { + await commandFixture( + ` + await Bun.write("leader",String(process.pid)); + process.stdout.write(${mode === "overflow" ? '"x".repeat(256*1024+1)' : '"fault"'}); + await Bun.sleep(1500); + await Bun.write("late-marker","unexpected"); + `, + async (opts, root) => { + await expect( + runNativeImportBuildConfig({ + ...opts, + ...(mode === "read-failure" + ? { + afterRead: () => { + throw new Error("private stream failure"); + }, + } + : {}), + }) + ).rejects.toThrow("values omitted"); + const pid = Number(await readFile(join(root, "leader"), "utf8")); + let absent = false; + try { + process.kill(pid, 0); + } catch (error: unknown) { + absent = isRecord(error) && error.code === "ESRCH"; + } + expect(absent).toBe(true); + expect(await Bun.file(join(root, "late-marker")).exists()).toBe(false); + } + ); +}); From e8466917b1480c7f282b8aee3f50ededdde1a237 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:23:45 -0400 Subject: [PATCH 02/26] feat: add retained basic build adoption proof --- bun.lock | 3 + docs/reference/native-compose-adoption.md | 58 +- docs/reference/native-config-import.md | 6 +- package.json | 1 + src/commands/config-adopt.ts | 2 +- src/lib/native-compose-adoption-binding.ts | 93 +++- .../native-compose-adoption-build-ignore.ts | 67 +++ .../native-compose-adoption-build-images.ts | 157 ++++++ src/lib/native-compose-adoption-build.ts | 518 ++++++++++++++++++ src/lib/native-compose-adoption-command.ts | 5 +- src/lib/native-compose-adoption-execution.ts | 6 +- src/lib/native-compose-adoption-generation.ts | 121 +++- src/lib/native-compose-adoption-plan.ts | 17 +- src/lib/native-compose-adoption-preview.ts | 11 +- src/lib/native-compose-adoption-receipt.ts | 9 +- src/lib/native-config-import-plan.ts | 26 +- tests/helpers/retained-build-adoption.ts | 226 ++++++++ ...-compose-adoption-build-generation.test.ts | 350 ++++++++++++ ...tive-compose-adoption-build-source.test.ts | 335 +++++++++++ 19 files changed, 1967 insertions(+), 44 deletions(-) create mode 100644 src/lib/native-compose-adoption-build-ignore.ts create mode 100644 src/lib/native-compose-adoption-build-images.ts create mode 100644 src/lib/native-compose-adoption-build.ts create mode 100644 tests/helpers/retained-build-adoption.ts create mode 100644 tests/native-compose-adoption-build-generation.test.ts create mode 100644 tests/native-compose-adoption-build-source.test.ts diff --git a/bun.lock b/bun.lock index 7f8583cc4..cf47220fc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "hack-cli", "dependencies": { + "@balena/dockerignore": "1.0.2", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "@modelcontextprotocol/sdk": "1.26.0", @@ -74,6 +75,8 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index eb5f9a1b1..381d1fff2 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -108,6 +108,61 @@ same-identity byte repair, separate rollback and exact owned cleanup. It requires the current compiled CLI and companion compiler; it does not qualify completed jobs or container recreation. +## Retained basic builds + +The distinct version 9 owner handles existing basic build-only services with +qualified named data volumes and the existing default bridge. Context, relative +Dockerfile and target use the closed import mapping, with current local files +additionally verified. A service must omit `pull_policy`: explicit `build` +requires builder execution and cannot be satisfied by starting an old image. +The owner never builds, pulls, creates a container or substitutes an image during +the format switch or retained execution. Explicit rebuild/recreation requests +refuse. Image-only binding APIs and versions 1–5 retain their prior contracts. +Versions for custom networks, completed jobs and retained files are separate; +their combinations with this first build proof refuse, as do profiles, readiness, +managed/generated inputs, typed locals and literal-dollar build paths. + +Included context files, the Dockerfile, optional root and Dockerfile-specific +ignore files, and their safe filesystem identities are privately pinned. A +Dockerfile-specific ignore file takes precedence, while presence and bytes of +both files remain bound. The pinned `@balena/dockerignore` Moby port handles only +the qualified case-sensitive grammar: literal normalized paths, `!` negation, +bare `**`, blank lines and comments. Other globs, escapes, BOMs and ambiguous +paths refuse. Every possible adopted-owned path must be excluded by the effective +rules, including Git markers, `.hack/.internal`, `.hack/.branch` and the switched +authored files. Excluded subtrees are not read. Parent negations include +descendants: `**` followed by `!.hack` does not isolate future private outputs. +Such a context refuses unless later literal exclusions close those paths. +Root and `.hack` contexts can qualify through these exact exclusions. + +One acquisition is limited to 16 builds, depth 32, 256 captured entries, 4096 +directory names, 16 MiB of bytes and a 48 KiB private proof; the existing stable +file owner also limits each file to 1 MiB. Included byte, identity or mode changes, +included additions/removals, ignore presence changes and unsafe paths refuse. +Same-inode exact byte repair can restore a saved proof; it does not repair a +strict prepared authored-source timestamp or make editor races atomic. + +The selected read-only Compose query supplies each original image reference. +Container IDs and birth, image IDs and birth, and current tag resolution must +match the privately saved observation, along with the existing config hashes, +mounts, network identity and volume creation identities. No image environment, +command or layer contents are read. These facts attest the current retained +image and current included source separately; they do not establish which source +historically built that image. Missing images or retargeted tags refuse before +effects. No image ownership for removal is granted by this proof. + +Preparation, dry-run and saved execution use the same closed owner. Public output +contains field provenance and counts, never context hashes, image references or +private capabilities. Version 9 requires a finite remaining mutation deadline, +the whole original selection and the existing signal/process-group owner. +Starts, stops and recovery consume original IDs; source or image drift retains +pending evidence. Rollback restores the exact original authored inputs after +verified stop and keeps their original data. Older upgraded owners that know +only versions 1–5 refuse the new proof; this is not a universal old-launcher +fence. Current Moby/BuildKit ignore parity and maintained two-worktree SQL, +image/ID/birth, recovery and rollback acceptance qualify this slice separately +from pure preview and synthetic model controls. Full NC04 remains open. + The version 4 typed-local slice reads optional `.hack/hack.local.json` at the selected checkout and verified inherited primary in the same issued private source acquisition. It accepts only `schema_version: 1` and an optional `environment` @@ -162,7 +217,8 @@ in the resource binding. Other authored network policies remain unsupported. Engine ID, resource inventories and source bytes are rechecked, and acquisition compares two complete private observations. The Docker routing environment is captured for later comparison. Queries never -request container environment or image configuration. Execution admission also +request container environment or full image configuration; version 9 additionally +reads the minimal image reference, ID and birth described above. Execution admission also compares Compose configuration hashes from the saved ordered sources with the engine-created `com.docker.compose.config-hash` label on each original container. Those private digests never enter reports, authored files or resource labels. diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index ae8808aa4..0a724c1dd 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -97,8 +97,10 @@ service's `pull_policy: build` is a separate supported acquisition requirement. Combined `build` and `image` refuse because the native model requires exactly one source. Invalid builds cannot fall back to an authored image or a default policy. -This expands read-only preview only. Retained-container adoption still uses its -separate image-only mapping and refuses builds. The mapper runs no Compose +This expands read-only preview only. The image-only retained baseline remains +closed. A separate [proof-bearing basic-build owner](native-compose-adoption.md#retained-basic-builds) +qualifies current included source and exact existing images before adoption; a +complete pure preview never supplies that authority. The mapper runs no Compose normalization, builder or runtime command, and does not validate Dockerfile contents, path existence or filesystem identity. The authoritative compiler still must validate the whole private candidate before a complete CLI preview; actual diff --git a/package.json b/package.json index ce4db2e8c..73053cacc 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "typescript": "^5" }, "dependencies": { + "@balena/dockerignore": "1.0.2", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "elysia": "^1.4.9", diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index 8e77ccc85..c8dc5a7fa 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -110,7 +110,7 @@ async function adoptPrepared( if (opts.signal.aborted) { throw new Error("Legacy adoption cancelled; values omitted."); } - if (input.retainedPlan.requiresV5) { + if (input.retainedPlan.requiresV5 || input.retainedBuild) { return await runLegacyComposeRetainedOperation({ input, operation: "stop", diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 05576fcc6..ad45b03d8 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,10 +1,19 @@ import { resolve } from "node:path"; import { isRecord } from "./guards.ts"; +import { + acquireLegacyComposeBuildSource, + type LegacyComposeBuildSourceProof, +} from "./native-compose-adoption-build.ts"; +import { + inspectLegacyComposeRetainedBuildImages, + type LegacyComposeRetainedBuildImage, +} from "./native-compose-adoption-build-images.ts"; import { legacyComposeAdoptionLayoutSupported } from "./native-compose-adoption-contract.ts"; import { retainLegacyAdoptionLocalRefusal } from "./native-compose-adoption-local.ts"; import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, + planLegacyComposeRetainedBasicBuildAdoption, } from "./native-compose-adoption-plan.ts"; import { hasLegacyComposeGeneratedSources, @@ -599,6 +608,10 @@ export type LegacyComposeAdoptionBinding = { readonly compose: NativeConfigImportSourceIdentity; }; readonly projection?: Readonly; + readonly build?: { + readonly source: LegacyComposeBuildSourceProof; + readonly images: readonly LegacyComposeRetainedBuildImage[]; + }; }>; }; function translate(error: unknown, signal?: AbortSignal): never { @@ -627,12 +640,36 @@ function translate(error: unknown, signal?: AbortSignal): never { * consume this binding explicitly and recheck it on the same engine under its * lease. Repeated checks cannot atomically freeze Docker or external editors. */ -export async function acquireLegacyComposeAdoptionBinding(input: { +type BindingSelection = { readonly projectRoot: string; readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly binary?: string; -}): Promise { +}; +export async function acquireLegacyComposeAdoptionBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "image-only"); +} + +/** Distinct current-source/image proof; ordinary image-only admission is unchanged. */ +export async function acquireLegacyComposeRetainedBasicBuildBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "basic-build"); +} + +/** Preparation self-selects a qualified owner; no caller-provided binding or bypass is accepted. */ +export async function acquireLegacyComposeAdoptionPreparationBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "preparation"); +} + +async function acquireBinding( + input: BindingSelection, + purpose: "image-only" | "basic-build" | "preparation" +): Promise { let signal: AbortSignal | undefined; try { const selected = selection(input); @@ -650,19 +687,25 @@ export async function acquireLegacyComposeAdoptionBinding(input: { if (!source.ok) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } - const planned = planLegacyComposeAdoption({ - configText: source.configText, - composeText: source.composeText, - }); + const ordinary = planLegacyComposeAdoption(source); + const basic = + purpose === "basic-build" || + (purpose === "preparation" && !ordinary.intent); + const planned = basic + ? planLegacyComposeRetainedBasicBuildAdoption(source) + : ordinary; const intent = planned.intent; if (!intent) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } + const buildSource = basic + ? await acquireLegacyComposeBuildSource({ source, signal }) + : undefined; const mapped = mapLegacyNativeStorageAdoption({ configText: source.configText, composeText: source.composeText, }); - const candidate = mapped.candidate; + const candidate = buildSource?.candidate ?? mapped.candidate; let projection: LegacyComposeAdoptionProjection | undefined; let projected: Readonly | undefined; const generatedPresent = await hasLegacyComposeGeneratedSources( @@ -678,6 +721,9 @@ export async function acquireLegacyComposeAdoptionBinding(input: { signal, }))) ) { + if (buildSource) { + refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); + } projection = await LegacyComposeAdoptionProjection.acquire({ source, signal, @@ -697,6 +743,9 @@ export async function acquireLegacyComposeAdoptionBinding(input: { await projection.assertFresh({ signal: selectedSignal }); return; } + if (buildSource) { + await buildSource.assertFresh({ signal: selectedSignal }); + } if (await hasLegacyComposeGeneratedSources(root, selectedSignal)) { refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); } @@ -721,6 +770,14 @@ export async function acquireLegacyComposeAdoptionBinding(input: { timeoutMs, composeFiles: projected?.composeFiles, }); + const buildImages = buildSource + ? await inspectLegacyComposeRetainedBuildImages({ + binding: baseline, + composeFile: baseline.composeFile, + signal, + timeoutMs, + }) + : undefined; freezeImportValue(baseline); const assertFresh = async (current: { readonly projectRoot: string; @@ -752,6 +809,19 @@ export async function acquireLegacyComposeAdoptionBinding(input: { if (JSON.stringify(observed) !== JSON.stringify(baseline)) { refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); } + if ( + buildImages && + JSON.stringify( + await inspectLegacyComposeRetainedBuildImages({ + binding: observed, + composeFile: observed.composeFile, + signal: currentSignal, + timeoutMs, + }) + ) !== JSON.stringify(buildImages) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } await source.assertFresh({ signal: currentSignal }); await layoutSupported(currentSignal); cancelled(signal); @@ -783,6 +853,14 @@ export async function acquireLegacyComposeAdoptionBinding(input: { binding: baseline, sourceFiles: source.sourceFiles, ...(projected ? { projection: projected } : {}), + ...(buildSource && buildImages + ? { + build: Object.freeze({ + source: buildSource.proof, + images: buildImages, + }), + } + : {}), }; for (const key of [ "configText", @@ -790,6 +868,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: { "binding", "sourceFiles", "projection", + "build", ]) { Object.defineProperty(result, key, { enumerable: false }); } diff --git a/src/lib/native-compose-adoption-build-ignore.ts b/src/lib/native-compose-adoption-build-ignore.ts new file mode 100644 index 000000000..83aedf7c3 --- /dev/null +++ b/src/lib/native-compose-adoption-build-ignore.ts @@ -0,0 +1,67 @@ +import createDockerignore from "@balena/dockerignore"; + +const LITERAL = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/; +const MAX_RULES = 128; +const MAX_IGNORE_BYTES = 16 * 1024; + +function refuse(): never { + throw new Error( + "Legacy retained build ignore mapping is unsupported; values omitted." + ); +} + +/** + * Deliberately closed Dockerignore grammar. The pinned Moby port owns matching; + * qualification is limited to literal paths, their parent matches and bare **. + * Other globs, escapes, whitespace paths and normalization ambiguities refuse. + */ +export function legacyComposeBuildIgnore(text: string) { + if (Buffer.byteLength(text) > MAX_IGNORE_BYTES || text.includes("\uFEFF")) { + refuse(); + } + const rules: string[] = []; + const literals: string[] = []; + for (const raw of text.split(/\r?\n/)) { + if (raw.startsWith("#") || !raw.trim()) { + continue; + } + const rule = raw.trim(); + const literal = rule.startsWith("!") ? rule.slice(1) : rule; + if ( + (rule !== "**" && + (!LITERAL.test(literal) || + literal.split("/").some((part) => part === "." || part === ".."))) || + rules.length >= MAX_RULES + ) { + refuse(); + } + rules.push(rule); + if (rule !== "**") { + literals.push(literal); + } + } + const matcher = createDockerignore({ ignorecase: false }).add(rules); + // This name denotes the unmatched child region, rather than a filesystem + // object. It cannot coincide with any admitted literal rule component. + let unmatched = "__hack_build_unmatched__"; + const components = new Set(literals.flatMap((path) => path.split("/"))); + while (components.has(unmatched)) { + unmatched += "_"; + } + function excluded(path: string) { + return matcher.ignores(path); + } + function subtreeExcluded(path: string) { + const regions = new Set([path, `${path}/${unmatched}`]); + for (const literal of literals) { + if (literal.startsWith(`${path}/`)) { + regions.add(literal); + regions.add(`${literal}/${unmatched}`); + } + } + // With this grammar, matching changes only at a literal prefix boundary. + // Its own node and unmatched-child region cover every descendant decision. + return [...regions].every(excluded); + } + return Object.freeze({ excluded, subtreeExcluded }); +} diff --git a/src/lib/native-compose-adoption-build-images.ts b/src/lib/native-compose-adoption-build-images.ts new file mode 100644 index 000000000..bb74450ee --- /dev/null +++ b/src/lib/native-compose-adoption-build-images.ts @@ -0,0 +1,157 @@ +import { isRecord } from "./guards.ts"; +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { freezeImportValue } from "./native-config-import-plan.ts"; + +const ID = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,511}$/; +const TIME = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const CONTAINER = + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}'; +const BUILT_IMAGE = '{"id":{{json .Id}},"createdAt":{{json .Created}}}'; + +/** Private observation of the current image; it does not attest historical build inputs. */ +export type LegacyComposeRetainedBuildImage = { + readonly service: string; + readonly container: string; + readonly containerCreatedAt: string; + readonly reference: string; + readonly image: string; + readonly imageCreatedAt: string; +}; +function refuse(): never { + throw new Error( + "Legacy retained build image identity is unavailable or changed; values omitted." + ); +} +function time(value: unknown): value is string { + return ( + typeof value === "string" && + TIME.test(value) && + Number.isFinite(Date.parse(value)) + ); +} +function record(text: string, expected: string) { + const value: unknown = JSON.parse(text); + if (!isRecord(value) || Object.keys(value).sort().join() !== expected) { + refuse(); + } + return value; +} + +/** + * Uses the authoritative selected Compose image reference, not a guessed name. + * Reads only IDs, reference and creation time; never image/container environment, + * command or layer contents. Same daemon and exact original IDs are rechecked. + * No tag mutation, allocation, build, pull or image removal is authorized. + */ +export async function inspectLegacyComposeRetainedBuildImages(opts: { + readonly binding: LegacyComposeVerifiedBinding; + readonly composeFile: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + try { + const { composeFile, signal, timeoutMs } = opts; + const { binding_version, composeProject, projectRoot, engineId } = + opts.binding; + const originals = opts.binding.containers.map(({ id, service }) => ({ + id, + service, + })); + if (binding_version !== 1 || !NAME.test(composeProject)) { + refuse(); + } + const probe = createNativeComposeProbe({ signal, timeoutMs }); + const engine = async () => + (await probe(["info", "--format", "{{json .ID}}"])).trim(); + const baselineEngine = await engine(); + if (JSON.parse(baselineEngine) !== engineId) { + refuse(); + } + const result: LegacyComposeRetainedBuildImage[] = []; + for (const container of originals) { + if (!ID.test(container.id) || !NAME.test(container.service)) { + refuse(); + } + const imageOutput = await probe([ + "compose", + "--project-name", + composeProject, + "--project-directory", + `${projectRoot}/.hack`, + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + composeFile, + "config", + "--no-env-resolution", + "--images", + container.service, + ]); + const reference = imageOutput.trim(); + if (!REFERENCE.test(reference)) { + refuse(); + } + const original = record( + await probe([ + "container", + "inspect", + "--format", + CONTAINER, + container.id, + ]), + "createdAt,id,image,reference" + ); + if ( + original.id !== container.id || + original.reference !== reference || + typeof original.image !== "string" || + !IMAGE.test(original.image) || + !time(original.createdAt) + ) { + refuse(); + } + const tagged = record( + await probe(["image", "inspect", "--format", BUILT_IMAGE, reference]), + "createdAt,id" + ); + const exact = record( + await probe([ + "image", + "inspect", + "--format", + BUILT_IMAGE, + original.image, + ]), + "createdAt,id" + ); + if ( + tagged.id !== original.image || + JSON.stringify(tagged) !== JSON.stringify(exact) || + !time(exact.createdAt) + ) { + refuse(); + } + result.push({ + service: container.service, + container: container.id, + containerCreatedAt: original.createdAt, + reference, + image: original.image, + imageCreatedAt: exact.createdAt, + }); + } + if ((await engine()) !== baselineEngine) { + refuse(); + } + freezeImportValue(result); + return result; + } catch { + refuse(); + } +} diff --git a/src/lib/native-compose-adoption-build.ts b/src/lib/native-compose-adoption-build.ts new file mode 100644 index 000000000..afc31d18b --- /dev/null +++ b/src/lib/native-compose-adoption-build.ts @@ -0,0 +1,518 @@ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import { lstat, opendir, realpath } from "node:fs/promises"; +import { join, posix, resolve } from "node:path"; +import { isRecord } from "./guards.ts"; +import { legacyComposeBuildIgnore } from "./native-compose-adoption-build-ignore.ts"; +import { + type NativeConfigImportInputs, + privateNativeConfigImportSourceProof, + readNativeConfigImportSourceFile, +} from "./native-config-import-inputs.ts"; +import { + freezeImportValue, + mapLegacyNativeRetainedBasicBuild, +} from "./native-config-import-plan.ts"; + +const MAX_ENTRIES = 256; +const MAX_BYTES = 16 * 1024 * 1024; +const MAX_NAMES = 4096; +const MAX_DEPTH = 32; +const MAX_PROOF_BYTES = 48 * 1024; +const PRIVATE_PATHS = [ + { path: ".git", tree: true }, + { path: ".hack/.internal", tree: true }, + { path: ".hack/.branch", tree: true }, + { path: ".hack/hack.config.json", tree: false }, + { path: ".hack/docker-compose.yml", tree: false }, + { path: ".hack/hack.project.json", tree: false }, +] as const; +type Source = Extract; +type Identity = { + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly uid: number; +}; +type File = Identity & { + readonly path: string; + readonly hash: string; + readonly size: number; +}; +type Node = + | (Identity & { readonly path: string; readonly kind: "directory" }) + | (File & { readonly kind: "file" }); +type Build = { + readonly service: string; + readonly context: string; + readonly dockerfile: string; + readonly target: string | null; +}; +type Context = Build & { + readonly ancestors: readonly (Identity & { readonly path: string })[]; + readonly definition: File; + readonly ignores: readonly { + readonly path: string; + readonly file: File | null; + }[]; + readonly effectiveIgnore: string | null; + readonly nodes: readonly Node[]; +}; +type Budget = { entries: number; bytes: number; names: number }; + +/** Private current raw-byte provenance, never a report, label, log or builder input. */ +export type LegacyComposeBuildSourceProof = { + readonly build_source_version: 1; + readonly contexts: readonly Context[]; +}; +function refuse(): never { + throw new Error( + "Legacy retained build source is unsupported, unsafe or changed; values omitted." + ); +} +function check(signal?: AbortSignal) { + if (signal?.aborted) { + refuse(); + } +} +function identity(info: Stats): Identity { + if ( + info.uid !== process.getuid?.() || + (info.mode & 0o022) !== 0 || + !(info.mode & 0o444) + ) { + refuse(); + } + return { dev: info.dev, ino: info.ino, mode: info.mode, uid: info.uid }; +} +function pathSupported(path: string) { + return ( + path.length > 0 && + path.length <= 1024 && + path.split("/").length <= MAX_DEPTH && + posix.normalize(path) === path && + !path.startsWith("/") && + path !== ".." && + !path.startsWith("../") && + !path.includes("$") && + !/[\\\0\r\n]/.test(path) + ); +} +function selectedBuilds(candidate: unknown): readonly Build[] { + if (!(isRecord(candidate) && isRecord(candidate.services))) { + refuse(); + } + const result: Build[] = []; + for (const [service, value] of Object.entries(candidate.services)) { + if (!isRecord(value)) { + refuse(); + } + // These other version owners cannot be implicitly combined with a new proof. + if ( + Object.hasOwn(value, "profiles") || + Object.hasOwn(value, "depends_on") || + Object.hasOwn(value, "readiness") + ) { + refuse(); + } + if (!Object.hasOwn(value, "build")) { + continue; + } + // The retained start owner never invokes a builder. Explicit build policy + // requires a new image even when one exists, so it cannot be honored here. + if (Object.hasOwn(value, "pull_policy")) { + refuse(); + } + const build = value.build; + if ( + !( + isRecord(build) && + typeof build.context === "string" && + pathSupported(build.context) + ) + ) { + refuse(); + } + if ( + PRIVATE_PATHS.some( + (owned) => + build.context === owned.path || + build.context.startsWith(`${owned.path}/`) + ) + ) { + refuse(); + } + const dockerfile = build.dockerfile ?? "Dockerfile"; + const target = build.target ?? null; + if ( + typeof dockerfile !== "string" || + !pathSupported(dockerfile) || + dockerfile === "." || + (target !== null && typeof target !== "string") + ) { + refuse(); + } + const definitionPath = posix.join(build.context, dockerfile); + if ( + PRIVATE_PATHS.some( + (owned) => + definitionPath === owned.path || + (owned.tree && definitionPath.startsWith(`${owned.path}/`)) + ) + ) { + refuse(); + } + result.push({ service, context: build.context, dockerfile, target }); + } + if (!result.length || result.length > 16) { + refuse(); + } + return result.sort((left, right) => + left.service.localeCompare(right.service) + ); +} +async function directory(path: string, signal?: AbortSignal) { + check(signal); + const info = await lstat(path); + if (!info.isDirectory() || (await realpath(path)) !== path) { + refuse(); + } + return identity(info); +} +async function names(path: string, budget: Budget, signal?: AbortSignal) { + const selected: string[] = []; + const reader = await opendir(path); + for await (const entry of reader) { + check(signal); + if ( + ++budget.names > MAX_NAMES || + entry.name.includes("\n") || + entry.name.includes("\r") + ) { + refuse(); + } + selected.push(entry.name); + } + return selected.sort(); +} +async function optionalFile(path: string, signal?: AbortSignal) { + try { + await lstat(path); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readNativeConfigImportSourceFile({ path, signal }); +} +function requireExcluded( + context: string, + ignore: ReturnType +) { + for (const owned of PRIVATE_PATHS) { + if (context === owned.path || context.startsWith(`${owned.path}/`)) { + refuse(); + } + const relative = posix.relative(context, owned.path); + if (relative === ".." || relative.startsWith("../")) { + continue; + } + if ( + !(owned.tree + ? ignore.subtreeExcluded(relative) + : ignore.excluded(relative)) + ) { + refuse(); + } + } +} +async function captureContext(opts: { + readonly root: string; + readonly build: Build; + readonly signal?: AbortSignal; + readonly budget: Budget; +}): Promise { + const { root, build, signal, budget } = opts; + const base = resolve(root, build.context); + const ancestors = new Map(); + const checks: (() => Promise)[] = []; + async function pinDirectory(path: string) { + const before = await directory(path, signal); + const relative = posix.relative(root, path) || "."; + const held = ancestors.get(relative); + if (held && JSON.stringify(held) !== JSON.stringify(before)) { + refuse(); + } + ancestors.set(relative, before); + checks.push(async () => { + if ( + JSON.stringify(await directory(path, signal)) !== JSON.stringify(before) + ) { + refuse(); + } + }); + return before; + } + let ancestor = root; + await pinDirectory(ancestor); + for (const part of build.context === "." ? [] : build.context.split("/")) { + ancestor = join(ancestor, part); + await pinDirectory(ancestor); + } + async function pinFile(relative: string, optional = false) { + let parent = base; + for (const part of relative.split("/").slice(0, -1)) { + parent = join(parent, part); + await pinDirectory(parent); + } + const path = join(base, relative); + const read = optional + ? await optionalFile(path, signal) + : await readNativeConfigImportSourceFile({ path, signal }); + checks.push(async () => { + if (!read) { + if (await optionalFile(path, signal)) { + refuse(); + } + return; + } + const info = await lstat(path); + if ( + !info.isFile() || + JSON.stringify(identity(info)) !== + JSON.stringify(identity(read.info)) || + info.nlink !== 1 || + info.size !== read.info.size || + info.mtimeMs !== read.info.mtimeMs || + info.ctimeMs !== read.info.ctimeMs + ) { + refuse(); + } + }); + if (!read) { + return null; + } + budget.bytes += read.bytes.byteLength; + budget.entries += 1; + if (budget.bytes > MAX_BYTES || budget.entries > MAX_ENTRIES) { + refuse(); + } + return { + file: { + path: relative, + ...identity(read.info), + hash: createHash("sha256").update(read.bytes).digest("hex"), + size: read.bytes.byteLength, + }, + bytes: read.bytes, + }; + } + const definition = await pinFile(build.dockerfile); + if (!definition) { + refuse(); + } + const rootIgnore = await pinFile(".dockerignore", true); + const specificPath = `${build.dockerfile}.dockerignore`; + const specificIgnore = await pinFile(specificPath, true); + const effective = specificIgnore ?? rootIgnore; + // Shadowing does not hide an unqualified ignore grammar from this first slice. + for (const selected of [rootIgnore, specificIgnore]) { + if (selected) { + legacyComposeBuildIgnore( + new TextDecoder("utf-8", { fatal: true }).decode(selected.bytes) + ); + } + } + const ignore = legacyComposeBuildIgnore( + effective + ? new TextDecoder("utf-8", { fatal: true }).decode(effective.bytes) + : "" + ); + requireExcluded(build.context, ignore); + const nodes: Node[] = []; + async function walk(path: string, relative: string, depth: number) { + check(signal); + if (depth > MAX_DEPTH || ++budget.entries > MAX_ENTRIES) { + refuse(); + } + const before = await pinDirectory(path); + nodes.push({ path: relative, kind: "directory", ...before }); + const selectedNames = async () => + (await names(path, budget, signal)).filter( + (name) => + !ignore.subtreeExcluded(relative ? `${relative}/${name}` : name) + ); + const entries = await selectedNames(); + for (const name of entries) { + const selected = join(path, name); + const nodePath = relative ? `${relative}/${name}` : name; + const info = await lstat(selected); + if (info.isDirectory()) { + await walk(selected, nodePath, depth + 1); + } else if (info.isFile() && !ignore.excluded(nodePath)) { + const read = await pinFile(nodePath); + if (!read) { + refuse(); + } + nodes.push({ ...read.file, kind: "file" }); + } else if (!info.isFile()) { + refuse(); + } + } + // Excluded output creation does not change the projected names. Included + // additions/removals and ancestor replacement still refuse this acquisition. + if ( + JSON.stringify(before) !== + JSON.stringify(await directory(path, signal)) || + JSON.stringify(entries) !== JSON.stringify(await selectedNames()) + ) { + refuse(); + } + } + await walk(base, "", 0); + for (const recheck of checks) { + check(signal); + await recheck(); + } + return { + ...build, + ancestors: [...ancestors.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, held]) => ({ path, ...held })), + definition: definition.file, + ignores: [ + { path: ".dockerignore", file: rootIgnore?.file ?? null }, + { path: specificPath, file: specificIgnore?.file ?? null }, + ], + effectiveIgnore: specificIgnore + ? specificPath + : rootIgnore + ? ".dockerignore" + : null, + nodes, + }; +} +async function capture(opts: { + readonly root: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}): Promise { + const contexts: Context[] = []; + const budget: Budget = { entries: 0, bytes: 0, names: 0 }; + for (const build of selectedBuilds(opts.candidate)) { + contexts.push( + await captureContext({ + root: opts.root, + build, + signal: opts.signal, + budget, + }) + ); + } + const proof: LegacyComposeBuildSourceProof = { + build_source_version: 1, + contexts, + }; + if (Buffer.byteLength(JSON.stringify(proof)) > MAX_PROOF_BYTES) { + refuse(); + } + freezeImportValue(proof); + return proof; +} + +/** + * Same-issued authored acquisition plus bounded included context provenance. + * Neither this proof nor config-hash attests the image's historical build input. + * Hidden proof/candidate/callbacks authorize no builder or engine effect. + */ +export async function acquireLegacyComposeBuildSource(opts: { + readonly source: Source; + readonly signal?: AbortSignal; +}) { + const { source, signal } = opts; + try { + privateNativeConfigImportSourceProof(source); + const candidate = mapLegacyNativeRetainedBasicBuild(source).candidate; + if (!candidate) { + refuse(); + } + selectedBuilds(candidate); + await source.assertFresh({ signal }); + const proof = await capture({ + root: source.projectRoot, + candidate, + signal, + }); + const assertFresh = async (current?: { readonly signal?: AbortSignal }) => { + try { + const selected = + signal && current?.signal + ? AbortSignal.any([signal, current.signal]) + : (current?.signal ?? signal); + await source.assertFresh({ signal: selected }); + if ( + JSON.stringify( + await capture({ + root: source.projectRoot, + candidate, + signal: selected, + }) + ) !== JSON.stringify(proof) + ) { + refuse(); + } + await source.assertFresh({ signal: selected }); + } catch { + refuse(); + } + }; + await assertFresh(); + const result = { candidate, proof, assertFresh }; + for (const key of Object.keys(result)) { + Object.defineProperty(result, key, { enumerable: false }); + } + return Object.freeze(result); + } catch { + refuse(); + } +} + +/** Key-free saved read pins included bytes/identity and optional ignore presence. */ +export async function assertSavedLegacyComposeBuildSource(opts: { + readonly projectRoot: string; + readonly configText: string; + readonly composeText: string; + readonly proof: unknown; + readonly signal?: AbortSignal; + readonly checkOwner: () => Promise; +}) { + try { + const { projectRoot, configText, composeText, signal, checkOwner } = opts; + const saved = JSON.stringify(opts.proof); + if ( + typeof saved !== "string" || + Buffer.byteLength(saved) > MAX_PROOF_BYTES + ) { + refuse(); + } + const candidate = mapLegacyNativeRetainedBasicBuild({ + configText, + composeText, + }).candidate; + if (!candidate) { + refuse(); + } + await checkOwner(); + if ( + JSON.stringify( + await capture({ root: projectRoot, candidate, signal }) + ) !== saved + ) { + refuse(); + } + await checkOwner(); + } catch { + refuse(); + } +} diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 2c75a669d..e75de48c9 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -291,7 +291,10 @@ export async function tryLegacyComposeAdoptedCommand( deadline, run: async (privateInput) => { cancelled(signal); - if (privateInput.retainedPlan.requiresV5) { + if ( + privateInput.retainedPlan.requiresV5 || + privateInput.retainedBuild + ) { return await runLegacyComposeRetainedOperation({ input: privateInput, operation, diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index 13833494c..cc8fa1d18 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -22,6 +22,7 @@ function refuse(): never { */ export async function executeLegacyComposeRetainedPlan(opts: { readonly plan: LegacyComposeRetainedPlan; + readonly retainedBuild?: true; readonly binding: LegacyComposeVerifiedBinding; readonly operation: AdoptionOperation; readonly deadline: number; @@ -45,12 +46,13 @@ export async function executeLegacyComposeRetainedPlan(opts: { assertFresh, observe, effect, + retainedBuild, } = opts; const ids = new Map( binding.containers.map((container) => [container.service, container.id]) ); if ( - !plan.requiresV5 || + (!plan.requiresV5 && !retainedBuild) || ids.size !== binding.containers.length || plan.ordered.length !== ids.size || plan.ordered.some((service) => !ids.has(service.service)) || @@ -129,6 +131,7 @@ export async function runLegacyComposeRetainedOperation(opts: { readonly input: { readonly binding: LegacyComposeVerifiedBinding; readonly retainedPlan: LegacyComposeRetainedPlan; + readonly retainedBuild?: true; readonly assertFresh: () => Promise; }; readonly operation: AdoptionOperation; @@ -138,6 +141,7 @@ export async function runLegacyComposeRetainedOperation(opts: { const { input, operation, deadline, signal } = opts; return await executeLegacyComposeRetainedPlan({ plan: input.retainedPlan, + retainedBuild: input.retainedBuild, binding: input.binding, operation, deadline, diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 7066e9f90..bd42ee73b 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -4,17 +4,22 @@ import { link, lstat, mkdir, rename, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; import { isRecord } from "./guards.ts"; import { - acquireLegacyComposeAdoptionBinding, + acquireLegacyComposeAdoptionPreparationBinding, inspectLegacyComposeAdoptionResources, type LegacyComposeVerifiedBinding, } from "./native-compose-adoption-binding.ts"; +import { assertSavedLegacyComposeBuildSource } from "./native-compose-adoption-build.ts"; +import { inspectLegacyComposeRetainedBuildImages } from "./native-compose-adoption-build-images.ts"; import { acquireLegacyComposeAdoptionCheckout } from "./native-compose-adoption-checkout.ts"; import { admitLegacyComposeCandidate } from "./native-compose-adoption-compiler.ts"; import { legacyComposeAdoptionCandidateSupported, legacyComposeAdoptionLayoutSupported, } from "./native-compose-adoption-contract.ts"; -import { planLegacyComposeAdoption } from "./native-compose-adoption-plan.ts"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedBasicBuildAdoption, +} from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; import { type LegacyComposeRetainedPlan, @@ -62,6 +67,7 @@ import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, mapLegacyNativeStorageAdoption, + mapLegacyNativeRetainedBasicBuild, } from "./native-config-import-plan.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; @@ -80,13 +86,14 @@ const ROUTING = [ "HACK_EXECUTION_MODE", ] as const; type SavedManifest = { - readonly adoption_generation_version: 1 | 3 | 4 | 5; + readonly adoption_generation_version: 1 | 3 | 4 | 5 | 9; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly buildProof?: { readonly source: unknown; readonly images: unknown }; readonly sourceFiles: { readonly config: NativeConfigImportSourceIdentity; readonly compose: NativeConfigImportSourceIdentity; @@ -106,6 +113,8 @@ type PrivateInputs = { readonly candidateText: string; readonly binding: LegacyComposeVerifiedBinding; readonly projectionMetadata?: NativeProjectEnvMetadata; + /** Private version9 policy; never serialized into a compiler report or receipt label. */ + readonly retainedBuild?: true; }; type MutationInputs = PrivateInputs & { /** Issued while the journal and mutation lock are held; valid only during this callback. */ @@ -216,14 +225,21 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { isRecord(value) && keys( value, - (value.adoption_generation_version === 5 && - Object.hasOwn(value, "projectionProof")) || - value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 - ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" - : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles" + value.adoption_generation_version === 9 + ? "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles" + : (value.adoption_generation_version === 5 && + Object.hasOwn(value, "projectionProof")) || + value.adoption_generation_version === 3 || + value.adoption_generation_version === 4 + ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" + : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles" ) && (value.adoption_generation_version === 1 || + (value.adoption_generation_version === 9 && + isRecord(value.buildProof) && + keys(value.buildProof, "images,source") && + isRecord(value.buildProof.source) && + Array.isArray(value.buildProof.images)) || (value.adoption_generation_version === 5 && (!Object.hasOwn(value, "projectionProof") || (isRecord(value.projectionProof) && @@ -257,6 +273,14 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { id, binding: value.binding, runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 9 && isRecord(value.buildProof) + ? { + buildProof: { + source: value.buildProof.source, + images: value.buildProof.images, + }, + } + : {}), ...((value.adoption_generation_version === 5 && Object.hasOwn(value, "projectionProof")) || value.adoption_generation_version === 3 || @@ -315,7 +339,7 @@ async function writeArtifact(path: string, text: string): Promise { /** A distinct private generation claim; it never makes original legacy resources native nonce-owned. */ export type LegacyComposeAdoptedGeneration = { readonly report: { - readonly adoption_generation_version: 1 | 3 | 4 | 5; + readonly adoption_generation_version: 1 | 3 | 4 | 5 | 9; readonly owner: "legacy-compose"; readonly status: "prepared" | "active"; readonly containers: number; @@ -418,8 +442,32 @@ async function readInputs( join(generationRoot, "candidate.json"), meta.files.candidate ); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); - const planned = planLegacyComposeAdoption({ configText, composeText }); + const basic = meta.adoption_generation_version === 9; + const mapped = ( + basic ? mapLegacyNativeRetainedBasicBuild : mapLegacyNativeStorageAdoption + )({ configText, composeText }); + const planned = ( + basic + ? planLegacyComposeRetainedBasicBuildAdoption + : planLegacyComposeAdoption + )({ configText, composeText }); + const assertBuildSource = async () => { + if (!basic) { + return; + } + if (!meta.buildProof) { + refuse(); + } + await assertSavedLegacyComposeBuildSource({ + projectRoot: ctx.root, + configText, + composeText, + proof: meta.buildProof.source, + signal: ctx.signal, + checkOwner: ctx.check, + }); + }; + await assertBuildSource(); const projectionOpts = { projectRoot: ctx.root, configText, @@ -453,7 +501,8 @@ async function readInputs( const currentReceipt = await publicationState(ctx); if ( (meta.adoption_generation_version === 5) !== - (currentReceipt.adoption_receipt_version === 5) + (currentReceipt.adoption_receipt_version === 5) || + basic !== (currentReceipt.adoption_receipt_version === 9) ) { refuse(); } @@ -477,6 +526,19 @@ async function readInputs( if (JSON.stringify(meta.runtimeConfig) !== JSON.stringify(runtimeConfig)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } + if ( + basic && + JSON.stringify( + await inspectLegacyComposeRetainedBuildImages({ + binding: observed, + composeFile: join(generationRoot, "legacy-compose.yml"), + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }) + ) !== JSON.stringify(meta.buildProof?.images) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } await readArtifact( join(generationRoot, "manifest.json"), selected.manifest, @@ -498,6 +560,7 @@ async function readInputs( if (projection) { await readSavedLegacyComposeAdoptionProjection(projectionOpts); } + await assertBuildSource(); await ctx.check(); freezeImportValue(observed); return { @@ -508,6 +571,7 @@ async function readInputs( candidateText, binding: observed, ...(projection ? { projectionMetadata: projection.metadata } : {}), + ...(basic ? { retainedBuild: true as const } : {}), }), }; } finally { @@ -575,7 +639,7 @@ async function prepare( ctx: Context, binary: string | undefined ): Promise { - const binding = await acquireLegacyComposeAdoptionBinding({ + const binding = await acquireLegacyComposeAdoptionPreparationBinding({ projectRoot: ctx.root, signal: ctx.signal, timeoutMs: ctx.timeoutMs, @@ -585,7 +649,11 @@ async function prepare( projectRoot: ctx.root, signal: ctx.signal, }); - const mapped = mapLegacyNativeStorageAdoption(acquired); + const mapped = ( + acquired.build + ? mapLegacyNativeRetainedBasicBuild + : mapLegacyNativeStorageAdoption + )(acquired); if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } @@ -633,12 +701,16 @@ async function prepare( if (legacyComposeRetainedPlan(JSON.parse(candidateText)).requiresV5) { version = 5; } + if (acquired.build) { + version = 9; + } const meta: Manifest = { adoption_generation_version: version, kind: KIND, projectRoot: ctx.root, id, binding: acquired.binding, + ...(acquired.build ? { buildProof: acquired.build } : {}), ...(acquired.projection ? { projectionProof: acquired.projection.projectionProof } : {}), @@ -1248,7 +1320,8 @@ function requireMutationDeadline( plan: LegacyComposeRetainedPlan ) { if ( - plan.requiresV5 && + (plan.requiresV5 || + captured.generation.report.adoption_generation_version === 9) && (captured.deadline === undefined || !Number.isFinite(captured.deadline) || captured.deadline <= Date.now()) @@ -1257,7 +1330,7 @@ function requireMutationDeadline( } } -/** V5 reacquisitions share the same remaining clock; old receipt owners keep their prior budgets. */ +/** New proof owners share one remaining clock; old receipt owners keep their prior budgets. */ function boundedMutationContext(ctx: Context, deadline: number) { function remaining() { cancelled(ctx.signal); @@ -1301,8 +1374,11 @@ function preparedReceiptVersion( if (version !== 1) { return version; } - // A rolled-back v5 contract must not label a later plain generation as v5. - if (prior.adoption_receipt_version === 5) { + // A rolled-back proof owner must not label a later plain generation with its version. + if ( + prior.adoption_receipt_version === 5 || + prior.adoption_receipt_version === 9 + ) { return "kind" in checkout.git ? 2 : 1; } return prior.adoption_receipt_version; @@ -1374,7 +1450,9 @@ async function mutateRetainedContainers( if (!known.has(captured.generation)) { refuse(); } - if (captured.generation.report.adoption_generation_version !== 5) { + if ( + ![5, 9].includes(captured.generation.report.adoption_generation_version) + ) { return await mutateRetainedContainersWithinBudget( original, known, @@ -1436,7 +1514,7 @@ async function mutateRetainedContainersWithinBudget( JSON.parse(loaded.inputs.candidateText) ); if ( - retainedPlan.requiresV5 && + (retainedPlan.requiresV5 || loaded.inputs.retainedBuild) && JSON.stringify([...selectedServices].sort()) !== JSON.stringify([...services].sort()) ) { @@ -1489,6 +1567,7 @@ async function mutateRetainedContainersWithinBudget( ? { projectionMetadata: loaded.inputs.projectionMetadata } : {}), retainedPlan, + ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), assertFresh: async () => { if (!callbackOpen) { refuse(); diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 2f18dfa54..6a44f3f16 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -5,6 +5,7 @@ import { import { freezeImportValue, mapLegacyNativeAdoptionBaseline, + mapLegacyNativeRetainedBasicBuild, } from "./native-config-import-plan.ts"; import { type LegacyComposeStorageIntent, @@ -63,7 +64,21 @@ export function planLegacyComposeAdoption(opts: { readonly configText: string; readonly composeText: string; }): LegacyComposeAdoptionPlan { - const baseline = mapLegacyNativeAdoptionBaseline(opts); + return plan(opts, mapLegacyNativeAdoptionBaseline(opts)); +} + +/** Pure closed build/storage intent only; the distinct source/image owner must still admit it. */ +export function planLegacyComposeRetainedBasicBuildAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedBasicBuild(opts)); +} + +function plan( + opts: { readonly configText: string; readonly composeText: string }, + baseline: ReturnType +): LegacyComposeAdoptionPlan { const config = parseImportDocument({ text: opts.configText, document: "config", diff --git a/src/lib/native-compose-adoption-preview.ts b/src/lib/native-compose-adoption-preview.ts index b9641dd98..883421da7 100644 --- a/src/lib/native-compose-adoption-preview.ts +++ b/src/lib/native-compose-adoption-preview.ts @@ -1,4 +1,4 @@ -import { acquireLegacyComposeAdoptionBinding } from "./native-compose-adoption-binding.ts"; +import { acquireLegacyComposeAdoptionPreparationBinding } from "./native-compose-adoption-binding.ts"; import { admitLegacyComposeCandidate } from "./native-compose-adoption-compiler.ts"; import { legacyComposeAdoptionCandidateSupported, @@ -13,6 +13,7 @@ import type { ImportField } from "./native-config-import-parser.ts"; import { freezeImportValue, mapLegacyNativeStorageAdoption, + mapLegacyNativeRetainedBasicBuild, } from "./native-config-import-plan.ts"; function refused(code: string): ImportField { @@ -39,9 +40,13 @@ export async function previewLegacyComposeAdoption(input: { const opts = { ...input }; let fields: readonly ImportField[] = []; try { - const owner = await acquireLegacyComposeAdoptionBinding(opts), + const owner = await acquireLegacyComposeAdoptionPreparationBinding(opts), acquired = await owner.resolvePreparationInputs(opts); - const mapped = mapLegacyNativeStorageAdoption(acquired); + const mapped = ( + acquired.build + ? mapLegacyNativeRetainedBasicBuild + : mapLegacyNativeStorageAdoption + )(acquired); fields = [ ...mapped.report.fields, ...(acquired.projection?.localFields ?? []), diff --git a/src/lib/native-compose-adoption-receipt.ts b/src/lib/native-compose-adoption-receipt.ts index 6c4e7c477..7230e3a13 100644 --- a/src/lib/native-compose-adoption-receipt.ts +++ b/src/lib/native-compose-adoption-receipt.ts @@ -17,7 +17,7 @@ export type Checkout = { }; export type Anchor = { readonly id: string; readonly manifest: Artifact }; export type Receipt = { - readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5; + readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5 | 9; readonly kind: typeof KIND; readonly checkout: Checkout; readonly prepared: Anchor | null; @@ -77,7 +77,8 @@ export function parseLegacyComposeAdoptionReceipt( value, "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" ) && - (value.adoption_receipt_version === 5 || + (value.adoption_receipt_version === 9 || + value.adoption_receipt_version === 5 || value.adoption_receipt_version === 4 || value.adoption_receipt_version === 3 || value.adoption_receipt_version === ("kind" in checkout.git ? 2 : 1)) && @@ -112,7 +113,9 @@ export function parseLegacyComposeAdoptionReceipt( const version = value.adoption_receipt_version; return { adoption_receipt_version: - version === 5 || version === 4 || version === 3 ? version : legacyVersion, + version === 9 || version === 5 || version === 4 || version === 3 + ? version + : legacyVersion, kind: KIND, checkout, prepared: value.prepared, diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 467b5bac1..840185ae7 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -134,7 +134,11 @@ function mappingFields( function mapLegacyNativeInput(opts: { readonly configText: string; readonly composeText: string; - readonly purpose: "preview" | "adoption-baseline" | "storage-adoption"; + readonly purpose: + | "preview" + | "adoption-baseline" + | "storage-adoption" + | "retained-basic-build"; }): NativeImportPlan { const config = parseImportDocument({ text: opts.configText, @@ -178,9 +182,13 @@ function mapLegacyNativeInput(opts: { candidate, mark, refuse, - buildPreview: opts.purpose === "preview", + buildPreview: + opts.purpose === "preview" || opts.purpose === "retained-basic-build", }); - if (opts.purpose === "storage-adoption") { + if ( + opts.purpose === "storage-adoption" || + opts.purpose === "retained-basic-build" + ) { mapStorageCandidate({ config: config.value, compose: compose.value, @@ -224,6 +232,18 @@ export function mapLegacyNativeStorageAdoption(opts: { }); } +/** + * Private closed build-and-storage intent for the retained original-image owner. + * This does not grant adoption or build authority. The image-only baseline and + * ordinary storage mapper deliberately retain their previous refusals. + */ +export function mapLegacyNativeRetainedBasicBuild(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ ...opts, purpose: "retained-basic-build" }); +} + function mapStorageCandidate( opts: MappingContext & { readonly compose: Record } ) { diff --git a/tests/helpers/retained-build-adoption.ts b/tests/helpers/retained-build-adoption.ts new file mode 100644 index 000000000..3e12a126f --- /dev/null +++ b/tests/helpers/retained-build-adoption.ts @@ -0,0 +1,226 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; +import { runLegacyComposeRetainedOperation } from "../../src/lib/native-compose-adoption-execution.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; +import { managedEnvCompilerFixture } from "./managed-env-compiler.ts"; +import { restoreEnv } from "./env.ts"; + +export const BUILD_CANARY = "synthetic-private-retained-build"; +const CREATED = "2026-01-01T01:02:03Z"; +const CONTAINER = "a".repeat(64), + NETWORK = "b".repeat(64), + IMAGE = `sha256:${"c".repeat(64)}`, + HASH = "d".repeat(64); +function identity(value: unknown) { + if ( + !isRecord(value) || + typeof value.dev !== "number" || + typeof value.ino !== "number" || + !Number.isSafeInteger(value.dev) || + !Number.isSafeInteger(value.ino) || + value.dev < 0 || + value.ino <= 0 + ) { + throw new Error("Synthetic identity is invalid; values omitted."); + } + return { dev: value.dev, ino: value.ino }; +} +/** Source-to-model transport control only. No real daemon, image build or SQL proof. */ +export async function retainedBuildFixture() { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-owner-")) + ); + const root = join(outer, "checkout"); + await mkdir(join(root, ".hack"), { recursive: true }); + await mkdir(join(root, ".git")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), BUILD_CANARY); + await writeFile(join(root, "Dockerfile"), "FROM scratch\nCOPY src /source\n"); + await writeFile(join(root, ".dockerignore"), "**\n!Dockerfile\n!src\n"); + const config = '{"name":"fixture"}'; + const compose = JSON.stringify({ + name: "fixture", + services: { db: { build: "..", volumes: ["data:/data"] } }, + volumes: { data: { name: "fixture_original_data" } }, + }); + await writeFile(join(root, ".hack/hack.config.json"), config); + await writeFile(join(root, ".hack/docker-compose.yml"), compose); + await writeFile(join(outer, "accepted-compose"), compose); + const model = { + running: false, + image: IMAGE, + tag: IMAGE, + imageBirth: CREATED, + containerBirth: CREATED, + volumeBirth: CREATED, + hash: HASH, + reference: "fixture-db", + engine: "synthetic-retained-build-engine", + partial: false, + sourceRace: false, + hold: false, + probeHold: false, + imageExists: true, + row: BUILD_CANARY, + }; + const modelPath = join(outer, "model.json"), + commands = join(outer, "commands"), + binary = join(outer, "docker"); + await writeFile(modelPath, JSON.stringify(model)); + await writeFile( + binary, + `#!${process.execPath} +import {appendFileSync,readFileSync,writeFileSync} from 'node:fs'; +const outer=${JSON.stringify(outer)}, root=${JSON.stringify(root)}, id=${JSON.stringify(CONTAINER)}, network=${JSON.stringify(NETWORK)}; +const args=process.argv.slice(2), [kind,action]=args, m=JSON.parse(readFileSync(outer+'/model.json','utf8')); +appendFileSync(outer+'/commands',JSON.stringify(args)+'\\n'); +function red(){writeFileSync(outer+'/unexpected','unowned action');process.exit(99);} +const volume='fixture_original_data', born=${JSON.stringify(CREATED)}; +if(kind==='info' && args.length===3 && action==='--format') {console.log(args[2]==='{{json .ID}}' ? JSON.stringify(m.engine) : JSON.stringify({id:m.engine,os:'linux'}));process.exit(0);} +if(kind==='compose') { + const path=args[10], service=args.at(-1); + if(args[1]!=='--project-name' || args[2]!=='fixture' || args[3]!=='--project-directory' || args[4]!==root+'/.hack' || args[5]!=='--env-file' || args[6]!=='/dev/null' || args[7]!=='--profile' || args[8]!=='*' || args[9]!=='--file' || args[11]!=='config' || args[12]!=='--no-env-resolution') red(); + const prefix=root+'/.hack/.internal/legacy-compose-adoption-v1/generations/', tail=path.slice(prefix.length).split('/'); + if(path!==root+'/.hack/docker-compose.yml' && !(path.startsWith(prefix) && tail.length===2 && /^[a-f0-9]{32}$/.test(tail[0]) && tail[1]==='legacy-compose.yml')) red(); + if(readFileSync(path,'utf8')!==readFileSync(outer+'/accepted-compose','utf8')) red(); + if(args.length===15 && args[13]==='--images' && service==='db') console.log(m.reference); + else if(args.length===15 && args[13]==='--hash' && service==='*') console.log('db '+m.hash); + else red();process.exit(0); +} +if(kind==='image' && action==='inspect' && args.length===5 && args[2]==='--format') { + if(!m.imageExists) process.exit(1); + if(!args[3].includes('createdAt') || args[3].includes('Env')) red(); + const selected=args[4];if(selected!==m.reference && selected!==m.image) process.exit(1); + console.log(JSON.stringify({id:selected===m.reference ? m.tag : m.image,createdAt:m.imageBirth}));process.exit(0); +} +if(kind==='container' && ['start','stop'].includes(action)) { + if(args.length!==3 || args[2]!==id) red(); + if(m.hold) {writeFileSync(outer+'/effect-started',String(process.pid));await Bun.sleep(60000);} + m.running=action==='start';if(m.sourceRace) {writeFileSync(root+'/src/marker','changed');} + writeFileSync(outer+'/model.json',JSON.stringify(m));process.exit(m.partial ? 7 : 0); +} +if(!['container','volume','network'].includes(kind) || !['ls','inspect'].includes(action) || !args.includes('--format')) red(); +if(m.probeHold) {writeFileSync(outer+'/probe-started',String(process.pid));await Bun.sleep(60000);} +if(action==='ls') { + const row=kind==='container' ? {id,name:'fixture-db-1',project:'fixture'} : kind==='network' ? {id:network,name:'fixture_default',project:'fixture'} : {id:volume,name:volume,project:'fixture'}; + console.log(JSON.stringify(row));process.exit(0); +} +const selected=args.at(-1), format=args[3]; +if(kind==='container') { + if(selected!==id) process.exit(1); + if(format.includes('.Config.Image')) console.log(JSON.stringify({id,image:m.image,reference:m.reference,createdAt:m.containerBirth})); + else if(format.includes('config-hash')) console.log(JSON.stringify({id,hash:m.hash})); + else if(format.includes('.Mounts')) console.log(JSON.stringify({id,name:'/fixture-db-1',project:'fixture',native:'',service:'db',number:'1',oneoff:'False',running:m.running,workingDir:root+'/.hack',configFiles:root+'/.hack/docker-compose.yml',mounts:[{type:'volume',name:volume,source:'/var/lib/docker/volumes/original/_data',target:'/data',rw:true}],networks:[{name:'fixture_default',id:network}]})); + else if(format.includes('.State.Running')) console.log(JSON.stringify({id,running:m.running,paused:false,status:m.running ? 'running' : 'exited',...(format.includes('Health') ? {health:''} : {})})); + else red(); +} else if(kind==='volume' && selected===volume) console.log(JSON.stringify({id:volume,name:volume,project:'fixture',native:'',storage:'data',createdAt:m.volumeBirth,driver:'local',scope:'local',mountpoint:'/var/lib/docker/volumes/original/_data',options:null})); +else if(kind==='network' && selected===network) console.log(JSON.stringify({id:network,name:'fixture_default',project:'fixture',native:'',logical:'default',createdAt:born,driver:'bridge',scope:'local',internal:false,containers:m.running ? [id] : []})); +else red(); +` + ); + await chmod(binary, 0o700); + const previousPath = process.env.PATH; + process.env.PATH = outer; + const compiler = await managedEnvCompilerFixture(join(outer, "compiler")); + function isModel(value: unknown): value is typeof model { + return ( + isRecord(value) && + Object.keys(value).sort().join() === Object.keys(model).sort().join() && + Object.entries(model).every( + ([key, original]) => typeof value[key] === typeof original + ) + ); + } + return { + root, + outer, + config, + compose, + model, + compiler, + acceptCompose: (text: string) => + writeFile(join(outer, "accepted-compose"), text), + persist: () => writeFile(modelPath, JSON.stringify(model)), + readModel: async () => { + const value: unknown = JSON.parse(await readFile(modelPath, "utf8")); + if (!isModel(value)) { + throw new Error("Synthetic model is invalid; values omitted."); + } + return value; + }, + commands: async (): Promise => + (await readFile(commands, "utf8")) + .trim() + .split("\n") + .map((line) => { + const value: unknown = JSON.parse(line); + if ( + !Array.isArray(value) || + !value.every((part): part is string => typeof part === "string") + ) { + throw new Error( + "Synthetic command ledger is invalid; values omitted." + ); + } + return value; + }), + receipt: async () => { + const value: unknown = JSON.parse( + await readFile( + join(root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"), + "utf8" + ) + ); + if (!isRecord(value) || !isRecord(value.checkout)) { + throw new Error("Synthetic receipt is invalid; values omitted."); + } + return parseLegacyComposeAdoptionReceipt(value, { + root: identity(value.checkout.root), + project: identity(value.checkout.project), + git: identity(value.checkout.git), + }); + }, + store: (signal?: AbortSignal) => + openLegacyComposeAdoptedGenerationStore({ projectRoot: root, signal }), + operation: async ( + store: Awaited< + ReturnType + >, + generation: Parameters[0]["generation"], + operation: "start" | "stop", + recover = false + ) => { + const deadline = Date.now() + 15_000; + return await store.withMutation({ + generation, + operation, + recover, + services: [], + binary: compiler, + deadline, + run: (input) => + runLegacyComposeRetainedOperation({ + input, + operation, + deadline, + signal: new AbortController().signal, + }), + }); + }, + cleanup: async () => { + restoreEnv("PATH", previousPath); + await rm(outer, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/native-compose-adoption-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts new file mode 100644 index 000000000..f3e09d8ac --- /dev/null +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, expect, test as boundedTest } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; +import { + BUILD_CANARY, + retainedBuildFixture, +} from "./helpers/retained-build-adoption.ts"; + +const test = (name: string, run: () => Promise) => + boundedTest(name, run, 30_000); +let h: Awaited>; +beforeEach(async () => { + h = await retainedBuildFixture(); +}); +afterEach(async () => { + await h.cleanup(); +}); +async function red(pending: Promise) { + try { + await pending; + throw new Error("unexpected retained build success"); + } catch (error) { + expect(String(error)).toMatch(/values omitted/i); + expect(String(error)).not.toContain(BUILD_CANARY); + expect(String(error)).not.toContain(h.root); + } +} +async function prepared() { + const store = await h.store(); + const generation = await store.prepare({ binary: h.compiler }); + return { store, generation }; +} +async function assertNoAllocation() { + const commands = await h.commands(); + expect( + commands.some( + (args) => + args.includes("build") || + args.includes("pull") || + args.includes("create") || + args.includes("up") || + args.includes("rm") + ) + ).toBe(false); + expect( + commands + .filter( + (args) => + args[0] === "container" && ["start", "stop"].includes(args[1] ?? "") + ) + .every((args) => args.length === 3 && args[2] === "a".repeat(64)) + ).toBe(true); +} +async function writeReceipt(value: unknown) { + await writeFile( + join(h.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"), + JSON.stringify(value) + ); +} + +test("version9 prepares/switches/starts/stops/rolls back the original model identities and data", async () => { + const { store, generation } = await prepared(); + try { + expect(generation.report.adoption_generation_version).toBe(9); + expect((await h.receipt()).adoption_receipt_version).toBe(9); + expect(JSON.stringify(generation)).not.toContain(BUILD_CANARY); + expect(JSON.stringify(generation)).not.toContain("sha256:"); + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("missing synthetic active generation"); + } + expect(await h.operation(store, active, "start")).toBe(0); + expect(await h.operation(store, active, "stop")).toBe(0); + await store.rollback(); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect(await h.readModel()).toMatchObject({ + row: BUILD_CANARY, + image: h.model.image, + imageBirth: h.model.imageBirth, + volumeBirth: h.model.volumeBirth, + containerBirth: h.model.containerBirth, + running: false, + }); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("dry-run reports only field provenance/counts and does not create a generation", async () => { + const report = await previewLegacyComposeAdoption({ + projectRoot: h.root, + binary: h.compiler, + }); + expect(report.complete).toBe(true); + expect(JSON.stringify(report)).not.toContain(BUILD_CANARY); + expect(JSON.stringify(report)).not.toContain("sha256:"); + expect( + await Bun.file( + join(h.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json") + ).exists() + ).toBe(false); + await assertNoAllocation(); +}); +boundedTest.each([ + "context", + "tag", + "missing image", + "image birth", + "container birth", + "volume birth", + "config hash", +])( + "prepared %s drift cannot switch the authored format", + async (kind) => { + const { store, generation } = await prepared(); + try { + if (kind === "context") { + await writeFile(join(h.root, "src/marker"), "changed source"); + } + if (kind === "tag") { + h.model.tag = `sha256:${"e".repeat(64)}`; + } + if (kind === "missing image") { + h.model.imageExists = false; + } + if (kind === "image birth") { + h.model.imageBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "container birth") { + h.model.containerBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "volume birth") { + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "config hash") { + h.model.hash = "e".repeat(64); + } + await h.persist(); + await red(store.publish({ generation, binary: h.compiler })); + expect((await h.receipt()).publication).toBeNull(); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + await assertNoAllocation(); + } finally { + await store.close(); + } + }, + 30_000 +); +test("post-effect context drift retains pending ownership; exact same-inode byte repair permits explicit stop recovery", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("missing synthetic active generation"); + } + h.model.sourceRace = true; + await h.persist(); + await red(h.operation(store, active, "start")); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + await red(store.loadActive()); + h.model.sourceRace = false; + await h.persist(); + await writeFile(join(h.root, "src/marker"), BUILD_CANARY); + const repair = await store.loadActive({ recoverOperation: true }); + if (!repair) { + throw new Error("missing synthetic repair generation"); + } + expect(await h.operation(store, repair, "stop", true)).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + await store.rollback(); + expect((await h.readModel()).row).toBe(BUILD_CANARY); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("candidate build edits refuse before any retained effect or rollback overwrite", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + const path = join(h.root, ".hack/hack.project.json"); + const original = await readFile(path, "utf8"); + await writeFile( + path, + original.replace('"context":"."', '"context":"changed"') + ); + await red(store.loadActive()); + await red(store.rollback()); + expect((await h.receipt()).publication?.phase).toBe("active"); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("older receipt versions cannot label a build manifest", async () => { + const { store } = await prepared(); + try { + const receipt = await h.receipt(); + expect( + parseLegacyComposeAdoptionReceipt(receipt, receipt.checkout) + .adoption_receipt_version + ).toBe(9); + for (const version of [1, 2, 3, 4, 5]) { + await writeReceipt({ ...receipt, adoption_receipt_version: version }); + await red(store.loadPrepared()); + } + await writeReceipt(receipt); + expect( + (await store.loadPrepared())?.report.adoption_generation_version + ).toBe(9); + } finally { + await store.close(); + } +}); +test("rolled-back version9 does not relabel a later image-only generation", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + await store.rollback(); + const plainCompose = JSON.stringify({ + name: "fixture", + services: { + db: { image: "postgres:16-alpine", volumes: ["data:/data"] }, + }, + volumes: { data: { name: "fixture_original_data" } }, + }); + await writeFile(join(h.root, ".hack/docker-compose.yml"), plainCompose); + await h.acceptCompose(plainCompose); + const plain = await store.prepare({ binary: h.compiler }); + expect(plain.report.adoption_generation_version).toBe(1); + expect((await h.receipt()).adoption_receipt_version).toBe(1); + expect( + (await store.loadPrepared())?.report.adoption_generation_version + ).toBe(1); + const receipt = await h.receipt(); + await writeReceipt({ ...receipt, adoption_receipt_version: 9 }); + await red(store.loadPrepared()); + await writeReceipt(receipt); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("remaining operation budget settles a held pre-effect ownership query without calling the mutation", async () => { + const { store, generation } = await prepared(); + try { + h.model.probeHold = true; + await h.persist(); + const start = performance.now(); + let entered = false; + await red( + store.withPreparationStop({ + generation, + binary: h.compiler, + deadline: Date.now() + 1_000, + run: async () => { + entered = true; + return 0; + }, + }) + ); + expect(await Bun.file(join(h.outer, "probe-started")).exists()).toBe(true); + expect(performance.now() - start).toBeLessThan(5_000); + expect(entered).toBe(false); + expect((await h.receipt()).pendingOperation).toBeNull(); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("version9 requires one deadline before any mutation callback and preserves pending evidence on failed stop", async () => { + const { store, generation } = await prepared(); + try { + let entered = false; + await red( + store.withPreparationStop({ + generation, + run: async () => { + entered = true; + return 0; + }, + }) + ); + expect(entered).toBe(false); + expect((await h.receipt()).pendingOperation).toBeNull(); + h.model.running = true; + h.model.partial = true; + await h.persist(); + const deadline = Date.now() + 15_000; + expect( + await store.withPreparationStop({ + generation, + deadline, + binary: h.compiler, + run: (input) => + import("../src/lib/native-compose-adoption-execution.ts").then( + ({ runLegacyComposeRetainedOperation }) => + runLegacyComposeRetainedOperation({ + input, + operation: "stop", + deadline, + signal: new AbortController().signal, + }) + ), + }) + ).toBe(7); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + h.model.partial = false; + await h.persist(); + const repair = await store.loadPrepared({ recoverOperation: true }); + if (!repair) { + throw new Error("missing synthetic prepared repair"); + } + const nextDeadline = Date.now() + 15_000; + expect( + await store.withPreparationStop({ + generation: repair, + recover: true, + deadline: nextDeadline, + binary: h.compiler, + run: (input) => + import("../src/lib/native-compose-adoption-execution.ts").then( + ({ runLegacyComposeRetainedOperation }) => + runLegacyComposeRetainedOperation({ + input, + operation: "stop", + deadline: nextDeadline, + signal: new AbortController().signal, + }) + ), + }) + ).toBe(0); + await store.publish({ generation: repair, binary: h.compiler }); + await store.rollback(); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts new file mode 100644 index 000000000..5420bd0fc --- /dev/null +++ b/tests/native-compose-adoption-build-source.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + chmod, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + acquireLegacyComposeBuildSource, + assertSavedLegacyComposeBuildSource, +} from "../src/lib/native-compose-adoption-build.ts"; +import { legacyComposeBuildIgnore } from "../src/lib/native-compose-adoption-build-ignore.ts"; +import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; +import * as importInputs from "../src/lib/native-config-import-inputs.ts"; +import { + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; + +const CANARY = "synthetic-private-build-source"; +let root: string; +let composeText: string; +beforeEach(async () => { + root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-source-")) + ); + await mkdir(join(root, ".hack")); + await mkdir(join(root, ".git")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), CANARY); + await writeFile(join(root, "Dockerfile"), "FROM scratch\nCOPY src /source\n"); + await writeFile(join(root, ".dockerignore"), "**\n!Dockerfile\n!src\n"); + await writeFile(join(root, ".hack/hack.config.json"), '{"name":"fixture"}'); + composeText = JSON.stringify({ + name: "fixture", + services: { db: { build: "..", volumes: ["data:/data"] } }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +async function acquire(signal?: AbortSignal) { + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + signal, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + return await acquireLegacyComposeBuildSource({ source, signal }); +} +async function red(pending: Promise) { + try { + await pending; + throw new Error("unexpected build source admission"); + } catch (error) { + expect(String(error)).toContain("values omitted"); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain(CANARY); + expect(JSON.stringify(error)).not.toContain(CANARY); + } +} + +test("retained pure intent does not broaden either image-only baseline API", () => { + const inputs = { configText: '{"name":"fixture"}', composeText }; + expect(mapLegacyNativeAdoptionBaseline(inputs).candidate).toBeUndefined(); + expect(mapLegacyNativeStorageAdoption(inputs).candidate).toBeUndefined(); + expect(mapLegacyNativeRetainedBasicBuild(inputs).candidate).toMatchObject({ + services: { db: { build: { context: "." } } }, + }); +}); +test("root context pins included source and keeps private proof/candidate/callbacks out of reports", async () => { + const captured = await acquire(); + expect(Object.keys(captured)).toEqual([]); + expect(JSON.stringify(captured)).toBe("{}"); + expect(Object.isFrozen(captured.proof)).toBe(true); + expect(JSON.stringify(captured.proof)).not.toContain(CANARY); + await captured.assertFresh(); + await assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }); +}); +test("creation and edits inside actually ignored owned outputs do not invalidate included source", async () => { + const captured = await acquire(); + await mkdir(join(root, ".hack/.internal")); + await writeFile(join(root, ".hack/.internal/private-output"), CANARY); + await captured.assertFresh(); + await writeFile( + join(root, ".hack/.internal/private-output"), + `${CANARY}-changed` + ); + await captured.assertFresh(); + // A native selector changes the issued authored-source family. Only the saved + // owner (which separately verifies its published candidate) may read it. + await writeFile(join(root, ".hack/hack.project.json"), CANARY); + await red(captured.assertFresh()); + await assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }); +}); +test("Dockerfile-specific ignore wins, while both optional ignore identities remain pinned", async () => { + await writeFile(join(root, ".dockerignore"), "src\n.git\n.hack\n"); + await writeFile( + join(root, "Dockerfile.dockerignore"), + "**\n!Dockerfile\n!src\n" + ); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.effectiveIgnore).toBe( + "Dockerfile.dockerignore" + ); + await writeFile(join(root, "src/marker"), "changed included source"); + await red(captured.assertFresh()); + await writeFile(join(root, "src/marker"), CANARY); + await captured.assertFresh(); + await rm(join(root, "Dockerfile.dockerignore")); + await red(captured.assertFresh()); +}); +test("a newly added specific ignore refuses even when its effective rules are byte-equal", async () => { + const captured = await acquire(); + await writeFile( + join(root, "Dockerfile.dockerignore"), + await readFile(join(root, ".dockerignore")) + ); + await red(captured.assertFresh()); +}); +test.each([ + "bytes", + "added", + "removed", + "replaced", + "symlink", + "hardlink", + "unsafe mode", +])("included %s drift refuses with fixed redacted errors", async (kind) => { + const captured = await acquire(), + path = join(root, "src/marker"); + if (kind === "bytes") { + await writeFile(path, "changed"); + } + if (kind === "added") { + await writeFile(join(root, "src/new"), "new"); + } + if (kind === "removed") { + await rm(path); + } + if (kind === "replaced") { + await rename(path, join(root, "outside")); + await writeFile(path, CANARY); + } + if (kind === "symlink") { + await rename(path, join(root, "outside")); + await symlink(join(root, "outside"), path); + } + if (kind === "hardlink") { + await link(path, join(root, "outside")); + } + if (kind === "unsafe mode") { + await chmod(path, 0o666); + } + await red(captured.assertFresh()); +}); +test("a held root or included directory replacement cannot match same bytes", async () => { + const captured = await acquire(); + await rename(join(root, "src"), join(root, "old-src")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), CANARY); + await red(captured.assertFresh()); +}); +test("parents of an excluded Dockerfile remain identity-bound special builder inputs", async () => { + await mkdir(join(root, "definitions")); + await rename(join(root, "Dockerfile"), join(root, "definitions/Dockerfile")); + await writeFile(join(root, ".dockerignore"), "**\n!src\n"); + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: { context: "..", dockerfile: "definitions/Dockerfile" }, + volumes: ["data:/data"], + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + await rename(join(root, "definitions"), join(root, "old-definitions")); + await mkdir(join(root, "definitions")); + await rename( + join(root, "old-definitions/Dockerfile"), + join(root, "definitions/Dockerfile") + ); + await red(captured.assertFresh()); +}); +test("Dockerfile whitespace is raw source drift even when the recipe remains valid", async () => { + const captured = await acquire(); + await writeFile( + join(root, "Dockerfile"), + "FROM scratch\nCOPY src /source\n\n" + ); + await red(captured.assertFresh()); +}); +test("an ignored symlink cannot become a included input or leak its target", async () => { + await symlink(join(root, "src/marker"), join(root, "ignored-link")); + const captured = await acquire(); + expect( + captured.proof.contexts[0]?.nodes.some( + (node) => node.path === "ignored-link" + ) + ).toBe(false); + await rm(join(root, "ignored-link")); + await symlink(join(root, "Dockerfile"), join(root, "ignored-link")); + await captured.assertFresh(); +}); +test("a disjoint context without ignore files pins absent ignore presence", async () => { + await mkdir(join(root, ".hack/build")); + await writeFile(join(root, ".hack/build/Dockerfile"), "FROM scratch\n"); + composeText = composeText.replace('"build":".."', '"build":"build"'); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.effectiveIgnore).toBeNull(); + await writeFile( + join(root, ".hack/build/.dockerignore"), + "# no effective rules\n" + ); + await red(captured.assertFresh()); +}); +test("same ignore bytes at a replacement inode cannot repair saved source", async () => { + const captured = await acquire(); + const text = await readFile(join(root, ".dockerignore")); + await rename(join(root, ".dockerignore"), join(root, "old-ignore")); + await writeFile(join(root, ".dockerignore"), text); + await red(captured.assertFresh()); +}); +test("a cloned source cannot issue a build capability", async () => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + await red(acquireLegacyComposeBuildSource({ source: { ...source } })); +}); +test("captured cancellation cannot be replaced and the abort reason stays private", async () => { + const original = new AbortController(), + captured = await acquire(original.signal); + original.abort(CANARY); + await red(captured.assertFresh({ signal: new AbortController().signal })); + await red(acquire(original.signal)); +}); +test("explicit build policy refuses before any included context file is opened", async () => { + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { build: "..", pull_policy: "build", volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test(".hack context excludes switched authored files while preserving its included inputs", async () => { + await writeFile(join(root, ".hack/Dockerfile"), "FROM scratch\n"); + await writeFile(join(root, ".hack/.dockerignore"), "**\n!Dockerfile\n"); + composeText = composeText.replace('"build":".."', '"build":"."'); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.context).toBe(".hack"); + await captured.assertFresh(); +}); +test("parent negation includes descendants: toolchain pattern is not assumed to be a safe whitelist", async () => { + const rules = + "**\n!mise.toml\n!.hack\n!.hack/toolchain\n!.hack/toolchain/Dockerfile\n!.hack/toolchain/run.sh\n"; + const ignore = legacyComposeBuildIgnore(rules); + expect(ignore.excluded(".hack/.internal/arbitrary-future-file")).toBe(false); + expect(ignore.subtreeExcluded(".hack/.internal")).toBe(false); + await writeFile(join(root, "Dockerfile.dockerignore"), rules); + await red(acquire()); +}); +test("later owned subtree exclusion closes earlier broad inclusion", async () => { + const ignore = legacyComposeBuildIgnore( + "**\n!.hack\n.hack/.internal\n.hack/.branch\n" + ); + expect(ignore.subtreeExcluded(".hack/.internal")).toBe(true); + expect(ignore.excluded(".hack/toolchain/run.sh")).toBe(false); + expect( + legacyComposeBuildIgnore( + "**\n!.hack\n.hack/.internal\n!.hack/.internal/later\n" + ).subtreeExcluded(".hack/.internal") + ).toBe(false); +}); +test.each([ + "*.ts", + "a/**", + "a?", + "[ab]", + "a\\b", + "!", + "a/../b", + "./a", + "/a", + "a b", + "\uFEFF**", +])("unqualified ignore grammar %p refuses", (rule) => { + expect(() => legacyComposeBuildIgnore(rule)).toThrow("values omitted"); +}); From 30fc85d2df52a62b88d77db3b3b8b49afcf27401 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:32:32 -0400 Subject: [PATCH 03/26] fix: refuse private inputs before retained build capture --- docs/reference/native-compose-adoption.md | 6 +- src/lib/native-compose-adoption-build.ts | 47 ++++++- src/lib/native-compose-adoption-generation.ts | 2 +- ...-compose-adoption-build-generation.test.ts | 1 + ...tive-compose-adoption-build-source.test.ts | 122 ++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 381d1fff2..703059e3f 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -123,8 +123,10 @@ their combinations with this first build proof refuse, as do profiles, readiness managed/generated inputs, typed locals and literal-dollar build paths. Included context files, the Dockerfile, optional root and Dockerfile-specific -ignore files, and their safe filesystem identities are privately pinned. A -Dockerfile-specific ignore file takes precedence, while presence and bytes of +ignore files, and their safe filesystem identities are privately pinned. The +names-only private env/local layout refusal precedes the context walk and is +rechecked afterwards; known private material added mid-walk is never opened as +an included file. A Dockerfile-specific ignore file takes precedence, while presence and bytes of both files remain bound. The pinned `@balena/dockerignore` Moby port handles only the qualified case-sensitive grammar: literal normalized paths, `!` negation, bare `**`, blank lines and comments. Other globs, escapes, BOMs and ambiguous diff --git a/src/lib/native-compose-adoption-build.ts b/src/lib/native-compose-adoption-build.ts index afc31d18b..d8c7956ce 100644 --- a/src/lib/native-compose-adoption-build.ts +++ b/src/lib/native-compose-adoption-build.ts @@ -4,6 +4,7 @@ import { lstat, opendir, realpath } from "node:fs/promises"; import { join, posix, resolve } from "node:path"; import { isRecord } from "./guards.ts"; import { legacyComposeBuildIgnore } from "./native-compose-adoption-build-ignore.ts"; +import { legacyComposeAdoptionLayoutSupported } from "./native-compose-adoption-contract.ts"; import { type NativeConfigImportInputs, privateNativeConfigImportSourceProof, @@ -75,6 +76,30 @@ function check(signal?: AbortSignal) { refuse(); } } +function refusePrivateMaterial(root: string, path: string) { + const relative = posix.relative(root, path); + if ( + [".env", ".hack/.env", ".hack/hack.local.json"].includes(relative) || + /^\.hack\/hack\.env(?:\.|$)/.test(relative) + ) { + refuse(); + } +} +async function requireLayout(opts: { + readonly root: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}) { + if ( + !(await legacyComposeAdoptionLayoutSupported({ + projectRoot: opts.root, + candidate: opts.candidate, + signal: opts.signal, + })) + ) { + refuse(); + } +} function identity(info: Stats): Identity { if ( info.uid !== process.getuid?.() || @@ -238,6 +263,7 @@ async function captureContext(opts: { const ancestors = new Map(); const checks: (() => Promise)[] = []; async function pinDirectory(path: string) { + refusePrivateMaterial(root, path); const before = await directory(path, signal); const relative = posix.relative(root, path) || "."; const held = ancestors.get(relative); @@ -267,6 +293,9 @@ async function captureContext(opts: { await pinDirectory(parent); } const path = join(base, relative); + // A known private input added after the names-only precheck must never be + // opened as context material. The final layout check still refuses drift. + refusePrivateMaterial(root, path); const read = optional ? await optionalFile(path, signal) : await readNativeConfigImportSourceFile({ path, signal }); @@ -430,14 +459,18 @@ export async function acquireLegacyComposeBuildSource(opts: { readonly source: Source; readonly signal?: AbortSignal; }) { - const { source, signal } = opts; try { + const { source, signal } = opts; + if (signal !== undefined && !(signal instanceof AbortSignal)) { + refuse(); + } privateNativeConfigImportSourceProof(source); const candidate = mapLegacyNativeRetainedBasicBuild(source).candidate; if (!candidate) { refuse(); } selectedBuilds(candidate); + await requireLayout({ root: source.projectRoot, candidate, signal }); await source.assertFresh({ signal }); const proof = await capture({ root: source.projectRoot, @@ -450,6 +483,11 @@ export async function acquireLegacyComposeBuildSource(opts: { signal && current?.signal ? AbortSignal.any([signal, current.signal]) : (current?.signal ?? signal); + await requireLayout({ + root: source.projectRoot, + candidate, + signal: selected, + }); await source.assertFresh({ signal: selected }); if ( JSON.stringify( @@ -463,6 +501,11 @@ export async function acquireLegacyComposeBuildSource(opts: { refuse(); } await source.assertFresh({ signal: selected }); + await requireLayout({ + root: source.projectRoot, + candidate, + signal: selected, + }); } catch { refuse(); } @@ -504,6 +547,7 @@ export async function assertSavedLegacyComposeBuildSource(opts: { refuse(); } await checkOwner(); + await requireLayout({ root: projectRoot, candidate, signal }); if ( JSON.stringify( await capture({ root: projectRoot, candidate, signal }) @@ -512,6 +556,7 @@ export async function assertSavedLegacyComposeBuildSource(opts: { refuse(); } await checkOwner(); + await requireLayout({ root: projectRoot, candidate, signal }); } catch { refuse(); } diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index bd42ee73b..5ba91d46b 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -620,7 +620,7 @@ function claim( known: WeakMap, binding: LegacyComposeVerifiedBinding, status: "prepared" | "active" = "prepared", - version: 1 | 3 | 4 | 5 = 1 + version: 1 | 3 | 4 | 5 | 9 = 1 ): LegacyComposeAdoptedGeneration { const result: LegacyComposeAdoptedGeneration = { report: { diff --git a/tests/native-compose-adoption-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts index f3e09d8ac..7e3755d9b 100644 --- a/tests/native-compose-adoption-build-generation.test.ts +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -72,6 +72,7 @@ test("version9 prepares/switches/starts/stops/rolls back the original model iden if (!active) { throw new Error("missing synthetic active generation"); } + expect(active.report.adoption_generation_version).toBe(9); expect(await h.operation(store, active, "start")).toBe(0); expect(await h.operation(store, active, "stop")).toBe(0); await store.rollback(); diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts index 5420bd0fc..4be539cc5 100644 --- a/tests/native-compose-adoption-build-source.test.ts +++ b/tests/native-compose-adoption-build-source.test.ts @@ -72,6 +72,47 @@ async function red(pending: Promise) { expect(JSON.stringify(error)).not.toContain(CANARY); } } +async function linkedSource() { + async function git(args: readonly string[]) { + const child = Bun.spawn(["/usr/bin/git", "-C", root, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }); + if ((await child.exited) !== 0) { + throw new Error("Synthetic linked source setup refused; values omitted."); + } + } + await git(["init", "--quiet", "-b", "main"]); + await git(["add", ".hack", "Dockerfile", ".dockerignore", "src"]); + await git([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ]); + const checkout = join(root, "linked"); + await git(["worktree", "add", "--quiet", "-b", "linked", checkout]); + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: checkout, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic linked source setup refused; values omitted."); + } + return source; +} test("retained pure intent does not broaden either image-only baseline API", () => { const inputs = { configText: '{"name":"fixture"}', composeText }; @@ -288,6 +329,87 @@ test("explicit build policy refuses before any included context file is opened", read.mockRestore(); } }); +test.each([ + ".env", + ".hack/.env", + ".hack/hack.env.default.yaml", + ".hack/hack.local.json", +])("unsupported private layout %s refuses before any context/private material opens", async (relative) => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + await writeFile(join(root, relative), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test("new unsupported private inputs refuse saved read before context acquisition", async () => { + const captured = await acquire(); + await writeFile(join(root, ".env"), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red( + assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }) + ); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test("private material appearing after the layout check cannot be opened as an included context file", async () => { + await writeFile( + join(root, ".dockerignore"), + ".git\n.hack/.internal\n.hack/.branch\n.hack/hack.config.json\n.hack/docker-compose.yml\n.hack/hack.project.json\n" + ); + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const original = importInputs.readNativeConfigImportSourceFile; + const paths: string[] = []; + const read = spyOn( + importInputs, + "readNativeConfigImportSourceFile" + ).mockImplementation(async (opts) => { + paths.push(opts.path); + if (opts.path === join(root, "Dockerfile")) { + await writeFile(join(root, ".env"), CANARY); + } + return await original(opts); + }); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(paths).toContain(join(root, "Dockerfile")); + expect(paths).not.toContain(join(root, ".env")); + } finally { + read.mockRestore(); + } +}); +test.each([ + ".hack/hack.env.default.local.yaml", + ".hack/hack.local.json", +])("inherited primary %s refuses before context/private reads", async (relative) => { + const source = await linkedSource(); + await writeFile(join(root, relative), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); test(".hack context excludes switched authored files while preserving its included inputs", async () => { await writeFile(join(root, ".hack/Dockerfile"), "FROM scratch\n"); await writeFile(join(root, ".hack/.dockerignore"), "**\n!Dockerfile\n"); From 5687b4959ed98a83c1420a79d9752f28f2dd596b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:33:00 -0400 Subject: [PATCH 04/26] refactor: derive adoption claim version from manifest --- src/lib/native-compose-adoption-generation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 5ba91d46b..ad39ef78b 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -339,7 +339,7 @@ async function writeArtifact(path: string, text: string): Promise { /** A distinct private generation claim; it never makes original legacy resources native nonce-owned. */ export type LegacyComposeAdoptedGeneration = { readonly report: { - readonly adoption_generation_version: 1 | 3 | 4 | 5 | 9; + readonly adoption_generation_version: Manifest["adoption_generation_version"]; readonly owner: "legacy-compose"; readonly status: "prepared" | "active"; readonly containers: number; @@ -620,7 +620,7 @@ function claim( known: WeakMap, binding: LegacyComposeVerifiedBinding, status: "prepared" | "active" = "prepared", - version: 1 | 3 | 4 | 5 | 9 = 1 + version: Manifest["adoption_generation_version"] = 1 ): LegacyComposeAdoptedGeneration { const result: LegacyComposeAdoptedGeneration = { report: { From 3e82ef20f257d07f1c3782090b880c8744d7cfd4 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:38:21 -0400 Subject: [PATCH 05/26] fix: project private retained build source fields explicitly --- src/lib/native-compose-adoption-binding.ts | 5 ++++- src/lib/native-compose-adoption-build.ts | 8 ++++---- src/lib/native-config-import-plan.ts | 6 +++++- ...ive-compose-adoption-build-generation.test.ts | 9 +++++++-- .../native-compose-adoption-build-source.test.ts | 16 ++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index ad45b03d8..237a008fb 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -687,7 +687,10 @@ async function acquireBinding( if (!source.ok) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } - const ordinary = planLegacyComposeAdoption(source); + const ordinary = planLegacyComposeAdoption({ + configText: source.configText, + composeText: source.composeText, + }); const basic = purpose === "basic-build" || (purpose === "preparation" && !ordinary.intent); diff --git a/src/lib/native-compose-adoption-build.ts b/src/lib/native-compose-adoption-build.ts index d8c7956ce..339490531 100644 --- a/src/lib/native-compose-adoption-build.ts +++ b/src/lib/native-compose-adoption-build.ts @@ -158,11 +158,11 @@ function selectedBuilds(candidate: unknown): readonly Build[] { ) { refuse(); } + const context = build.context; if ( PRIVATE_PATHS.some( (owned) => - build.context === owned.path || - build.context.startsWith(`${owned.path}/`) + context === owned.path || context.startsWith(`${owned.path}/`) ) ) { refuse(); @@ -177,7 +177,7 @@ function selectedBuilds(candidate: unknown): readonly Build[] { ) { refuse(); } - const definitionPath = posix.join(build.context, dockerfile); + const definitionPath = posix.join(context, dockerfile); if ( PRIVATE_PATHS.some( (owned) => @@ -187,7 +187,7 @@ function selectedBuilds(candidate: unknown): readonly Build[] { ) { refuse(); } - result.push({ service, context: build.context, dockerfile, target }); + result.push({ service, context, dockerfile, target }); } if (!result.length || result.length > 16) { refuse(); diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 840185ae7..52dc421b4 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -241,7 +241,11 @@ export function mapLegacyNativeRetainedBasicBuild(opts: { readonly configText: string; readonly composeText: string; }): NativeImportPlan { - return mapLegacyNativeInput({ ...opts, purpose: "retained-basic-build" }); + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-basic-build", + }); } function mapStorageCandidate( diff --git a/tests/native-compose-adoption-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts index 7e3755d9b..4fc7d60f1 100644 --- a/tests/native-compose-adoption-build-generation.test.ts +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -29,8 +29,13 @@ async function red(pending: Promise) { } async function prepared() { const store = await h.store(); - const generation = await store.prepare({ binary: h.compiler }); - return { store, generation }; + try { + const generation = await store.prepare({ binary: h.compiler }); + return { store, generation }; + } catch (error: unknown) { + await store.close(); + throw error; + } } async function assertNoAllocation() { const commands = await h.commands(); diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts index 4be539cc5..b4a924acf 100644 --- a/tests/native-compose-adoption-build-source.test.ts +++ b/tests/native-compose-adoption-build-source.test.ts @@ -122,6 +122,22 @@ test("retained pure intent does not broaden either image-only baseline API", () services: { db: { build: { context: "." } } }, }); }); +test("retained mapper explicitly projects non-enumerable private source fields", async () => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + expect(Object.keys(source)).not.toContain("composeText"); + expect(mapLegacyNativeRetainedBasicBuild(source).candidate).toEqual( + mapLegacyNativeRetainedBasicBuild({ + configText: source.configText, + composeText: source.composeText, + }).candidate + ); + expect( + mapLegacyNativeRetainedBasicBuild(source).candidate?.services.db?.build + ).toEqual({ context: "." }); +}); test("root context pins included source and keeps private proof/candidate/callbacks out of reports", async () => { const captured = await acquire(); expect(Object.keys(captured)).toEqual([]); From 1606d1757684a2bb679ef0a6aecf3e44cfc27f1f Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:39:11 -0400 Subject: [PATCH 06/26] test: narrow private mapper candidate assertion --- tests/native-compose-adoption-build-source.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts index b4a924acf..355b5a307 100644 --- a/tests/native-compose-adoption-build-source.test.ts +++ b/tests/native-compose-adoption-build-source.test.ts @@ -134,9 +134,9 @@ test("retained mapper explicitly projects non-enumerable private source fields", composeText: source.composeText, }).candidate ); - expect( - mapLegacyNativeRetainedBasicBuild(source).candidate?.services.db?.build - ).toEqual({ context: "." }); + expect(mapLegacyNativeRetainedBasicBuild(source).candidate).toMatchObject({ + services: { db: { build: { context: "." } } }, + }); }); test("root context pins included source and keeps private proof/candidate/callbacks out of reports", async () => { const captured = await acquire(); From 2afdf2decb995c435715f74e141f70a8bef50e7a Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 15:42:23 -0400 Subject: [PATCH 07/26] test: select local inheritance in retained source controls --- tests/native-compose-adoption-build-source.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts index 355b5a307..36100cc5d 100644 --- a/tests/native-compose-adoption-build-source.test.ts +++ b/tests/native-compose-adoption-build-source.test.ts @@ -25,11 +25,20 @@ import { mapLegacyNativeRetainedBasicBuild, mapLegacyNativeStorageAdoption, } from "../src/lib/native-config-import-plan.ts"; +import { restoreEnv } from "./helpers/env.ts"; const CANARY = "synthetic-private-build-source"; let root: string; let composeText: string; +let previousCi: string | undefined; +let previousExecutionMode: string | undefined; beforeEach(async () => { + previousCi = process.env.CI; + previousExecutionMode = process.env.HACK_EXECUTION_MODE; + // These inherited-input cases intentionally exercise the ordinary local + // selection rather than CI/slim's existing primary-scope opt-out. + process.env.CI = ""; + process.env.HACK_EXECUTION_MODE = ""; root = await realpath( await mkdtemp(join(tmpdir(), "retained-build-source-")) ); @@ -48,6 +57,8 @@ beforeEach(async () => { await writeFile(join(root, ".hack/docker-compose.yml"), composeText); }); afterEach(async () => { + restoreEnv("CI", previousCi); + restoreEnv("HACK_EXECUTION_MODE", previousExecutionMode); await rm(root, { recursive: true, force: true }); }); async function acquire(signal?: AbortSignal) { From 0b10773cc23594b93efdcd27d446e7acdc7486d9 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:05:41 -0400 Subject: [PATCH 08/26] refactor: extract retained build validation predicates --- .../native-compose-adoption-build-ignore.ts | 3 +- .../native-compose-adoption-build-images.ts | 2 +- src/lib/native-compose-adoption-build.ts | 165 ++++++++++-------- src/lib/native-compose-adoption-execution.ts | 2 +- src/lib/native-compose-adoption-generation.ts | 66 ++++--- src/lib/native-compose-adoption-preview.ts | 2 +- src/lib/native-config-import-plan.ts | 38 ++-- tests/helpers/retained-build-adoption.ts | 12 +- ...-compose-adoption-build-generation.test.ts | 6 +- ...tive-compose-adoption-build-source.test.ts | 2 +- 10 files changed, 168 insertions(+), 130 deletions(-) diff --git a/src/lib/native-compose-adoption-build-ignore.ts b/src/lib/native-compose-adoption-build-ignore.ts index 83aedf7c3..a0424c858 100644 --- a/src/lib/native-compose-adoption-build-ignore.ts +++ b/src/lib/native-compose-adoption-build-ignore.ts @@ -1,6 +1,7 @@ import createDockerignore from "@balena/dockerignore"; const LITERAL = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/; +const LINES = /\r?\n/; const MAX_RULES = 128; const MAX_IGNORE_BYTES = 16 * 1024; @@ -21,7 +22,7 @@ export function legacyComposeBuildIgnore(text: string) { } const rules: string[] = []; const literals: string[] = []; - for (const raw of text.split(/\r?\n/)) { + for (const raw of text.split(LINES)) { if (raw.startsWith("#") || !raw.trim()) { continue; } diff --git a/src/lib/native-compose-adoption-build-images.ts b/src/lib/native-compose-adoption-build-images.ts index bb74450ee..7d102420c 100644 --- a/src/lib/native-compose-adoption-build-images.ts +++ b/src/lib/native-compose-adoption-build-images.ts @@ -73,7 +73,7 @@ export async function inspectLegacyComposeRetainedBuildImages(opts: { } const result: LegacyComposeRetainedBuildImage[] = []; for (const container of originals) { - if (!ID.test(container.id) || !NAME.test(container.service)) { + if (!(ID.test(container.id) && NAME.test(container.service))) { refuse(); } const imageOutput = await probe([ diff --git a/src/lib/native-compose-adoption-build.ts b/src/lib/native-compose-adoption-build.ts index 339490531..347d02b52 100644 --- a/src/lib/native-compose-adoption-build.ts +++ b/src/lib/native-compose-adoption-build.ts @@ -20,6 +20,8 @@ const MAX_BYTES = 16 * 1024 * 1024; const MAX_NAMES = 4096; const MAX_DEPTH = 32; const MAX_PROOF_BYTES = 48 * 1024; +const MANAGED_PATH = /^\.hack\/hack\.env(?:\.|$)/; +const UNSAFE_PATH_BYTES = /[\\\0\r\n]/; const PRIVATE_PATHS = [ { path: ".git", tree: true }, { path: ".hack/.internal", tree: true }, @@ -80,7 +82,7 @@ function refusePrivateMaterial(root: string, path: string) { const relative = posix.relative(root, path); if ( [".env", ".hack/.env", ".hack/hack.local.json"].includes(relative) || - /^\.hack\/hack\.env(?:\.|$)/.test(relative) + MANAGED_PATH.test(relative) ) { refuse(); } @@ -120,74 +122,79 @@ function pathSupported(path: string) { path !== ".." && !path.startsWith("../") && !path.includes("$") && - !/[\\\0\r\n]/.test(path) + !UNSAFE_PATH_BYTES.test(path) ); } +function selectedBuild(service: string, value: unknown): Build | null { + if (!isRecord(value)) { + refuse(); + } + // These other version owners cannot be implicitly combined with a new proof. + if ( + Object.hasOwn(value, "profiles") || + Object.hasOwn(value, "depends_on") || + Object.hasOwn(value, "readiness") + ) { + refuse(); + } + if (!Object.hasOwn(value, "build")) { + return null; + } + // The retained start owner never invokes a builder. Explicit build policy + // requires a new image even when one exists, so it cannot be honored here. + if (Object.hasOwn(value, "pull_policy")) { + refuse(); + } + const build = value.build; + if ( + !( + isRecord(build) && + typeof build.context === "string" && + pathSupported(build.context) + ) + ) { + refuse(); + } + const context = build.context; + if ( + PRIVATE_PATHS.some( + (owned) => context === owned.path || context.startsWith(`${owned.path}/`) + ) + ) { + refuse(); + } + const dockerfile = build.dockerfile ?? "Dockerfile"; + const target = build.target ?? null; + if ( + typeof dockerfile !== "string" || + !pathSupported(dockerfile) || + dockerfile === "." || + (target !== null && typeof target !== "string") + ) { + refuse(); + } + const definitionPath = posix.join(context, dockerfile); + if ( + PRIVATE_PATHS.some( + (owned) => + definitionPath === owned.path || + (owned.tree && definitionPath.startsWith(`${owned.path}/`)) + ) + ) { + refuse(); + } + return { service, context, dockerfile, target }; +} function selectedBuilds(candidate: unknown): readonly Build[] { if (!(isRecord(candidate) && isRecord(candidate.services))) { refuse(); } const result: Build[] = []; for (const [service, value] of Object.entries(candidate.services)) { - if (!isRecord(value)) { - refuse(); - } - // These other version owners cannot be implicitly combined with a new proof. - if ( - Object.hasOwn(value, "profiles") || - Object.hasOwn(value, "depends_on") || - Object.hasOwn(value, "readiness") - ) { - refuse(); + const build = selectedBuild(service, value); + if (build) { + result.push(build); } - if (!Object.hasOwn(value, "build")) { - continue; - } - // The retained start owner never invokes a builder. Explicit build policy - // requires a new image even when one exists, so it cannot be honored here. - if (Object.hasOwn(value, "pull_policy")) { - refuse(); - } - const build = value.build; - if ( - !( - isRecord(build) && - typeof build.context === "string" && - pathSupported(build.context) - ) - ) { - refuse(); - } - const context = build.context; - if ( - PRIVATE_PATHS.some( - (owned) => - context === owned.path || context.startsWith(`${owned.path}/`) - ) - ) { - refuse(); - } - const dockerfile = build.dockerfile ?? "Dockerfile"; - const target = build.target ?? null; - if ( - typeof dockerfile !== "string" || - !pathSupported(dockerfile) || - dockerfile === "." || - (target !== null && typeof target !== "string") - ) { - refuse(); - } - const definitionPath = posix.join(context, dockerfile); - if ( - PRIVATE_PATHS.some( - (owned) => - definitionPath === owned.path || - (owned.tree && definitionPath.startsWith(`${owned.path}/`)) - ) - ) { - refuse(); - } - result.push({ service, context, dockerfile, target }); } if (!result.length || result.length > 16) { refuse(); @@ -360,6 +367,24 @@ async function captureContext(opts: { ); requireExcluded(build.context, ignore); const nodes: Node[] = []; + async function captureNode(path: string, nodePath: string, depth: number) { + const info = await lstat(path); + if (info.isDirectory()) { + await walk(path, nodePath, depth); + return; + } + if (!info.isFile()) { + refuse(); + } + if (ignore.excluded(nodePath)) { + return; + } + const read = await pinFile(nodePath); + if (!read) { + refuse(); + } + nodes.push({ ...read.file, kind: "file" }); + } async function walk(path: string, relative: string, depth: number) { check(signal); if (depth > MAX_DEPTH || ++budget.entries > MAX_ENTRIES) { @@ -376,18 +401,7 @@ async function captureContext(opts: { for (const name of entries) { const selected = join(path, name); const nodePath = relative ? `${relative}/${name}` : name; - const info = await lstat(selected); - if (info.isDirectory()) { - await walk(selected, nodePath, depth + 1); - } else if (info.isFile() && !ignore.excluded(nodePath)) { - const read = await pinFile(nodePath); - if (!read) { - refuse(); - } - nodes.push({ ...read.file, kind: "file" }); - } else if (!info.isFile()) { - refuse(); - } + await captureNode(selected, nodePath, depth + 1); } // Excluded output creation does not change the projected names. Included // additions/removals and ancestor replacement still refuse this acquisition. @@ -404,6 +418,7 @@ async function captureContext(opts: { check(signal); await recheck(); } + const rootIgnorePath = rootIgnore ? ".dockerignore" : null; return { ...build, ancestors: [...ancestors.entries()] @@ -414,11 +429,7 @@ async function captureContext(opts: { { path: ".dockerignore", file: rootIgnore?.file ?? null }, { path: specificPath, file: specificIgnore?.file ?? null }, ], - effectiveIgnore: specificIgnore - ? specificPath - : rootIgnore - ? ".dockerignore" - : null, + effectiveIgnore: specificIgnore ? specificPath : rootIgnorePath, nodes, }; } diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index cc8fa1d18..9c90f7410 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -52,7 +52,7 @@ export async function executeLegacyComposeRetainedPlan(opts: { binding.containers.map((container) => [container.service, container.id]) ); if ( - (!plan.requiresV5 && !retainedBuild) || + !(plan.requiresV5 || retainedBuild) || ids.size !== binding.containers.length || plan.ordered.length !== ids.size || plan.ordered.some((service) => !ids.has(service.service)) || diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index ad39ef78b..fbcb67289 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -66,8 +66,8 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, - mapLegacyNativeStorageAdoption, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; @@ -219,21 +219,24 @@ function sourceFileIdentity( value.uid === process.getuid?.() ); } +function manifestFieldKeys(value: Record) { + if (value.adoption_generation_version === 9) { + return "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; + } + const projected = + (value.adoption_generation_version === 5 && + Object.hasOwn(value, "projectionProof")) || + value.adoption_generation_version === 3 || + value.adoption_generation_version === 4; + return projected + ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" + : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; +} function manifest(value: unknown, root: string, id: string): SavedManifest { if ( !( isRecord(value) && - keys( - value, - value.adoption_generation_version === 9 - ? "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles" - : (value.adoption_generation_version === 5 && - Object.hasOwn(value, "projectionProof")) || - value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 - ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" - : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles" - ) && + keys(value, manifestFieldKeys(value)) && (value.adoption_generation_version === 1 || (value.adoption_generation_version === 9 && isRecord(value.buildProof) && @@ -410,6 +413,27 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +async function assertRetainedBuildSource(opts: { + readonly ctx: Context; + readonly meta: Manifest; + readonly configText: string; + readonly composeText: string; +}) { + if (opts.meta.adoption_generation_version !== 9) { + return; + } + if (!opts.meta.buildProof) { + refuse(); + } + await assertSavedLegacyComposeBuildSource({ + projectRoot: opts.ctx.root, + configText: opts.configText, + composeText: opts.composeText, + proof: opts.meta.buildProof.source, + signal: opts.ctx.signal, + checkOwner: opts.ctx.check, + }); +} async function readInputs( ctx: Context, selected: Anchor, @@ -451,22 +475,8 @@ async function readInputs( ? planLegacyComposeRetainedBasicBuildAdoption : planLegacyComposeAdoption )({ configText, composeText }); - const assertBuildSource = async () => { - if (!basic) { - return; - } - if (!meta.buildProof) { - refuse(); - } - await assertSavedLegacyComposeBuildSource({ - projectRoot: ctx.root, - configText, - composeText, - proof: meta.buildProof.source, - signal: ctx.signal, - checkOwner: ctx.check, - }); - }; + const assertBuildSource = () => + assertRetainedBuildSource({ ctx, meta, configText, composeText }); await assertBuildSource(); const projectionOpts = { projectRoot: ctx.root, diff --git a/src/lib/native-compose-adoption-preview.ts b/src/lib/native-compose-adoption-preview.ts index 883421da7..d9d05aae8 100644 --- a/src/lib/native-compose-adoption-preview.ts +++ b/src/lib/native-compose-adoption-preview.ts @@ -12,8 +12,8 @@ import { import type { ImportField } from "./native-config-import-parser.ts"; import { freezeImportValue, - mapLegacyNativeStorageAdoption, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; function refused(code: string): ImportField { diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 52dc421b4..1884f0c16 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -733,6 +733,25 @@ function commandPresence( return undefined; } +function serviceRuleValue(opts: { + readonly key: string; + readonly raw: unknown; + readonly buildPreview: boolean; +}) { + if (opts.key === "pull_policy" && opts.buildPreview) { + return opts.raw === "build" ? opts.raw : undefined; + } + return SERVICE_RULES[opts.key]?.(opts.raw); +} +function rememberProfiles(value: unknown, profiles: Set) { + if (Array.isArray(value)) { + for (const name of value) { + if (typeof name === "string") { + profiles.add(name); + } + } + } +} function mapService( opts: Pick & { readonly source: Record; @@ -759,12 +778,11 @@ function mapService( opts.refuse("compose", pointer, "empty_command_unrepresentable"); continue; } - let value: unknown; - if (key === "pull_policy" && opts.buildPreview && hasBuild) { - value = raw === "build" ? raw : undefined; - } else { - value = SERVICE_RULES[key]?.(raw); - } + const value = serviceRuleValue({ + key, + raw, + buildPreview: opts.buildPreview && hasBuild, + }); if (value === undefined) { opts.refuse("compose", pointer, "invalid_or_ambiguous_value"); continue; @@ -777,12 +795,8 @@ function mapService( pointer, target: `${opts.pointer}/${property}`, }); - if (key === "profiles" && Array.isArray(value)) { - for (const name of value) { - if (typeof name === "string") { - opts.profiles.add(name); - } - } + if (key === "profiles") { + rememberProfiles(value, opts.profiles); } } if (opts.buildPreview && hasBuild) { diff --git a/tests/helpers/retained-build-adoption.ts b/tests/helpers/retained-build-adoption.ts index 3e12a126f..2fcbd7283 100644 --- a/tests/helpers/retained-build-adoption.ts +++ b/tests/helpers/retained-build-adoption.ts @@ -10,11 +10,11 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { isRecord } from "../../src/lib/guards.ts"; -import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; import { runLegacyComposeRetainedOperation } from "../../src/lib/native-compose-adoption-execution.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; -import { managedEnvCompilerFixture } from "./managed-env-compiler.ts"; import { restoreEnv } from "./env.ts"; +import { managedEnvCompilerFixture } from "./managed-env-compiler.ts"; export const BUILD_CANARY = "synthetic-private-retained-build"; const CREATED = "2026-01-01T01:02:03Z"; @@ -166,8 +166,10 @@ else red(); .map((line) => { const value: unknown = JSON.parse(line); if ( - !Array.isArray(value) || - !value.every((part): part is string => typeof part === "string") + !( + Array.isArray(value) && + value.every((part): part is string => typeof part === "string") + ) ) { throw new Error( "Synthetic command ledger is invalid; values omitted." @@ -182,7 +184,7 @@ else red(); "utf8" ) ); - if (!isRecord(value) || !isRecord(value.checkout)) { + if (!(isRecord(value) && isRecord(value.checkout))) { throw new Error("Synthetic receipt is invalid; values omitted."); } return parseLegacyComposeAdoptionReceipt(value, { diff --git a/tests/native-compose-adoption-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts index 4fc7d60f1..15ac421e2 100644 --- a/tests/native-compose-adoption-build-generation.test.ts +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test as boundedTest } from "bun:test"; +import { afterEach, beforeEach, test as boundedTest, expect } from "bun:test"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; @@ -269,7 +269,7 @@ test("remaining operation budget settles a held pre-effect ownership query witho store.withPreparationStop({ generation, binary: h.compiler, - deadline: Date.now() + 1_000, + deadline: Date.now() + 1000, run: async () => { entered = true; return 0; @@ -277,7 +277,7 @@ test("remaining operation budget settles a held pre-effect ownership query witho }) ); expect(await Bun.file(join(h.outer, "probe-started")).exists()).toBe(true); - expect(performance.now() - start).toBeLessThan(5_000); + expect(performance.now() - start).toBeLessThan(5000); expect(entered).toBe(false); expect((await h.receipt()).pendingOperation).toBeNull(); await assertNoAllocation(); diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts index 36100cc5d..386f0f38e 100644 --- a/tests/native-compose-adoption-build-source.test.ts +++ b/tests/native-compose-adoption-build-source.test.ts @@ -18,8 +18,8 @@ import { assertSavedLegacyComposeBuildSource, } from "../src/lib/native-compose-adoption-build.ts"; import { legacyComposeBuildIgnore } from "../src/lib/native-compose-adoption-build-ignore.ts"; -import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; import * as importInputs from "../src/lib/native-config-import-inputs.ts"; +import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; import { mapLegacyNativeAdoptionBaseline, mapLegacyNativeRetainedBasicBuild, From 508afe0c970b22a010c76cd99c3cbddb707c8028 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:06:35 -0400 Subject: [PATCH 09/26] test: expose fixed retained reprepare stage diagnostics --- ...native-compose-adoption-generation.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/native-compose-adoption-generation.test.ts b/tests/native-compose-adoption-generation.test.ts index 10f6267fb..8bd7ce12c 100644 --- a/tests/native-compose-adoption-generation.test.ts +++ b/tests/native-compose-adoption-generation.test.ts @@ -437,22 +437,61 @@ for (const linked of [false, true]) { boundedTest( `rolled-back v5 can reprepare a verified plain generation with ${linked ? "linked" : "directory"} checkout receipt version`, async () => { + const captureStages = + process.env.HACK_TEST_ADOPTION_REPREPARE_STAGES === "1"; + const started = performance.now(); + const stage = ( + value: + | "entry" + | "checkout" + | "dependencies" + | "v5-prepared" + | "v5-published" + | "v5-rolled-back" + | "plain-source" + | "plain-prepared" + | "plain-loaded-prepared" + | "plain-published" + | "plain-loaded-active" + | "closed" + ) => { + if (captureStages) { + // Opt-in diagnostic only: fixed stages and elapsed time, no fixture + // path, source, private receipt, child argv or resource values. + process.stderr.write( + `${JSON.stringify({ + diagnostic: "v5-reprepare-stage", + layout: linked ? "linked" : "directory", + stage: value, + elapsedMs: Math.round(performance.now() - started), + })}\n` + ); + } + }; + stage("entry"); if (linked) { await linkedCheckout(); } + stage("checkout"); const worker = await dependencyFixture(); + stage("dependencies"); const { store, generation } = await prepared(); + stage("v5-prepared"); try { const binary = await compiler(); await store.publish({ generation, binary }); + stage("v5-published"); await store.rollback(); + stage("v5-rolled-back"); const composePath = join(projectRoot, ".hack/docker-compose.yml"); const authored = JSON.parse(await readFile(composePath, "utf8")); authored.services.web.depends_on = undefined; authored.services.db.healthcheck = undefined; await writeFile(composePath, JSON.stringify(authored)); + stage("plain-source"); // The synthetic engine config-hash owner continues to attest the new source. const plain = await store.prepare({ binary }); + stage("plain-prepared"); expect(plain.report.adoption_generation_version).toBe(1); expect((await readReceipt()).adoption_receipt_version).toBe( linked ? 2 : 1 @@ -460,15 +499,19 @@ for (const linked of [false, true]) { expect( (await store.loadPrepared())?.report.adoption_generation_version ).toBe(1); + stage("plain-loaded-prepared"); await store.publish({ generation: plain, binary }); + stage("plain-published"); expect( (await store.loadActive())?.report.adoption_generation_version ).toBe(1); + stage("plain-loaded-active"); expect(await mutationCommands()).toEqual([]); expect(fixture.container.map((row) => row.id)).toEqual([ID, worker]); expect(fixture.volume[0]?.createdAt).toBe(CREATED); } finally { await store.close(); + stage("closed"); } }, 30_000 From 527058d2835d8fd3799ac18fed8e56d036dd47e2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:08:28 -0400 Subject: [PATCH 10/26] refactor: isolate retained saved receipt correspondence --- src/lib/native-compose-adoption-generation.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index fbcb67289..2b6f53d5e 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -415,7 +415,7 @@ type Context = { }; async function assertRetainedBuildSource(opts: { readonly ctx: Context; - readonly meta: Manifest; + readonly meta: SavedManifest; readonly configText: string; readonly composeText: string; }) { @@ -434,6 +434,16 @@ async function assertRetainedBuildSource(opts: { checkOwner: opts.ctx.check, }); } +function requireSavedReceiptVersion(meta: SavedManifest, current: Receipt) { + if ( + (meta.adoption_generation_version === 5) !== + (current.adoption_receipt_version === 5) || + (meta.adoption_generation_version === 9) !== + (current.adoption_receipt_version === 9) + ) { + refuse(); + } +} async function readInputs( ctx: Context, selected: Anchor, @@ -508,14 +518,7 @@ async function readInputs( refuse(); } if (!preparing) { - const currentReceipt = await publicationState(ctx); - if ( - (meta.adoption_generation_version === 5) !== - (currentReceipt.adoption_receipt_version === 5) || - basic !== (currentReceipt.adoption_receipt_version === 9) - ) { - refuse(); - } + requireSavedReceiptVersion(meta, await publicationState(ctx)); } const observed = await inspectLegacyComposeAdoptionResources({ root: ctx.root, From 9e248d14aedaf34611e8b32b2c158f083be7a6ba Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:50:06 -0400 Subject: [PATCH 11/26] test: maintain retained basic build worktree acceptance --- docs/reference/native-compose-adoption.md | 13 + tests/e2e/run.ts | 2 + .../native-compose-adoption-build-inputs.ts | 365 ++++++++ .../native-compose-adoption-worktrees.ts | 817 +++++++++++++++++- ...ive-compose-adoption-build-fixture.test.ts | 404 +++++++++ 5 files changed, 1583 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-build-inputs.ts create mode 100644 tests/native-compose-adoption-build-fixture.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index a74614418..663ea22a8 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -173,6 +173,19 @@ fence. Current Moby/BuildKit ignore parity and maintained two-worktree SQL, image/ID/birth, recovery and rollback acceptance qualify this slice separately from pure preview and synthetic model controls. Full NC04 remains open. +The maintained `native-compose-adoption-build-worktrees` scenario requires +explicit selection. It bootstraps two disposable Postgres images, checks the +complete `COPY .` projection for root/Dockerfile-specific and default `.hack` +contexts, and then permits only captured metadata queries and journaled original-ID +starts/stops. Format switch and saved consumption cannot reach a builder. Its +source and candidate drift controls retain pending ownership, preserve the other +worktree's SQL row, and restore both original configurations through rollback. +Exact fixture image removal requires its captured new ID/birth, sole tag, fixture +label, unchanged daemon and no remaining container references. Builder cache is +retained; cache reclamation and historical image-from-source provenance remain +unqualified. Fixture source/model checks alone do not establish live builder or +data-preservation acceptance. + The version 4 typed-local slice reads optional `.hack/hack.local.json` at the selected checkout and verified inherited primary in the same issued private source acquisition. It accepts only `schema_version: 1` and an optional `environment` diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 73c271e62..0d3c52338 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -15,6 +15,7 @@ import { initScenario } from "./scenarios/init.ts"; import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process.ts"; import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; import { + nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, nativeComposeAdoptionLocalWorktreesScenario, nativeComposeAdoptionManagedWorktreesScenario, @@ -89,6 +90,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeComposeAdoptionManagedWorktreesScenario, nativeComposeAdoptionLocalWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, + nativeComposeAdoptionBuildWorktreesScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, ]; diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts new file mode 100644 index 000000000..83ea36f81 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -0,0 +1,365 @@ +import { lstat, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { adoptionDependencyReadAllowed } from "./native-compose-adoption-dependency-inputs.ts"; +import { + type AdoptionDependencyFirstPrepare, + adoptionDependencyStagedReadAllowed, +} from "./native-compose-adoption-dependency-staged-read.ts"; + +export type RetainedBuildFixtureMode = "root-specific" | "hack-default"; +export const RETAINED_BUILD_BASE_TAG = "postgres:17.6-alpine"; +export const RETAINED_BUILD_IMAGE_OWNER = "hack.e2e.retained-build.owner"; +export const RETAINED_BUILD_IMAGE_FORMAT = `{"id":{{json .Id}},"created":{{json .Created}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}}}`; +const ID = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const BIRTH = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TOKEN = /^[a-f0-9]{32}$/; +const CONTAINER_IMAGE_FORMAT = + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}'; +const IMAGE_BIRTH_FORMAT = '{"id":{{json .Id}},"createdAt":{{json .Created}}}'; + +function refuse(): never { + throw new Error( + "Retained build fixture source or ownership refused; values omitted." + ); +} +function hash(bytes: string | Uint8Array) { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} +function marker(mode: RetainedBuildFixtureMode) { + return `retained-build-${mode}\n`; +} +/** Closed authored definition: root/specific/target and default .hack are independently exercised. */ +export function retainedBuildFixtureDefinition(mode: RetainedBuildFixtureMode) { + return mode === "root-specific" + ? { + context: "..", + dockerfile: ".hack/toolchain/Dockerfile", + target: "retained", + } + : {}; +} +export function retainedBuildFixtureMarker( + root: string, + mode: RetainedBuildFixtureMode +) { + return join( + root, + mode === "root-specific" ? ".hack/toolchain/run.sh" : ".hack/data-marker" + ); +} +export function retainedBuildFixtureCopiedFiles( + mode: RetainedBuildFixtureMode +) { + return mode === "root-specific" + ? [ + { path: ".hack/toolchain/run.sh", hash: hash(marker(mode)) }, + { path: "mise.toml", hash: hash("# synthetic retained build input\n") }, + ] + : [{ path: "data-marker", hash: hash(marker(mode)) }]; +} +/** The real builder's complete copied file list and hashes must equal this explicit fixture oracle. */ +export function assertRetainedBuildFixtureCopy(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly text: string; +}) { + const expected = retainedBuildFixtureCopiedFiles(opts.mode) + .map(({ path, hash: digest }) => `${path} ${digest}`) + .sort() + .join("\n"); + if (opts.text.trim() !== expected) { + refuse(); + } +} +export const RETAINED_BUILD_COPY_ORACLE = + 'set -eu; cd /nc04-context; find . -type f | LC_ALL=C sort | while IFS= read -r p; do h=$(sha256sum "$p"); printf \'%s %s\\n\' "${p#./}" "${h%% *}"; done'; + +/** Fixture authoring only. These files live outside all managed-output directories. */ +export async function prepareRetainedBuildFixtureSources(opts: { + readonly root: string; + readonly name: string; + readonly mode: RetainedBuildFixtureMode; +}) { + if (!NAME.test(opts.name)) { + refuse(); + } + const rootMode = opts.mode === "root-specific"; + const context = rootMode ? opts.root : join(opts.root, ".hack"); + const definition = rootMode ? ".hack/toolchain/Dockerfile" : "Dockerfile"; + await mkdir(join(opts.root, ".hack/toolchain"), { recursive: true }); + await Bun.write( + retainedBuildFixtureMarker(opts.root, opts.mode), + marker(opts.mode) + ); + const dockerfile = [ + `FROM ${RETAINED_BUILD_BASE_TAG} AS retained`, + `LABEL ${RETAINED_BUILD_IMAGE_OWNER}="${opts.name}" hack.e2e.retained-build.stage="retained"`, + "COPY . /nc04-context", + ...(rootMode + ? [ + `FROM ${RETAINED_BUILD_BASE_TAG} AS wrong-final-stage`, + "LABEL hack.e2e.retained-build.stage=wrong", + ] + : []), + "", + ].join("\n"); + await Bun.write(join(context, definition), dockerfile); + if (rootMode) { + await Bun.write( + join(opts.root, "mise.toml"), + "# synthetic retained build input\n" + ); + // The shadowed root ignore deliberately selects a different file. Both raw + // inputs remain privately pinned, while only the specific rules feed COPY. + await Bun.write(join(opts.root, "wrong-marker"), "shadowed-root-ignore\n"); + await Bun.write(join(opts.root, ".dockerignore"), "**\n!wrong-marker\n"); + await Bun.write( + join(context, `${definition}.dockerignore`), + [ + "**", + "!mise.toml", + "!.hack", + "!.hack/toolchain", + "!.hack/toolchain/Dockerfile", + "!.hack/toolchain/run.sh", + // Parent negation also admits future private output; close every owned + // frontier rather than pretending the raw toolchain whitelist is safe. + ".hack/.internal", + ".hack/.branch", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ".hack/hack.project.json", + ".hack/toolchain/Dockerfile", + ".hack/toolchain/Dockerfile.dockerignore", + "", + ].join("\n") + ); + } else { + await Bun.write(join(context, ".dockerignore"), "**\n!data-marker\n"); + } +} + +/** Explicit fixture paths, not an alternative general build-context acquisition owner. */ +export async function retainedBuildFixtureSourceSnapshot(opts: { + readonly root: string; + readonly mode: RetainedBuildFixtureMode; +}) { + const paths = + opts.mode === "root-specific" + ? [ + "mise.toml", + ".dockerignore", + "wrong-marker", + ".hack/toolchain/Dockerfile", + ".hack/toolchain/Dockerfile.dockerignore", + ".hack/toolchain/run.sh", + ] + : [".hack/Dockerfile", ".hack/.dockerignore", ".hack/data-marker"]; + const result = []; + for (const path of paths) { + const selected = join(opts.root, path); + const info = await lstat(selected); + if ( + !info.isFile() || + info.nlink !== 1 || + info.uid !== process.getuid?.() || + (info.mode & 0o022) !== 0 + ) { + refuse(); + } + result.push({ + path, + dev: info.dev, + ino: info.ino, + mode: info.mode, + hash: hash(await readFile(selected)), + }); + } + return JSON.stringify(result); +} + +export type RetainedFixtureImage = { + readonly id: string; + readonly created: string; + readonly reference: string; + readonly tag: string; + readonly owner: string; +}; +/** Capture no old/foreign image removal authority, even when its tag happens to match. */ +export function retainedBuildFixtureImage(opts: { + readonly value: unknown; + readonly reference: string; + readonly owner: string; + readonly originalImageIds: readonly string[]; +}): RetainedFixtureImage { + const { value, reference, owner, originalImageIds } = opts; + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "created,digests,id,owner,stage,tags" && + typeof value.id === "string" && + IMAGE.test(value.id) && + !originalImageIds.includes(value.id) && + originalImageIds.every((id) => IMAGE.test(id)) && + typeof value.created === "string" && + BIRTH.test(value.created) && + Number.isFinite(Date.parse(value.created)) && + NAME.test(owner) && + value.owner === owner && + value.stage === "retained" && + Array.isArray(value.tags) && + value.tags.length === 1 && + (value.tags[0] === reference || + value.tags[0] === `${reference}:latest`) && + (value.digests === null || + (Array.isArray(value.digests) && value.digests.length === 0)) + ) + ) { + refuse(); + } + return Object.freeze({ + id: value.id, + created: value.created, + reference, + tag: value.tags[0], + owner, + }); +} +export function assertRetainedFixtureImageUnchanged(opts: { + readonly current: RetainedFixtureImage; + readonly captured: RetainedFixtureImage; +}) { + if (JSON.stringify(opts.current) !== JSON.stringify(opts.captured)) { + refuse(); + } +} + +type BuildReadScope = { + readonly args: readonly string[]; + readonly projectRoot: string; + readonly project: string; + readonly containerIds: readonly string[]; + readonly networkId: string; + readonly volumeName: string; + readonly generationId?: unknown; + readonly images: readonly { + readonly id: string; + readonly reference: string; + }[]; +}; +function imageQueryAsHash(opts: BuildReadScope): readonly string[] | null { + const prefix = [ + "compose", + "--project-name", + opts.project, + "--project-directory", + join(opts.projectRoot, ".hack"), + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + ]; + if ( + opts.args.length !== prefix.length + 5 || + JSON.stringify(opts.args.slice(0, prefix.length)) !== + JSON.stringify(prefix) || + JSON.stringify(opts.args.slice(-3, -1)) !== + JSON.stringify(["--no-env-resolution", "--images"]) || + opts.args.at(-4) !== "config" || + !["db", "worker"].includes(opts.args.at(-1) ?? "") + ) { + return null; + } + return [...opts.args.slice(0, -2), "--hash", "*"]; +} +/** Closed read forwarding extends existing public metadata formats only with exact build-image facts. */ +export function retainedBuildFixtureReadAllowed(opts: BuildReadScope): boolean { + if (adoptionDependencyReadAllowed(opts)) { + return true; + } + const imageQuery = imageQueryAsHash(opts); + if (imageQuery) { + return adoptionDependencyReadAllowed({ ...opts, args: imageQuery }); + } + const { args } = opts; + if (args.length !== 5 || args[1] !== "inspect" || args[2] !== "--format") { + return false; + } + if (args[0] === "container") { + return ( + args[3] === CONTAINER_IMAGE_FORMAT && + opts.containerIds.includes(args[4] ?? "") && + opts.containerIds.every((id) => ID.test(id)) + ); + } + return ( + args[0] === "image" && + args[3] === IMAGE_BIRTH_FORMAT && + opts.images.some( + ({ id, reference }) => + IMAGE.test(id) && [id, reference].includes(args[4] ?? "") + ) + ); +} +/** First staged images query uses the same strict file/store/receipt check as the canonical staged hash query. */ +export async function retainedBuildFixtureStagedReadAllowed(opts: { + readonly scope: BuildReadScope; + readonly first: AdoptionDependencyFirstPrepare; +}) { + const args = imageQueryAsHash(opts.scope) ?? opts.scope.args; + return await adoptionDependencyStagedReadAllowed({ + args, + project: opts.scope.project, + first: opts.first, + }); +} +/** No generic engine passthrough: the saved journal, complete IDs and current daemon precede each effect. */ +export function retainedBuildFixtureMutationAllowed(opts: { + readonly args: readonly string[]; + readonly receipt: unknown; + readonly ids: readonly string[]; + readonly services: readonly string[]; +}) { + const { args, receipt, ids, services } = opts; + if ( + !( + args.length === 3 && + args[0] === "container" && + ["start", "stop"].includes(args[1] ?? "") && + ids.length === 2 && + new Set(ids).size === 2 && + ids.every((id) => ID.test(id)) && + ids.includes(args[2] ?? "") && + services.length === 2 && + JSON.stringify([...services].sort()) === + JSON.stringify(["db", "worker"]) && + isRecord(receipt) && + receipt.adoption_receipt_version === 9 && + isRecord(receipt.pendingOperation) && + receipt.pendingOperation.operation === args[1] && + Array.isArray(receipt.pendingOperation.services) && + JSON.stringify([...receipt.pendingOperation.services].sort()) === + JSON.stringify([...services].sort()) && + isRecord(receipt.pendingOperation.generation) && + typeof receipt.pendingOperation.generation.id === "string" && + TOKEN.test(receipt.pendingOperation.generation.id) + ) + ) { + return false; + } + const selection = + args[1] === "stop" && receipt.publication === null + ? receipt.prepared + : isRecord(receipt.publication) && receipt.publication.phase === "active" + ? receipt.publication.generation + : null; + return ( + isRecord(selection) && + JSON.stringify(selection) === + JSON.stringify(receipt.pendingOperation.generation) + ); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index b158b91a4..5d79f5e86 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -25,6 +25,21 @@ import { type Scenario, type ScenarioContext, } from "../harness.ts"; +import { + assertRetainedBuildFixtureCopy, + assertRetainedFixtureImageUnchanged, + prepareRetainedBuildFixtureSources, + RETAINED_BUILD_BASE_TAG, + RETAINED_BUILD_COPY_ORACLE, + RETAINED_BUILD_IMAGE_FORMAT, + RETAINED_BUILD_IMAGE_OWNER, + type RetainedBuildFixtureMode, + retainedBuildFixtureDefinition, + retainedBuildFixtureImage, + retainedBuildFixtureMarker, + retainedBuildFixtureSourceSnapshot, + type RetainedFixtureImage, +} from "./native-compose-adoption-build-inputs.ts"; import { adoptionDependencyHealthcheck, assertAdoptionDependencyControl, @@ -97,6 +112,7 @@ type Instance = { readonly typedLocal?: true; readonly ownedNetwork?: true; readonly dependency?: "service_started" | "service_healthy"; + readonly basicBuild?: RetainedBuildFixtureMode; }; type Observation = { readonly id: string; @@ -465,8 +481,9 @@ async function writeLegacy(instance: Instance, image: string) { name: instance.name, services: { db: { - image, - pull_policy: "never", + ...(instance.basicBuild + ? { build: retainedBuildFixtureDefinition(instance.basicBuild) } + : { image, pull_policy: "never" }), environment: { POSTGRES_DB: "fixture", POSTGRES_HOST_AUTH_METHOD: "trust", @@ -556,6 +573,7 @@ async function prepareFixtureInputs( readonly stringArgv?: boolean; readonly ownedNetwork?: boolean; readonly dependencies?: boolean; + readonly basicBuild?: boolean; } = {} ) { const { @@ -564,7 +582,14 @@ async function prepareFixtureInputs( stringArgv = false, ownedNetwork = false, dependencies = false, + basicBuild = false, } = options; + if ( + basicBuild && + (generated || typedLocal || stringArgv || ownedNetwork || dependencies) + ) { + refused(); + } const firstFeatures = linkedFixtureFeatures({ ownedNetwork, dependencies, @@ -601,6 +626,7 @@ async function prepareFixtureInputs( ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), ...(typedLocal ? { typedLocal: true as const } : {}), ...(ownedNetwork ? { ownedNetwork: true as const } : {}), + ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), }; if ((await probe(["info", "--format", "{{.OSType}}"])) !== "linux") { refused(); @@ -619,6 +645,12 @@ async function prepareFixtureInputs( refused(); } await writeLegacy(primary, image); + if (primary.basicBuild) { + await prepareRetainedBuildFixtureSources({ + ...primary, + mode: primary.basicBuild, + }); + } await commitAll({ root: primary.root, message: "fixture: canonical legacy source", @@ -631,6 +663,7 @@ async function prepareFixtureInputs( ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), ...(typedLocal ? { typedLocal: true as const } : {}), ...firstFeatures, + ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), }; const second: Instance = { root: await addLinkedWorktree({ fixture, branch: "adoption-beta" }), @@ -640,9 +673,16 @@ async function prepareFixtureInputs( ...(stringArgv ? { argvMode: "string-cleared" as const } : {}), ...(typedLocal ? { typedLocal: true as const } : {}), ...secondFeatures, + ...(basicBuild ? { basicBuild: "hack-default" as const } : {}), }; for (const instance of [first, second]) { await writeLegacy(instance, image); + if (instance.basicBuild) { + await prepareRetainedBuildFixtureSources({ + ...instance, + mode: instance.basicBuild, + }); + } await commitAll({ root: instance.root, message: "fixture: distinct original identity", @@ -673,14 +713,26 @@ async function prepareFixtureInputs( first, second, probe, + baseImage: image, + originalImageIds: basicBuild ? await fixtureImageInventory(probe) : [], }; } function createFixtureRuntime( opts: Awaited> ) { - const { ctx, engine, engineId, fixtureRoot, primary, first, second, probe } = - opts; + const { + ctx, + engine, + engineId, + fixtureRoot, + primary, + first, + second, + probe, + baseImage, + originalImageIds, + } = opts; const env = { HACK_RUNTIME_BACKEND: "compose", CI: "", @@ -740,6 +792,9 @@ function createFixtureRuntime( const anchors = new Map(); const managedAnchors = new Map(); const localAnchors = new Map(); + const buildSourceAnchors = new Map(); + const builtImages = new Map(); + const buildImageAnchors = new Map(); const effect = async (args: readonly string[]) => { await requirePreparedEngine({ engineId, probe }); return successful( @@ -904,6 +959,41 @@ function createFixtureRuntime( await assertAliasSql(instance); await checkWorkerArgv(instance); await checkHealthcheck(instance); + if (instance.basicBuild) { + await assertFixtureBuildImages({ + probe, + builtImages, + originalImageIds, + instance, + }); + if ( + (await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + })) !== buildSourceAnchors.get(instance) || + (await fixtureRuntimeImages({ + probe, + instance, + containers: baseline.resources.container, + builtImages, + baseImage, + })) !== buildImageAnchors.get(instance) + ) { + refused(); + } + assertRetainedBuildFixtureCopy({ + mode: instance.basicBuild, + text: await probe([ + "container", + "exec", + container(instance, "db"), + "/bin/sh", + "-c", + RETAINED_BUILD_COPY_ORACLE, + ]), + }); + await owned(instance, "container", container(instance, "db")); + } if (instance.sourceMode === "canonical-generated") { if ( (await managedAdoptionFixtureSourceSnapshot({ primary, instance })) !== @@ -971,6 +1061,19 @@ function createFixtureRuntime( refused(); } } + if (instance.basicBuild) { + await assertFixtureBuildImages({ ...opts, builtImages, instance }); + if ( + (await fixtureRuntimeImages({ + ...opts, + builtImages, + instance, + containers: baseline.resources.container, + })) !== buildImageAnchors.get(instance) + ) { + refused(); + } + } }; return { ctx, @@ -989,6 +1092,11 @@ function createFixtureRuntime( anchors, managedAnchors, localAnchors, + buildSourceAnchors, + buildImageAnchors, + builtImages, + baseImage, + originalImageIds, effect, container, sql, @@ -1009,6 +1117,267 @@ function refusedPreview(result: CliResult) { }); return result; } +async function fixtureImageInventory( + probe: ReturnType +) { + const ids = [ + ...new Set( + (await probe(["image", "ls", "--no-trunc", "--format", "{{.ID}}"])) + .split(/\s+/) + .filter(Boolean) + ), + ].sort(); + if (!ids.every((id) => IMAGE.test(id))) { + refused(); + } + return ids; +} +function fixtureComposePrefix( + instance: Instance, + composeFile = join(instance.root, ".hack/docker-compose.yml") +) { + return [ + "compose", + "--project-name", + instance.name, + "--project-directory", + join(instance.root, ".hack"), + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + composeFile, + ]; +} +type BuildImageContext = { + readonly probe: ReturnType; + readonly builtImages: ReadonlyMap; + readonly originalImageIds: readonly string[]; + readonly baseImage: string; +}; +async function assertFixtureBuildImages( + opts: Pick< + BuildImageContext, + "probe" | "builtImages" | "originalImageIds" + > & { readonly instance: Instance } +) { + const captured = opts.builtImages.get(opts.instance); + if (!captured) { + refused(); + } + for (const reference of [captured.reference, captured.id]) { + assertRetainedFixtureImageUnchanged({ + captured, + current: retainedBuildFixtureImage({ + value: object( + await opts.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_IMAGE_FORMAT, + reference, + ]) + ), + reference: captured.reference, + owner: opts.instance.name, + originalImageIds: opts.originalImageIds, + }), + }); + } +} +async function fixtureRuntimeImages( + opts: Pick & { + readonly instance: Instance; + readonly containers: readonly Observation[]; + } +) { + const selected = opts.builtImages.get(opts.instance); + if (!selected || opts.containers.length !== 2) { + refused(); + } + const result = []; + for (const container of opts.containers) { + const row = object( + await opts.probe([ + "container", + "inspect", + "--format", + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}', + container.id, + ]) + ); + const reference = + container.service === "db" ? selected.reference : opts.baseImage; + const id = container.service === "db" ? selected.id : opts.baseImage; + if ( + Object.keys(row).sort().join() !== "createdAt,id,image,reference" || + row.id !== container.id || + row.image !== id || + row.reference !== reference || + typeof row.createdAt !== "string" || + !CREATED.test(row.createdAt) + ) { + refused(); + } + const image = object( + await opts.probe([ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + id, + ]) + ); + if ( + Object.keys(image).sort().join() !== "createdAt,id" || + image.id !== id || + typeof image.createdAt !== "string" || + !CREATED.test(image.createdAt) + ) { + refused(); + } + result.push({ + service: container.service, + ...row, + imageCreatedAt: image.createdAt, + }); + } + return JSON.stringify( + result.sort((a, b) => String(a.service).localeCompare(String(b.service))) + ); +} +async function saveFixtureBuildRecovery(h: FixtureRuntime, pending?: unknown) { + const path = join(h.ctx.tempRoot, "retained-build-original-images.json"); + await Bun.write( + path, + JSON.stringify({ + engineId: h.engineId, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + images: [...h.builtImages.entries()].map(([instance, image]) => ({ + project: instance.name, + root: instance.root, + image, + })), + ...(pending !== undefined ? { pending } : {}), + }) + ); + await chmod(path, 0o600); +} +async function bootstrapFixtureBuildImage( + h: FixtureRuntime, + instance: Instance +) { + if (!instance.basicBuild || h.builtImages.has(instance)) { + refused(); + } + await requirePreparedEngine(h); + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage + ) { + refused(); + } + const reference = ( + await h.probe([ + ...fixtureComposePrefix(instance), + "config", + "--no-env-resolution", + "--images", + "db", + ]) + ).trim(); + if ( + !reference || + reference.length > 512 || + /\s/.test(reference) || + ( + await h.probe([ + "image", + "ls", + "--no-trunc", + "--filter", + `reference=${reference}`, + "--format", + "{{.ID}}", + ]) + ).trim() || + ( + await h.probe([ + "image", + "ls", + "--no-trunc", + "--filter", + `label=${RETAINED_BUILD_IMAGE_OWNER}=${instance.name}`, + "--format", + "{{.ID}}", + ]) + ).trim() + ) { + refused(); + } + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "before-build", + }); + await requirePreparedEngine(h); + const built = await runCommand({ + argv: [h.engine, ...fixtureComposePrefix(instance), "build", "db"], + cwd: h.fixtureRoot, + timeoutMs: TIMEOUT, + }); + const observation = object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_IMAGE_FORMAT, + reference, + ]) + ); + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "image-observed", + observation, + }); + const image = retainedBuildFixtureImage({ + value: observation, + reference, + owner: instance.name, + originalImageIds: h.originalImageIds, + }); + h.builtImages.set(instance, image); + await saveFixtureBuildRecovery(h); + successful(built); + await assertFixtureBuildImages({ ...h, instance }); + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage || + (await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + })) !== h.buildSourceAnchors.get(instance) + ) { + refused(); + } +} async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { const { @@ -1024,6 +1393,7 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { managedAnchors, localAnchors, primary, + buildSourceAnchors, } = h; for (const kind of ["container", "network", "volume"] as const) { @@ -1055,6 +1425,16 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { } } const originalSource = await source(instance); + if (instance.basicBuild) { + buildSourceAnchors.set( + instance, + await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + }) + ); + await bootstrapFixtureBuildImage(h, instance); + } if (instance.sourceMode === "canonical-generated") { managedAnchors.set( instance, @@ -1083,6 +1463,7 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { "--detach", "--pull", "never", + ...(instance.basicBuild ? ["--no-build"] : []), ], cwd: fixtureRoot, timeoutMs: TIMEOUT, @@ -1100,6 +1481,16 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { ) { refused(); } + if (instance.basicBuild) { + h.buildImageAnchors.set( + instance, + await fixtureRuntimeImages({ + ...h, + instance, + containers: captured.container, + }) + ); + } await waitReady(instance); await waitForAdoptionFixtureSql({ read: async () => { @@ -1578,9 +1969,10 @@ async function foreignCanaryRefusal( } async function interruptFirstStop(h: FixtureRuntime) { const { ctx, engine, first, container, cli } = h; - const firstPrepare = first.dependency - ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) - : undefined; + const firstPrepare = + first.dependency || first.basicBuild + ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) + : undefined; const shimRoot = join(ctx.tempRoot, "partial-stop-shim"); await mkdir(shimRoot, { mode: 0o700 }); const receipt = join( @@ -1591,13 +1983,15 @@ async function interruptFirstStop(h: FixtureRuntime) { worker = container(first, "worker"); const control = join(shimRoot, "control-hit"); const generatedVersion = first.typedLocal ? 4 : 3; - const receiptVersion = first.ownedNetwork - ? 6 - : first.dependency - ? 5 - : first.sourceMode - ? generatedVersion - : 2; + const receiptVersion = first.basicBuild + ? 9 + : first.ownedNetwork + ? 6 + : first.dependency + ? 5 + : first.sourceMode + ? generatedVersion + : 2; const shim = join(shimRoot, "docker"); await Bun.write( shim, @@ -1605,15 +1999,17 @@ async function interruptFirstStop(h: FixtureRuntime) { const args = process.argv.slice(2); const engine = ${JSON.stringify(engine)}; if(args[0]==="container" && args[1]==="stop") { - if(${first.dependency ? `args.length!==3 || args[2]!==${JSON.stringify(worker)}` : `args.length!==4 || !args.includes(${JSON.stringify(db)}) || !args.includes(${JSON.stringify(worker)})`}) process.exit(99); + if(${first.dependency || first.basicBuild ? `args.length!==3 || args[2]!==${JSON.stringify(worker)}` : `args.length!==4 || !args.includes(${JSON.stringify(db)}) || !args.includes(${JSON.stringify(worker)})`}) process.exit(99); const state=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); if(state.adoption_receipt_version!==${receiptVersion} || state.pendingOperation?.operation!=="stop") process.exit(98); - ${first.dependency ? dependencyEngineCheck(h) : ""} - const child=Bun.spawn([engine,"container","stop",${JSON.stringify(first.dependency ? worker : db)}],{stdin:"ignore",stdout:"ignore",stderr:"ignore"}); + ${first.dependency || first.basicBuild ? dependencyEngineCheck(h) : ""} + ${first.basicBuild ? buildMutationGuard(h, first, receipt) : ""} + const child=Bun.spawn([engine,"container","stop",${JSON.stringify(first.dependency || first.basicBuild ? worker : db)}],{stdin:"ignore",stdout:"ignore",stderr:"ignore"}); if(await child.exited!==0) process.exit(97); await Bun.write(${JSON.stringify(control)},"journal-before-partial-stop");process.exit(71); } ${first.dependency ? dependencyReadGuard(h, first, receipt, firstPrepare) : ""} +${first.basicBuild ? buildReadGuard(h, first, receipt, firstPrepare) : ""} const child=Bun.spawn([engine,...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});process.exit(await child.exited); ` ); @@ -1621,7 +2017,7 @@ const child=Bun.spawn([engine,...args],{stdin:"inherit",stdout:"inherit",stderr: const partial = await cli(first, ["config", "adopt", "--stop", "--json"], { PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, }); - if (first.dependency) { + if (first.dependency || first.basicBuild) { assertAdoptionDependencyControl({ stage: "prepared-stop", exitCode: partial.exitCode, @@ -1705,6 +2101,122 @@ try { if(!allowed){console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);} }catch{console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);}`; } +function fixtureBuildScope(h: FixtureRuntime, instance: Instance) { + const anchor = h.anchors.get(instance); + const built = h.builtImages.get(instance); + if ( + !anchor || + !built || + anchor.resources.container.length !== 2 || + anchor.resources.network.length !== 1 || + anchor.resources.volume.length !== 1 + ) { + refused(); + } + return { + projectRoot: instance.root, + project: instance.name, + containerIds: anchor.resources.container.map((row) => row.id), + networkId: anchor.resources.network[0]?.id, + volumeName: anchor.resources.volume[0]?.id, + images: [ + { id: built.id, reference: built.reference }, + { id: h.baseImage, reference: h.baseImage }, + ], + }; +} +function buildMutationGuard( + h: FixtureRuntime, + instance: Instance, + receipt: string +) { + const helper = fileURLToPath( + new URL("./native-compose-adoption-build-inputs.ts", import.meta.url) + ); + return `const {retainedBuildFixtureMutationAllowed}=await import(${JSON.stringify(helper)}); +const mutationReceipt=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); +if(!retainedBuildFixtureMutationAllowed({args,receipt:mutationReceipt,ids:${JSON.stringify(fixtureBuildScope(h, instance).containerIds)},services:['db','worker']})) {console.error('retained-build-refused stage=mutation-admission code=94');process.exit(94);}`; +} +function buildReadGuard( + h: FixtureRuntime, + instance: Instance, + receipt: string, + firstPrepare?: AdoptionDependencyFirstPrepare +) { + const helper = fileURLToPath( + new URL("./native-compose-adoption-build-inputs.ts", import.meta.url) + ); + return `import {retainedBuildFixtureReadAllowed,retainedBuildFixtureStagedReadAllowed} from ${JSON.stringify(helper)}; +try { + const savedFile=Bun.file(${JSON.stringify(receipt)}); + const saved=await savedFile.exists()?JSON.parse(await savedFile.text()):null; + const generationId=saved?.prepared?.id ?? saved?.publication?.generation?.id; + const scope={...${JSON.stringify(fixtureBuildScope(h, instance))},args,generationId}; + const allowed=retainedBuildFixtureReadAllowed(scope)${firstPrepare ? ` || await retainedBuildFixtureStagedReadAllowed({scope,first:${JSON.stringify(firstPrepare)}})` : ""}; + if(!allowed){console.error('retained-build-refused stage=read-admission code=93');process.exit(93);} +}catch{console.error('retained-build-refused stage=read-admission code=93');process.exit(93);}`; +} +async function buildFixtureCli( + h: FixtureRuntime, + instance: Instance, + args: readonly string[], + driftAfterStart = false +) { + if (!instance.basicBuild) { + refused(); + } + const receipt = join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const firstPrepare = + args[0] === "config" && + args[1] === "adopt" && + !(await Bun.file(receipt).exists()) + ? await captureAdoptionDependencyFirstPrepare({ + projectRoot: instance.root, + }) + : undefined; + const shimRoot = join( + h.ctx.tempRoot, + `retained-build-${instance.name}-${crypto.randomUUID()}` + ); + await mkdir(shimRoot, { mode: 0o700 }); + const control = join(shimRoot, "control-hit"); + const shim = join(shimRoot, "docker"); + await Bun.write( + shim, + `#!${process.execPath} +const args=process.argv.slice(2); const engine=${JSON.stringify(h.engine)}; +if(args[0]==='container' && ['start','stop'].includes(args[1])) { + ${buildMutationGuard(h, instance, receipt)} + ${dependencyEngineCheck(h)} + const child=Bun.spawn([engine,...args],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); + const code=await child.exited; + ${driftAfterStart ? `if(code===0 && args[1]==='start') {await Bun.write(${JSON.stringify(retainedBuildFixtureMarker(instance.root, instance.basicBuild))},${JSON.stringify("synthetic-controlled-context-drift\n")});await Bun.write(${JSON.stringify(control)},'original-start-before-context-drift');}` : ""} + process.exit(code); +} +${buildReadGuard(h, instance, receipt, firstPrepare)} +const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited); +` + ); + await chmod(shim, 0o700); + const result = await h.cli(instance, args, { + PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }); + if (driftAfterStart) { + assertAdoptionDependencyControl({ + stage: "pending-start", + exitCode: result.exitCode, + timedOut: result.timedOut, + control: await dependencyControlMarker( + control, + "original-start-before-context-drift" + ), + }); + } + return result; +} async function recoverFirstAndRollback(h: FixtureRuntime) { const { first, second, cli, container, waitReady, check, anchors, effect } = h; @@ -1900,6 +2412,275 @@ async function rollbackDependencyInstance( await h.waitReady(instance); await h.check(instance); } +async function fixtureRunningIds(h: FixtureRuntime, instance: Instance) { + const anchor = h.anchors.get(instance); + if (!anchor) { + refused(); + } + return JSON.stringify( + await Promise.all( + anchor.resources.container.map(async (row) => ({ + id: row.id, + running: await h.probe([ + "container", + "inspect", + "--format", + "{{.State.Running}}", + row.id, + ]), + })) + ) + ); +} +async function retainedBuildContextRecovery(h: FixtureRuntime) { + const { first, second } = h; + if (!first.basicBuild) { + refused(); + } + const path = retainedBuildFixtureMarker(first.root, first.basicBuild); + const bytes = await readFile(path); + try { + refusedPreview( + await buildFixtureCli(h, first, ["up", "--detach", "--json"], true) + ); + const receipt = object( + await Bun.file( + join( + first.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ) + ).text() + ); + if ( + !isRecord(receipt.pendingOperation) || + receipt.pendingOperation.operation !== "start" + ) { + refused(); + } + const state = await fixtureRunningIds(h, first); + refusedPreview( + await buildFixtureCli(h, first, ["down", "--recover", "--json"]) + ); + if ((await fixtureRunningIds(h, first)) !== state) { + refused(); + } + await assertFixtureBuildImages({ ...h, instance: first }); + await h.check(second); + } finally { + // The active included-context proof permits only exact bytes on the same + // inode. Prepared authored-source timestamp repair is never exercised here. + await writeFile(path, bytes); + } + successful(await buildFixtureCli(h, first, ["down", "--recover", "--json"])); + await h.assertStopped(first); + await h.check(second); + successful(await buildFixtureCli(h, first, ["up", "--detach", "--json"])); + await h.waitReady(first); + await h.check(first, false); + await h.check(second); +} +async function retainedBuildCandidateRefusal(h: FixtureRuntime) { + const path = join(h.first.root, ".hack/hack.project.json"); + const original = await readFile(path); + const candidate = object( + new TextDecoder("utf-8", { fatal: true }).decode(original) + ); + if ( + !isRecord(candidate.services) || + !isRecord(candidate.services.db) || + !isRecord(candidate.services.db.build) + ) { + refused(); + } + candidate.services.db.build.context = "unsupported-context-edit"; + const running = await fixtureRunningIds(h, h.first); + await writeFile(path, JSON.stringify(candidate)); + try { + refusedPreview( + await buildFixtureCli(h, h.first, ["up", "--detach", "--json"]) + ); + if ((await fixtureRunningIds(h, h.first)) !== running) { + refused(); + } + await assertFixtureBuildImages({ ...h, instance: h.first }); + await h.check(h.second); + } finally { + await writeFile(path, original); + } + await h.check(h.first, false); +} +async function rollbackRetainedBuildFixture( + h: FixtureRuntime, + instance: Instance +) { + successful(await buildFixtureCli(h, instance, ["down", "--json"])); + await h.assertStopped(instance); + successful( + await buildFixtureCli(h, instance, [ + "config", + "adopt", + "--rollback", + "--json", + ]) + ); + if ((await source(instance)) !== h.anchors.get(instance)?.source) { + refused(); + } + await h.effect([ + "container", + "start", + h.container(instance, "db"), + h.container(instance, "worker"), + ]); + await h.waitReady(instance); + await h.check(instance); +} +async function cleanupRetainedBuildFixtureImages(h: FixtureRuntime) { + const pending = new Set([...h.builtImages.values()].map((row) => row.id)); + for (const [instance, image] of h.builtImages) { + await requirePreparedEngine(h); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify([...h.originalImageIds, ...pending].sort()) + ) { + refused(); + } + await assertFixtureBuildImages({ ...h, instance }); + if ( + ( + await h.probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--filter", + `ancestor=${image.id}`, + "--format", + "{{.ID}}", + ]) + ).trim() || + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage + ) { + refused(); + } + await requirePreparedEngine(h); + await h.effect(["image", "rm", "--no-prune", image.id]); + pending.delete(image.id); + await requirePreparedEngine(h); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify([...h.originalImageIds, ...pending].sort()) + ) { + refused(); + } + } + await requirePreparedEngine(h); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify(h.originalImageIds) + ) { + refused(); + } +} +/** Basic builds qualify separately from preview; no builder is reachable after bootstrap. */ +export const nativeComposeAdoptionBuildWorktreesScenario: Scenario = { + name: "native-compose-adoption-build-worktrees", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "root/specific and default .hack COPY projections retain two existing SQL volumes, images and original IDs through recovery and rollback", + run: async (ctx) => { + const h = createFixtureRuntime( + await prepareFixtureInputs(ctx, { basicBuild: true }) + ); + await runWithFixtureCleanup({ + run: async () => { + for (const instance of [h.first, h.second]) { + await bootstrapOriginal(h, instance); + const preview = successful( + await h.cli(instance, [ + "config", + "adopt", + "--dry-run", + "--stop", + "--json", + ]) + ); + if (object(preview.stdout).complete !== true) { + refused(); + } + await h.assertNoState(instance); + await h.check(instance); + } + await interruptFirstStop(h); + successful( + await buildFixtureCli(h, h.first, [ + "config", + "adopt", + "--recover", + "--stop", + "--json", + ]) + ); + await h.assertStopped(h.first); + await h.check(h.second); + refusedPreview( + await buildFixtureCli(h, h.first, ["up", "db", "--detach", "--json"]) + ); + await h.assertStopped(h.first); + await retainedBuildContextRecovery(h); + await retainedBuildCandidateRefusal(h); + refusedPreview( + await buildFixtureCli(h, h.first, ["run", "db", "--", "true"]) + ); + await h.check(h.first, false); + await rollbackRetainedBuildFixture(h, h.first); + await h.check(h.second); + successful( + await buildFixtureCli(h, h.second, [ + "config", + "adopt", + "--stop", + "--json", + ]) + ); + await h.assertStopped(h.second); + await h.check(h.first); + successful( + await buildFixtureCli(h, h.second, ["up", "--detach", "--json"]) + ); + await h.waitReady(h.second); + await h.check(h.second, false); + await h.check(h.first); + await rollbackRetainedBuildFixture(h, h.second); + await h.check(h.first); + ctx.log( + "basic build COPY/ignore/target parity, original image/container/volume births and SQL, partial-stop/source repair and isolated rollback verified; historical image provenance and cache reclamation unqualified" + ); + }, + cleanup: async () => { + await cleanupOwnedAdoptionFixture({ + ...h, + instances: [h.first, h.second], + }); + await cleanupRetainedBuildFixtureImages(h); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Retained build exact-owned cleanup failed; original resource and image evidence retained" + ), + }); + }, +}; /** Explicit selector keeps the new v5 dependency acceptance independent of all previously qualified worktree cases. */ export const nativeComposeAdoptionDependencyWorktreesScenario: Scenario = { diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts new file mode 100644 index 000000000..488333bb2 --- /dev/null +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -0,0 +1,404 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { acquireLegacyComposeBuildSource } from "../src/lib/native-compose-adoption-build.ts"; +import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; +import { + assertRetainedBuildFixtureCopy, + assertRetainedFixtureImageUnchanged, + prepareRetainedBuildFixtureSources, + retainedBuildFixtureCopiedFiles, + retainedBuildFixtureDefinition, + retainedBuildFixtureImage, + retainedBuildFixtureMutationAllowed, + retainedBuildFixtureReadAllowed, + retainedBuildFixtureSourceSnapshot, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { nativeComposeAdoptionBuildWorktreesScenario } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; +import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; +import { retainedBuildFixture } from "./helpers/retained-build-adoption.ts"; + +const CANARY = "synthetic-private-builder-canary"; +const id = "a".repeat(64), + other = "b".repeat(64), + image = `sha256:${"c".repeat(64)}`; +const birth = "2026-10-08T20:00:00.123456789Z"; +const scope = { + projectRoot: "/owned/root", + project: "fixture", + containerIds: [id, other], + networkId: "d".repeat(64), + volumeName: "fixture_data", + images: [{ id: image, reference: "fixture-db" }], +}; +const prefix = [ + "compose", + "--project-name", + "fixture", + "--project-directory", + "/owned/root/.hack", + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", +]; +const anchor = { + id: "e".repeat(32), + manifest: { dev: 1, ino: 2, hash: "f".repeat(64) }, +}; +const receipt = { + adoption_receipt_version: 9, + prepared: anchor, + publication: null, + pendingOperation: { + generation: anchor, + operation: "stop", + services: ["db", "worker"], + }, +}; +const imageRow = { + id: image, + created: birth, + owner: "fixture", + stage: "retained", + tags: ["fixture-db:latest"], + digests: null, +}; + +test("retained build acceptance is explicit and failure-preserving", () => { + expect(nativeComposeAdoptionBuildWorktreesScenario).toMatchObject({ + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + }); +}); +for (const mode of ["root-specific", "hack-default"] as const) { + test(`real ${mode} fixture authoring issues a qualified current source with explicit COPY oracle`, async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-fixture-source-")) + ); + try { + await mkdir(join(root, ".git")); + await mkdir(join(root, ".hack")); + await prepareRetainedBuildFixtureSources({ root, name: "fixture", mode }); + const configText = '{"name":"fixture"}'; + const composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: retainedBuildFixtureDefinition(mode), + volumes: ["data:/data"], + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/hack.config.json"), configText); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + }); + if (!inputs.ok) { + throw new Error( + "Synthetic fixture source acquisition refused; values omitted." + ); + } + const captured = await acquireLegacyComposeBuildSource({ + source: inputs, + }); + expect(captured.candidate).toMatchObject({ + services: { + db: { + build: + mode === "root-specific" + ? { + context: ".", + dockerfile: ".hack/toolchain/Dockerfile", + target: "retained", + } + : { context: ".hack" }, + }, + }, + }); + const context = captured.proof.contexts[0]; + expect(context).toBeDefined(); + const files = context?.nodes + .filter((node) => node.kind === "file") + .map((node) => ({ + path: node.path, + hash: node.kind === "file" ? node.hash : "", + })); + expect(files).toEqual(retainedBuildFixtureCopiedFiles(mode)); + const copy = retainedBuildFixtureCopiedFiles(mode) + .map((row) => `${row.path} ${row.hash}`) + .join("\n"); + expect(() => + assertRetainedBuildFixtureCopy({ mode, text: copy }) + ).not.toThrow(); + expect(() => + assertRetainedBuildFixtureCopy({ + mode, + text: `${copy}\n.hack/.internal/private ${"a".repeat(64)}`, + }) + ).toThrow("values omitted"); + expect(() => assertRetainedBuildFixtureCopy({ mode, text: "" })).toThrow( + "values omitted" + ); + const source = await retainedBuildFixtureSourceSnapshot({ root, mode }); + await captured.assertFresh(); + expect(await retainedBuildFixtureSourceSnapshot({ root, mode })).toBe( + source + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} +test("image fixture capture requires a new sole-tag selected-stage image and pins birth", () => { + const captured = retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }); + expect(captured).toMatchObject({ + id: image, + created: birth, + tag: "fixture-db:latest", + }); + expect(() => + assertRetainedFixtureImageUnchanged({ + captured, + current: { ...captured, created: "2026-10-08T21:00:00Z" }, + }) + ).toThrow("values omitted"); + for (const row of [ + { ...imageRow, owner: CANARY }, + { ...imageRow, stage: "wrong" }, + { ...imageRow, tags: ["fixture-db:latest", "foreign:latest"] }, + { ...imageRow, digests: [CANARY] }, + { ...imageRow, created: CANARY }, + ]) { + expect(() => + retainedBuildFixtureImage({ + value: row, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }) + ).toThrow("values omitted"); + } + expect(() => + retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [image], + }) + ).toThrow("values omitted"); +}); +test("build read shim admits exact original/published images queries and captured image facts only", () => { + for (const file of [ + "/owned/root/.hack/docker-compose.yml", + `/owned/root/.hack/.internal/legacy-compose-adoption-v1/generations/${anchor.id}/legacy-compose.yml`, + ]) { + for (const service of ["db", "worker"]) { + expect( + retainedBuildFixtureReadAllowed({ + ...scope, + generationId: anchor.id, + args: [ + ...prefix, + file, + "config", + "--no-env-resolution", + "--images", + service, + ], + }) + ).toBe(true); + } + } + expect( + retainedBuildFixtureReadAllowed({ + ...scope, + args: [ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + image, + ], + }) + ).toBe(true); + for (const args of [ + [ + ...prefix, + "/elsewhere/source.yml", + "config", + "--no-env-resolution", + "--images", + "db", + ], + [ + ...prefix, + "/owned/root/.hack/docker-compose.yml", + "config", + "--images", + "db", + ], + [...prefix, "/owned/root/.hack/docker-compose.yml", "up", "--detach"], + ["compose", "build", "db"], + ["image", "rm", image], + ["pull", "fixture-db"], + ["container", "create", "fixture-db"], + ["image", "inspect", "--format", "{{json .Config.Env}}", image], + [ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + "foreign", + ], + ]) { + expect(retainedBuildFixtureReadAllowed({ ...scope, args })).toBe(false); + } +}); +test("build effects require whole journaled original IDs and exact prepared or active generation", () => { + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "stop", other], + receipt, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(true); + const active = { + ...receipt, + publication: { phase: "active", generation: anchor }, + pendingOperation: { ...receipt.pendingOperation, operation: "start" }, + }; + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "start", id], + receipt: active, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(true); + for (const value of [ + null, + { ...receipt, adoption_receipt_version: 6 }, + { ...receipt, pendingOperation: null }, + { + ...receipt, + pendingOperation: { ...receipt.pendingOperation, services: ["db"] }, + }, + { ...receipt, prepared: { ...anchor, id: "1".repeat(32) } }, + ]) { + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "stop", id], + receipt: value, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(false); + } + for (const args of [ + ["container", "stop", "f".repeat(64)], + ["container", "stop", id, other], + ["container", "rm", id], + ["compose", "up", "--detach"], + ]) { + expect( + retainedBuildFixtureMutationAllowed({ + args, + receipt, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(false); + } +}); +test("first preparation image/hash queries pass at actual empty-receipt boundaries through the closed staged owner", async () => { + const h = await retainedBuildFixture(); + try { + const first = await captureAdoptionDependencyFirstPrepare({ + projectRoot: h.root, + }); + const entry = join(h.outer, "docker"), + target = join(h.outer, "docker-model"); + await rename(entry, target); + const helper = fileURLToPath( + new URL( + "./e2e/scenarios/native-compose-adoption-build-inputs.ts", + import.meta.url + ) + ); + const trace = join(h.outer, "staged-fixture-read-control"); + const receiptPath = join( + h.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const fixedScope = { + projectRoot: h.root, + project: "fixture", + containerIds: [id], + networkId: "b".repeat(64), + volumeName: "fixture_original_data", + images: [{ id: h.model.image, reference: "fixture-db" }], + }; + const script = [ + `#!${process.execPath}`, + `import {retainedBuildFixtureReadAllowed,retainedBuildFixtureStagedReadAllowed} from ${JSON.stringify(helper)};`, + `const args=process.argv.slice(2), file=Bun.file(${JSON.stringify(receiptPath)});`, + "const saved=await file.exists()?await file.json():null;", + `const scope={...${JSON.stringify(fixedScope)},args,generationId:saved?.prepared?.id};`, + `const ordinary=retainedBuildFixtureReadAllowed(scope), staged=!ordinary && await retainedBuildFixtureStagedReadAllowed({scope,first:${JSON.stringify(first)}});`, + "if(!ordinary&&!staged)process.exit(93);", + `if(staged){const {appendFile}=await import('node:fs/promises');await appendFile(${JSON.stringify(trace)},JSON.stringify({imageQuery:args.includes('--images'),receiptPrepared:saved?.prepared!==null})+'\\n');}`, + `const child=Bun.spawn([${JSON.stringify(target)},...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);`, + ].join("\n"); + await writeFile(entry, script); + await chmod(entry, 0o700); + const store = await h.store(); + try { + const prepared = await store.prepare({ binary: h.compiler }); + expect(prepared.report.adoption_generation_version).toBe(9); + const rows: unknown[] = (await readFile(trace, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(rows).toContainEqual({ imageQuery: true, receiptPrepared: false }); + expect(rows).toContainEqual({ + imageQuery: false, + receiptPrepared: false, + }); + expect( + rows.every( + (row) => + typeof row === "object" && + row !== null && + "receiptPrepared" in row && + row.receiptPrepared === false + ) + ).toBe(true); + } finally { + await store.close(); + } + } finally { + await h.cleanup(); + } +}, 30_000); From 8ea4c9d3abc88fd5587fd8c085193b3e5848b126 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:59:46 -0400 Subject: [PATCH 12/26] test: fence retained build adoption previews --- .../native-compose-adoption-worktrees.ts | 38 +++--- ...ive-compose-adoption-build-fixture.test.ts | 109 +++++++++++++++++- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 5d79f5e86..5119d728d 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -2058,7 +2058,7 @@ async function dependencyControlMarker(path: string, expected: string) { } } -function dependencyEngineCheck(h: FixtureRuntime): string { +function dependencyEngineCheck(h: Pick): string { return `const engineCheck=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'}); const engineId=(await new Response(engineCheck.stdout).text()).trim();if(await engineCheck.exited!==0 || engineId!==${JSON.stringify(h.engineId)})process.exit(95);`; } @@ -2101,7 +2101,13 @@ try { if(!allowed){console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);} }catch{console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);}`; } -function fixtureBuildScope(h: FixtureRuntime, instance: Instance) { +type BuildFixtureTransport = Pick< + FixtureRuntime, + "engine" | "engineId" | "baseImage" | "anchors" | "builtImages" | "cli" +> & { + readonly ctx: Pick; +}; +function fixtureBuildScope(h: BuildFixtureTransport, instance: Instance) { const anchor = h.anchors.get(instance); const built = h.builtImages.get(instance); if ( @@ -2126,7 +2132,7 @@ function fixtureBuildScope(h: FixtureRuntime, instance: Instance) { }; } function buildMutationGuard( - h: FixtureRuntime, + h: BuildFixtureTransport, instance: Instance, receipt: string ) { @@ -2138,7 +2144,7 @@ const mutationReceipt=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text if(!retainedBuildFixtureMutationAllowed({args,receipt:mutationReceipt,ids:${JSON.stringify(fixtureBuildScope(h, instance).containerIds)},services:['db','worker']})) {console.error('retained-build-refused stage=mutation-admission code=94');process.exit(94);}`; } function buildReadGuard( - h: FixtureRuntime, + h: BuildFixtureTransport, instance: Instance, receipt: string, firstPrepare?: AdoptionDependencyFirstPrepare @@ -2156,8 +2162,9 @@ try { if(!allowed){console.error('retained-build-refused stage=read-admission code=93');process.exit(93);} }catch{console.error('retained-build-refused stage=read-admission code=93');process.exit(93);}`; } -async function buildFixtureCli( - h: FixtureRuntime, +/** Actual closed transport used by every post-bootstrap build9 CLI invocation, including previews. */ +export async function buildFixtureCli( + h: BuildFixtureTransport, instance: Instance, args: readonly string[], driftAfterStart = false @@ -2443,14 +2450,12 @@ async function retainedBuildContextRecovery(h: FixtureRuntime) { refusedPreview( await buildFixtureCli(h, first, ["up", "--detach", "--json"], true) ); - const receipt = object( - await Bun.file( - join( - first.root, - ".hack/.internal/legacy-compose-adoption-v1/receipt.json" - ) - ).text() + const receiptPath = join( + first.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" ); + const receiptBytes = await Bun.file(receiptPath).text(); + const receipt = object(receiptBytes); if ( !isRecord(receipt.pendingOperation) || receipt.pendingOperation.operation !== "start" @@ -2461,7 +2466,10 @@ async function retainedBuildContextRecovery(h: FixtureRuntime) { refusedPreview( await buildFixtureCli(h, first, ["down", "--recover", "--json"]) ); - if ((await fixtureRunningIds(h, first)) !== state) { + if ( + (await fixtureRunningIds(h, first)) !== state || + (await Bun.file(receiptPath).text()) !== receiptBytes + ) { refused(); } await assertFixtureBuildImages({ ...h, instance: first }); @@ -2607,7 +2615,7 @@ export const nativeComposeAdoptionBuildWorktreesScenario: Scenario = { for (const instance of [h.first, h.second]) { await bootstrapOriginal(h, instance); const preview = successful( - await h.cli(instance, [ + await buildFixtureCli(h, instance, [ "config", "adopt", "--dry-run", diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 488333bb2..1bdd1adf6 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { acquireLegacyComposeBuildSource } from "../src/lib/native-compose-adoption-build.ts"; import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; +import { runCommand } from "./e2e/harness.ts"; import { assertRetainedBuildFixtureCopy, assertRetainedFixtureImageUnchanged, @@ -25,7 +26,10 @@ import { retainedBuildFixtureReadAllowed, retainedBuildFixtureSourceSnapshot, } from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; -import { nativeComposeAdoptionBuildWorktreesScenario } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; +import { + buildFixtureCli, + nativeComposeAdoptionBuildWorktreesScenario, +} from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; import { retainedBuildFixture } from "./helpers/retained-build-adoption.ts"; @@ -165,6 +169,109 @@ for (const mode of ["root-specific", "hack-default"] as const) { } }); } +for (const mode of ["root-specific", "hack-default"] as const) { + test(`actual ${mode} preview transport refuses an injected build before forwarding`, async () => { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-preview-transport-")) + ); + const root = join(outer, "checkout"); + const engine = join(outer, "synthetic-engine"); + const forwarded = join(outer, "engine-forwarded"); + const previewArgs = ["config", "adopt", "--dry-run", "--stop", "--json"]; + const instance = { + root, + name: "fixture", + marker: "synthetic-sql-marker", + basicBuild: mode, + }; + try { + await chmod(outer, 0o700); + await mkdir(root, { mode: 0o700 }); + await mkdir(join(root, ".hack"), { mode: 0o700 }); + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify({ + services: { db: { build: retainedBuildFixtureDefinition(mode) } }, + }), + { mode: 0o600 } + ); + await writeFile( + engine, + `#!${process.execPath}\nawait Bun.write(${JSON.stringify(forwarded)},'unexpected-forwarding');process.exit(0);\n`, + { mode: 0o700 } + ); + let invocations = 0; + const cli: Parameters[0]["cli"] = async ( + selected, + args, + extra + ) => { + invocations += 1; + expect(selected).toBe(instance); + expect(args).toEqual(previewArgs); + return await runCommand({ + argv: ["docker", "compose", "build", "db"], + cwd: root, + env: extra, + timeoutMs: 5000, + }); + }; + const result = await buildFixtureCli( + { + ctx: { tempRoot: outer }, + engine, + engineId: "synthetic-daemon", + baseImage: `sha256:${"1".repeat(64)}`, + anchors: new Map([ + [ + instance, + { + source: "synthetic-original-source", + resources: { + container: [ + { id, service: "db" }, + { id: other, service: "worker" }, + ], + network: [{ id: scope.networkId }], + volume: [{ id: scope.volumeName }], + }, + }, + ], + ]), + builtImages: new Map([ + [ + instance, + retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }), + ], + ]), + cli, + }, + instance, + previewArgs + ); + expect(invocations).toBe(1); + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBe(93); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "retained-build-refused stage=read-admission code=93\n" + ); + expect(await Bun.file(forwarded).exists()).toBe(false); + expect( + await Bun.file( + join(root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json") + ).exists() + ).toBe(false); + } finally { + await rm(outer, { recursive: true, force: true }); + } + }); +} test("image fixture capture requires a new sole-tag selected-stage image and pins birth", () => { const captured = retainedBuildFixtureImage({ value: imageRow, From 611b9e86544edf835c3381ac8dde301e87edfa5f Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:10:29 -0400 Subject: [PATCH 13/26] test: simplify retained build fixture checks --- .../native-compose-adoption-build-inputs.ts | 8 +- .../native-compose-adoption-worktrees.ts | 245 +++++++++++------- ...ive-compose-adoption-build-fixture.test.ts | 2 +- 3 files changed, 166 insertions(+), 89 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts index 83ea36f81..34dd82494 100644 --- a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -157,7 +157,13 @@ export async function retainedBuildFixtureSourceSnapshot(opts: { ".hack/toolchain/run.sh", ] : [".hack/Dockerfile", ".hack/.dockerignore", ".hack/data-marker"]; - const result = []; + const result: { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly hash: string; + }[] = []; for (const path of paths) { const selected = join(opts.root, path); const info = await lstat(selected); diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 5119d728d..80a7497fc 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -34,11 +34,11 @@ import { RETAINED_BUILD_IMAGE_FORMAT, RETAINED_BUILD_IMAGE_OWNER, type RetainedBuildFixtureMode, + type RetainedFixtureImage, retainedBuildFixtureDefinition, retainedBuildFixtureImage, retainedBuildFixtureMarker, retainedBuildFixtureSourceSnapshot, - type RetainedFixtureImage, } from "./native-compose-adoption-build-inputs.ts"; import { adoptionDependencyHealthcheck, @@ -565,6 +565,26 @@ function linkedFixtureFeatures(opts: { }; } +function authoredFixtureFeatures(opts: { + readonly generated: boolean; + readonly stringArgv: boolean; + readonly typedLocal: boolean; + readonly role: "primary" | "first" | "second"; +}): Partial> { + return { + ...(opts.generated ? { sourceMode: "canonical-generated" as const } : {}), + ...(opts.stringArgv + ? { + argvMode: + opts.role === "second" + ? ("string-cleared" as const) + : ("string-entrypoint" as const), + } + : {}), + ...(opts.typedLocal ? { typedLocal: true as const } : {}), + }; +} + async function prepareFixtureInputs( ctx: ScenarioContext, options: { @@ -622,9 +642,12 @@ async function prepareFixtureInputs( root: fixture.root, name: `${fixture.name}-main`, marker: "unused-primary", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "primary", + }), ...(ownedNetwork ? { ownedNetwork: true as const } : {}), ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), }; @@ -659,9 +682,12 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-alpha" }), name: `${fixture.name}-alpha`, marker: "alpha-existing-sql-row", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "first", + }), ...firstFeatures, ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), }; @@ -669,9 +695,12 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-beta" }), name: `${fixture.name}-beta`, marker: "beta-existing-sql-row", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-cleared" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "second", + }), ...secondFeatures, ...(basicBuild ? { basicBuild: "hack-default" as const } : {}), }; @@ -943,7 +972,48 @@ function createFixtureRuntime( }); } }; - const check = async (instance: Instance, checkSource = true) => { + const checkBuild = async (instance: Instance, baseline: Snapshot) => { + if (!instance.basicBuild) { + refused(); + } + await assertFixtureBuildImages({ + probe, + builtImages, + originalImageIds, + instance, + }); + if ( + (await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + })) !== buildSourceAnchors.get(instance) || + (await fixtureRuntimeImages({ + probe, + instance, + containers: baseline.resources.container, + builtImages, + baseImage, + })) !== buildImageAnchors.get(instance) + ) { + refused(); + } + assertRetainedBuildFixtureCopy({ + mode: instance.basicBuild, + text: await probe([ + "container", + "exec", + container(instance, "db"), + "/bin/sh", + "-c", + RETAINED_BUILD_COPY_ORACLE, + ]), + }); + await owned(instance, "container", container(instance, "db")); + }; + const checkedRunningBaseline = async ( + instance: Instance, + checkSource: boolean + ) => { const baseline = anchors.get(instance); if ( !baseline || @@ -955,44 +1025,16 @@ function createFixtureRuntime( ) { refused(); } + return baseline; + }; + const check = async (instance: Instance, checkSource = true) => { + const baseline = await checkedRunningBaseline(instance, checkSource); await assertTopology(instance, true); await assertAliasSql(instance); await checkWorkerArgv(instance); await checkHealthcheck(instance); if (instance.basicBuild) { - await assertFixtureBuildImages({ - probe, - builtImages, - originalImageIds, - instance, - }); - if ( - (await retainedBuildFixtureSourceSnapshot({ - root: instance.root, - mode: instance.basicBuild, - })) !== buildSourceAnchors.get(instance) || - (await fixtureRuntimeImages({ - probe, - instance, - containers: baseline.resources.container, - builtImages, - baseImage, - })) !== buildImageAnchors.get(instance) - ) { - refused(); - } - assertRetainedBuildFixtureCopy({ - mode: instance.basicBuild, - text: await probe([ - "container", - "exec", - container(instance, "db"), - "/bin/sh", - "-c", - RETAINED_BUILD_COPY_ORACLE, - ]), - }); - await owned(instance, "container", container(instance, "db")); + await checkBuild(instance, baseline); } if (instance.sourceMode === "canonical-generated") { if ( @@ -1196,7 +1238,7 @@ async function fixtureRuntimeImages( if (!selected || opts.containers.length !== 2) { refused(); } - const result = []; + const result: Record[] = []; for (const container of opts.containers) { const row = object( await opts.probe([ @@ -1379,23 +1421,11 @@ async function bootstrapFixtureBuildImage( } } -async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { - const { - engine, - fixtureRoot, - probe, - list, - resources, - anchors, - sql, - waitReady, - check, - managedAnchors, - localAnchors, - primary, - buildSourceAnchors, - } = h; - +async function requireFixtureNamesAbsent( + h: FixtureRuntime, + instance: Instance +) { + const { list, probe } = h; for (const kind of ["container", "network", "volume"] as const) { if ((await list(instance, kind)).length) { refused(); @@ -1424,6 +1454,24 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { refused(); } } +} +async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { + const { + engine, + fixtureRoot, + probe, + resources, + anchors, + sql, + waitReady, + check, + managedAnchors, + localAnchors, + primary, + buildSourceAnchors, + } = h; + + await requireFixtureNamesAbsent(h, instance); const originalSource = await source(instance); if (instance.basicBuild) { buildSourceAnchors.set( @@ -1967,23 +2015,9 @@ async function foreignCanaryRefusal( } await h.assertNoState(first); } -async function interruptFirstStop(h: FixtureRuntime) { - const { ctx, engine, first, container, cli } = h; - const firstPrepare = - first.dependency || first.basicBuild - ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) - : undefined; - const shimRoot = join(ctx.tempRoot, "partial-stop-shim"); - await mkdir(shimRoot, { mode: 0o700 }); - const receipt = join( - first.root, - ".hack/.internal/legacy-compose-adoption-v1/receipt.json" - ); - const db = container(first, "db"), - worker = container(first, "worker"); - const control = join(shimRoot, "control-hit"); +function partialStopReceiptVersion(first: Instance): number { const generatedVersion = first.typedLocal ? 4 : 3; - const receiptVersion = first.basicBuild + return first.basicBuild ? 9 : first.ownedNetwork ? 6 @@ -1992,10 +2026,20 @@ async function interruptFirstStop(h: FixtureRuntime) { : first.sourceMode ? generatedVersion : 2; - const shim = join(shimRoot, "docker"); - await Bun.write( - shim, - `#!${process.execPath} +} +function partialStopDockerScript(opts: { + readonly h: FixtureRuntime; + readonly engine: string; + readonly first: Instance; + readonly receipt: string; + readonly db: string; + readonly worker: string; + readonly control: string; + readonly firstPrepare?: AdoptionDependencyFirstPrepare; +}): string { + const { h, engine, first, receipt, db, worker, control, firstPrepare } = opts; + const receiptVersion = partialStopReceiptVersion(first); + return `#!${process.execPath} const args = process.argv.slice(2); const engine = ${JSON.stringify(engine)}; if(args[0]==="container" && args[1]==="stop") { @@ -2011,7 +2055,36 @@ if(args[0]==="container" && args[1]==="stop") { ${first.dependency ? dependencyReadGuard(h, first, receipt, firstPrepare) : ""} ${first.basicBuild ? buildReadGuard(h, first, receipt, firstPrepare) : ""} const child=Bun.spawn([engine,...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});process.exit(await child.exited); -` +`; +} +async function interruptFirstStop(h: FixtureRuntime) { + const { ctx, engine, first, container, cli } = h; + const firstPrepare = + first.dependency || first.basicBuild + ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) + : undefined; + const shimRoot = join(ctx.tempRoot, "partial-stop-shim"); + await mkdir(shimRoot, { mode: 0o700 }); + const receipt = join( + first.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const db = container(first, "db"), + worker = container(first, "worker"); + const control = join(shimRoot, "control-hit"); + const shim = join(shimRoot, "docker"); + await Bun.write( + shim, + partialStopDockerScript({ + h, + engine, + first, + receipt, + db, + worker, + control, + firstPrepare, + }) ); await chmod(shim, 0o700); const partial = await cli(first, ["config", "adopt", "--stop", "--json"], { @@ -2111,8 +2184,7 @@ function fixtureBuildScope(h: BuildFixtureTransport, instance: Instance) { const anchor = h.anchors.get(instance); const built = h.builtImages.get(instance); if ( - !anchor || - !built || + !(anchor && built) || anchor.resources.container.length !== 2 || anchor.resources.network.length !== 1 || anchor.resources.volume.length !== 1 @@ -2494,8 +2566,7 @@ async function retainedBuildCandidateRefusal(h: FixtureRuntime) { new TextDecoder("utf-8", { fatal: true }).decode(original) ); if ( - !isRecord(candidate.services) || - !isRecord(candidate.services.db) || + !(isRecord(candidate.services) && isRecord(candidate.services.db)) || !isRecord(candidate.services.db.build) ) { refused(); diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 1bdd1adf6..6000f8200 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -26,11 +26,11 @@ import { retainedBuildFixtureReadAllowed, retainedBuildFixtureSourceSnapshot, } from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; import { buildFixtureCli, nativeComposeAdoptionBuildWorktreesScenario, } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; -import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; import { retainedBuildFixture } from "./helpers/retained-build-adoption.ts"; const CANARY = "synthetic-private-builder-canary"; From 3811d051eaad1c3c53e7ac8b354dbfa4d844a35e Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:13:07 -0400 Subject: [PATCH 14/26] test: finish retained build fixture lint cleanup --- tests/e2e/scenarios/native-compose-adoption-worktrees.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 80a7497fc..12557a48a 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -1459,7 +1459,6 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { const { engine, fixtureRoot, - probe, resources, anchors, sql, @@ -2566,8 +2565,11 @@ async function retainedBuildCandidateRefusal(h: FixtureRuntime) { new TextDecoder("utf-8", { fatal: true }).decode(original) ); if ( - !(isRecord(candidate.services) && isRecord(candidate.services.db)) || - !isRecord(candidate.services.db.build) + !( + isRecord(candidate.services) && + isRecord(candidate.services.db) && + isRecord(candidate.services.db.build) + ) ) { refused(); } From a94fd58773e3e59f48001fcca195d23eb115242c Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:22:17 -0400 Subject: [PATCH 15/26] fix: retain owned build image graph in adoption fixture --- docs/reference/native-compose-adoption.md | 12 +- .../native-compose-adoption-build-inputs.ts | 166 +++++++++- .../native-compose-adoption-worktrees.ts | 283 +++++++++++++++--- ...ive-compose-adoption-build-fixture.test.ts | 262 ++++++++++++++++ 4 files changed, 670 insertions(+), 53 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 96dbde0bd..d47ce566f 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -183,8 +183,16 @@ starts/stops. Format switch and saved consumption cannot reach a builder. Its source and candidate drift controls retain pending ownership, preserve the other worktree's SQL row, and restore both original configurations through rollback. Exact fixture image removal requires its captured new ID/birth, sole tag, fixture -label, unchanged daemon and no remaining container references. Builder cache is -retained; cache reclamation and historical image-from-source provenance remain +label, unchanged daemon and no remaining container references. A local final +image may expose one digest for that exact repository and captured image ID; +other digest aliases refuse. The fixture privately journals the exact new +image graph after each bootstrap build. Only fixture-labelled, untagged parents +on its complete chain to the captured original base qualify for disposal, in +child-before-parent order with nonforce `image rm --no-prune`. A builder exposing +no parent qualifies only its single final object. Unexplained new images, foreign +labels or references retain the failed fixture. Full original image inventory +and tags must be restored; inventory bounds are not relaxed for new objects. +General builder cache is retained; cache reclamation and historical image-from-source provenance remain unqualified. Fixture source/model checks alone do not establish live builder or data-preservation acceptance. diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts index 34dd82494..4b6707bdf 100644 --- a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -11,6 +11,7 @@ export type RetainedBuildFixtureMode = "root-specific" | "hack-default"; export const RETAINED_BUILD_BASE_TAG = "postgres:17.6-alpine"; export const RETAINED_BUILD_IMAGE_OWNER = "hack.e2e.retained-build.owner"; export const RETAINED_BUILD_IMAGE_FORMAT = `{"id":{{json .Id}},"created":{{json .Created}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}}}`; +export const RETAINED_BUILD_OBJECT_FORMAT = `{"id":{{json .Id}},"parent":{{json .Parent}},"created":{{json .Created}},"size":{{json .Size}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}},"labelNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}]}`; const ID = /^[a-f0-9]{64}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; const BIRTH = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; @@ -192,7 +193,20 @@ export type RetainedFixtureImage = { readonly reference: string; readonly tag: string; readonly owner: string; + readonly digests: readonly string[] | null; }; +function selectedDigests( + value: unknown, + reference: string, + id: string +): value is readonly string[] | null { + return ( + value === null || + (Array.isArray(value) && + (value.length === 0 || + (value.length === 1 && value[0] === `${reference}@${id}`))) + ); +} /** Capture no old/foreign image removal authority, even when its tag happens to match. */ export function retainedBuildFixtureImage(opts: { readonly value: unknown; @@ -220,8 +234,7 @@ export function retainedBuildFixtureImage(opts: { value.tags.length === 1 && (value.tags[0] === reference || value.tags[0] === `${reference}:latest`) && - (value.digests === null || - (Array.isArray(value.digests) && value.digests.length === 0)) + selectedDigests(value.digests, reference, value.id) ) ) { refuse(); @@ -232,6 +245,7 @@ export function retainedBuildFixtureImage(opts: { reference, tag: value.tags[0], owner, + digests: value.digests === null ? null : Object.freeze([...value.digests]), }); } export function assertRetainedFixtureImageUnchanged(opts: { @@ -243,6 +257,154 @@ export function assertRetainedFixtureImageUnchanged(opts: { } } +export type RetainedFixtureBuildObject = { + readonly id: string; + readonly parent: string; + readonly created: string; + readonly size: number; + readonly owner: string; + readonly stage: "retained"; + readonly tags: readonly string[] | null; + readonly digests: readonly string[] | null; + readonly labelNames: readonly string[]; +}; + +/** Only exact fixture-labelled objects, never arbitrary dangling images or cache entries. */ +export function retainedBuildFixtureObject(opts: { + readonly value: unknown; + readonly selected: RetainedFixtureImage; + readonly originalImageIds: readonly string[]; +}): RetainedFixtureBuildObject { + const { value, selected, originalImageIds } = opts; + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "created,digests,id,labelNames,owner,parent,size,stage,tags" && + typeof value.id === "string" && + IMAGE.test(value.id) && + !originalImageIds.includes(value.id) && + originalImageIds.every((id) => IMAGE.test(id)) && + typeof value.parent === "string" && + (value.parent === "" || IMAGE.test(value.parent)) && + typeof value.created === "string" && + BIRTH.test(value.created) && + Number.isFinite(Date.parse(value.created)) && + typeof value.size === "number" && + Number.isSafeInteger(value.size) && + value.size >= 0 && + value.owner === selected.owner && + value.stage === "retained" && + Array.isArray(value.labelNames) && + value.labelNames.every((name) => typeof name === "string") && + new Set(value.labelNames).size === value.labelNames.length && + value.labelNames.includes(RETAINED_BUILD_IMAGE_OWNER) && + value.labelNames.includes("hack.e2e.retained-build.stage") && + value.labelNames.every((name) => + [ + RETAINED_BUILD_IMAGE_OWNER, + "hack.e2e.retained-build.stage", + "com.docker.compose.image.builder", + ].includes(name) + ) + ) + ) { + refuse(); + } + if (value.id === selected.id) { + const current = retainedBuildFixtureImage({ + value: { + id: value.id, + created: value.created, + owner: value.owner, + stage: value.stage, + tags: value.tags, + digests: value.digests, + }, + reference: selected.reference, + owner: selected.owner, + originalImageIds, + }); + assertRetainedFixtureImageUnchanged({ current, captured: selected }); + } else if ( + !( + (value.tags === null || + (Array.isArray(value.tags) && value.tags.length === 0)) && + (value.digests === null || + (Array.isArray(value.digests) && value.digests.length === 0)) + ) + ) { + refuse(); + } + if ( + !(value.tags === null || Array.isArray(value.tags)) || + !(value.digests === null || Array.isArray(value.digests)) + ) { + refuse(); + } + return Object.freeze({ + id: value.id, + parent: value.parent, + created: value.created, + size: value.size, + owner: selected.owner, + stage: "retained", + tags: value.tags === null ? null : Object.freeze([...value.tags]), + digests: value.digests === null ? null : Object.freeze([...value.digests]), + labelNames: Object.freeze([...value.labelNames].sort()), + }); +} + +/** + * Capture a closed child-to-parent image graph from one explicit bootstrap build. + * A builder with no exposed Parent may qualify one final object. Exposed parents + * must form the exact owned chain to the captured base; unrelated new IDs refuse. + * This proves only disposal authority for these objects, never general cache ownership. + */ +export function retainedBuildFixtureObjectGraph(opts: { + readonly values: readonly unknown[]; + readonly selected: RetainedFixtureImage; + readonly originalImageIds: readonly string[]; + readonly baseImage: string; +}): readonly RetainedFixtureBuildObject[] { + if ( + opts.values.length < 1 || + opts.values.length > 16 || + !IMAGE.test(opts.baseImage) || + !opts.originalImageIds.includes(opts.baseImage) + ) { + refuse(); + } + const nodes = new Map(); + for (const value of opts.values) { + const node = retainedBuildFixtureObject({ ...opts, value }); + if (nodes.has(node.id)) { + refuse(); + } + nodes.set(node.id, node); + } + const result: RetainedFixtureBuildObject[] = []; + let id = opts.selected.id; + while (id !== opts.baseImage) { + const node = nodes.get(id); + if (!node || result.some((row) => row.id === id)) { + refuse(); + } + result.push(node); + if (node.parent === "") { + if (result.length !== 1 || nodes.size !== 1) { + refuse(); + } + break; + } + id = node.parent; + } + if (result.length !== nodes.size) { + refuse(); + } + return Object.freeze(result); +} + type BuildReadScope = { readonly args: readonly string[]; readonly projectRoot: string; diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index fe537f02f..d1c9ff57f 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -33,10 +33,14 @@ import { RETAINED_BUILD_COPY_ORACLE, RETAINED_BUILD_IMAGE_FORMAT, RETAINED_BUILD_IMAGE_OWNER, + RETAINED_BUILD_OBJECT_FORMAT, type RetainedBuildFixtureMode, type RetainedFixtureImage, + type RetainedFixtureBuildObject, retainedBuildFixtureDefinition, retainedBuildFixtureImage, + retainedBuildFixtureObject, + retainedBuildFixtureObjectGraph, retainedBuildFixtureMarker, retainedBuildFixtureSourceSnapshot, } from "./native-compose-adoption-build-inputs.ts"; @@ -820,6 +824,10 @@ function createFixtureRuntime( const localAnchors = new Map(); const buildSourceAnchors = new Map(); const builtImages = new Map(); + const builtImageObjects = new Map< + Instance, + readonly RetainedFixtureBuildObject[] + >(); const buildImageAnchors = new Map(); const effect = async (args: readonly string[]) => { await requirePreparedEngine({ engineId, probe }); @@ -976,6 +984,7 @@ function createFixtureRuntime( await assertFixtureBuildImages({ probe, builtImages, + builtImageObjects, originalImageIds, instance, }); @@ -1101,7 +1110,12 @@ function createFixtureRuntime( } } if (instance.basicBuild) { - await assertFixtureBuildImages({ ...opts, builtImages, instance }); + await assertFixtureBuildImages({ + ...opts, + builtImages, + builtImageObjects, + instance, + }); if ( (await fixtureRuntimeImages({ ...opts, @@ -1134,6 +1148,7 @@ function createFixtureRuntime( buildSourceAnchors, buildImageAnchors, builtImages, + builtImageObjects, baseImage, originalImageIds, effect, @@ -1161,7 +1176,16 @@ async function fixtureImageInventory( ) { const ids = [ ...new Set( - (await probe(["image", "ls", "--no-trunc", "--format", "{{.ID}}"])) + ( + await probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--format", + "{{.ID}}", + ]) + ) .split(/\s+/) .filter(Boolean) ), @@ -1192,13 +1216,17 @@ function fixtureComposePrefix( type BuildImageContext = { readonly probe: ReturnType; readonly builtImages: ReadonlyMap; + readonly builtImageObjects: ReadonlyMap< + Instance, + readonly RetainedFixtureBuildObject[] + >; readonly originalImageIds: readonly string[]; readonly baseImage: string; }; async function assertFixtureBuildImages( opts: Pick< BuildImageContext, - "probe" | "builtImages" | "originalImageIds" + "probe" | "builtImages" | "builtImageObjects" | "originalImageIds" > & { readonly instance: Instance } ) { const captured = opts.builtImages.get(opts.instance); @@ -1224,6 +1252,28 @@ async function assertFixtureBuildImages( }), }); } + const objects = opts.builtImageObjects.get(opts.instance); + if (!objects || objects.length < 1) { + refused(); + } + for (const expected of objects) { + const current = retainedBuildFixtureObject({ + value: object( + await opts.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + expected.id, + ]) + ), + selected: captured, + originalImageIds: opts.originalImageIds, + }); + if (JSON.stringify(current) !== JSON.stringify(expected)) { + refused(); + } + } } async function fixtureRuntimeImages( opts: Pick & { @@ -1299,6 +1349,12 @@ async function saveFixtureBuildRecovery(h: FixtureRuntime, pending?: unknown) { root: instance.root, image, })), + objects: [...h.builtImageObjects.entries()].map( + ([instance, objects]) => ({ + project: instance.name, + objects, + }) + ), ...(pending !== undefined ? { pending } : {}), }) ); @@ -1312,6 +1368,18 @@ async function bootstrapFixtureBuildImage( refused(); } await requirePreparedEngine(h); + const previousIds = [ + ...h.originalImageIds, + ...[...h.builtImageObjects.values()].flatMap((rows) => + rows.map((row) => row.id) + ), + ].sort(); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify(previousIds) + ) { + refused(); + } if ( ( await h.probe([ @@ -1342,6 +1410,7 @@ async function bootstrapFixtureBuildImage( await h.probe([ "image", "ls", + "--all", "--no-trunc", "--filter", `reference=${reference}`, @@ -1353,6 +1422,7 @@ async function bootstrapFixtureBuildImage( await h.probe([ "image", "ls", + "--all", "--no-trunc", "--filter", `label=${RETAINED_BUILD_IMAGE_OWNER}=${instance.name}`, @@ -1395,7 +1465,42 @@ async function bootstrapFixtureBuildImage( owner: instance.name, originalImageIds: h.originalImageIds, }); + const observedIds = await fixtureImageInventory(h.probe); + if (!previousIds.every((id) => observedIds.includes(id))) { + refused(); + } + const newIds = observedIds.filter((id) => !previousIds.includes(id)); + if (newIds.length < 1 || newIds.length > 16) { + refused(); + } + const values: unknown[] = []; + for (const id of newIds) { + values.push( + object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + id, + ]) + ) + ); + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "objects-observed", + observations: values, + }); + } + const objects = retainedBuildFixtureObjectGraph({ + values, + selected: image, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + }); h.builtImages.set(instance, image); + h.builtImageObjects.set(instance, objects); await saveFixtureBuildRecovery(h); successful(built); await assertFixtureBuildImages({ ...h, instance }); @@ -2615,60 +2720,132 @@ async function rollbackRetainedBuildFixture( await h.waitReady(instance); await h.check(instance); } -async function cleanupRetainedBuildFixtureImages(h: FixtureRuntime) { - const pending = new Set([...h.builtImages.values()].map((row) => row.id)); - for (const [instance, image] of h.builtImages) { - await requirePreparedEngine(h); - if ( - JSON.stringify(await fixtureImageInventory(h.probe)) !== - JSON.stringify([...h.originalImageIds, ...pending].sort()) - ) { - refused(); - } - await assertFixtureBuildImages({ ...h, instance }); - if ( - ( - await h.probe([ - "container", - "ls", - "--all", - "--no-trunc", - "--filter", - `ancestor=${image.id}`, - "--format", - "{{.ID}}", - ]) - ).trim() || - ( - await h.probe([ - "image", - "inspect", - "--format", - "{{.Id}}", - RETAINED_BUILD_BASE_TAG, - ]) - ).trim() !== h.baseImage - ) { +type BuildCleanupInputs = Pick< + FixtureRuntime, + | "engineId" + | "probe" + | "builtImages" + | "builtImageObjects" + | "originalImageIds" + | "baseImage" +> & { + readonly effect: (args: readonly string[]) => Promise; + readonly journal: (value: unknown) => Promise; +}; +async function requireRemainingBuildObjects( + h: BuildCleanupInputs, + pending: ReadonlySet +) { + await requirePreparedEngine(h); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify([...h.originalImageIds, ...pending].sort()) + ) { + refused(); + } + for (const [instance, rows] of h.builtImageObjects) { + const selected = h.builtImages.get(instance); + if (!selected) { refused(); } - await requirePreparedEngine(h); - await h.effect(["image", "rm", "--no-prune", image.id]); - pending.delete(image.id); - await requirePreparedEngine(h); - if ( - JSON.stringify(await fixtureImageInventory(h.probe)) !== - JSON.stringify([...h.originalImageIds, ...pending].sort()) - ) { - refused(); + for (const captured of rows) { + if (!pending.has(captured.id)) { + continue; + } + const current = retainedBuildFixtureObject({ + value: object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + captured.id, + ]) + ), + selected, + originalImageIds: h.originalImageIds, + }); + if ( + JSON.stringify(current) !== JSON.stringify(captured) || + ( + await h.probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--filter", + `ancestor=${captured.id}`, + "--format", + "{{.ID}}", + ]) + ).trim() + ) { + refused(); + } } } + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage + ) { + refused(); + } await requirePreparedEngine(h); +} +/** Exact captured child-before-parent disposal, never prune or general cache removal. */ +export async function cleanupRetainedBuildFixtureImages(h: BuildCleanupInputs) { + if (h.builtImages.size !== h.builtImageObjects.size) { + refused(); + } + const rows = [...h.builtImageObjects.values()].flat(); + const pending = new Set(rows.map((row) => row.id)); if ( - JSON.stringify(await fixtureImageInventory(h.probe)) !== - JSON.stringify(h.originalImageIds) + pending.size !== rows.length || + rows.some((row) => h.originalImageIds.includes(row.id)) ) { refused(); } + for (const [instance, objects] of h.builtImageObjects) { + const selected = h.builtImages.get(instance); + if ( + !selected || + JSON.stringify( + retainedBuildFixtureObjectGraph({ + values: objects, + selected, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + }) + ) !== JSON.stringify(objects) + ) { + refused(); + } + for (const row of objects) { + await requireRemainingBuildObjects(h, pending); + await h.journal({ + stage: "before-image-remove", + id: row.id, + remaining: [...pending].sort(), + }); + await requireRemainingBuildObjects(h, pending); + await h.effect(["image", "rm", "--no-prune", row.id]); + pending.delete(row.id); + await requireRemainingBuildObjects(h, pending); + await h.journal({ + stage: "after-image-remove", + id: row.id, + remaining: [...pending].sort(), + }); + } + } + await requireRemainingBuildObjects(h, pending); } /** Basic builds qualify separately from preview; no builder is reachable after bootstrap. */ export const nativeComposeAdoptionBuildWorktreesScenario: Scenario = { @@ -2752,7 +2929,15 @@ export const nativeComposeAdoptionBuildWorktreesScenario: Scenario = { ...h, instances: [h.first, h.second], }); - await cleanupRetainedBuildFixtureImages(h); + await cleanupRetainedBuildFixtureImages({ + ...h, + effect: async (args) => { + await h.effect(args); + }, + journal: async (pending) => { + await saveFixtureBuildRecovery(h, pending); + }, + }); }, secondaryFailure: () => ctx.retainFixtures( diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 6000f8200..9354611a3 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -19,9 +19,11 @@ import { assertRetainedBuildFixtureCopy, assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, + RETAINED_BUILD_OBJECT_FORMAT, retainedBuildFixtureCopiedFiles, retainedBuildFixtureDefinition, retainedBuildFixtureImage, + retainedBuildFixtureObjectGraph, retainedBuildFixtureMutationAllowed, retainedBuildFixtureReadAllowed, retainedBuildFixtureSourceSnapshot, @@ -29,6 +31,7 @@ import { import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; import { buildFixtureCli, + cleanupRetainedBuildFixtureImages, nativeComposeAdoptionBuildWorktreesScenario, } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; import { retainedBuildFixture } from "./helpers/retained-build-adoption.ts"; @@ -315,6 +318,265 @@ test("image fixture capture requires a new sole-tag selected-stage image and pin }) ).toThrow("values omitted"); }); +test("fixture image capture pins the exact observed same-ID repository digest", () => { + const digest = `fixture-db@${image}`; + const captured = retainedBuildFixtureImage({ + value: { ...imageRow, digests: [digest] }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }); + expect(captured.digests).toEqual([digest]); + expect(Object.isFrozen(captured.digests)).toBe(true); + expect(() => + assertRetainedFixtureImageUnchanged({ + captured, + current: { ...captured, digests: null }, + }) + ).toThrow("values omitted"); + for (const digests of [ + [CANARY], + [`other-db@${image}`], + [`fixture-db@sha256:${"d".repeat(64)}`], + [digest, digest], + [digest, CANARY], + ]) { + expect(() => + retainedBuildFixtureImage({ + value: { ...imageRow, digests }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }) + ).toThrow("values omitted"); + } +}); + +const baseBuildImage = `sha256:${"1".repeat(64)}`; +const firstBuildParent = `sha256:${"2".repeat(64)}`; +const secondBuildParent = `sha256:${"3".repeat(64)}`; +function capturedBuildGraph() { + const selected = retainedBuildFixtureImage({ + value: { ...imageRow, digests: [`fixture-db@${image}`] }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [baseBuildImage], + }); + const labels = [ + "hack.e2e.retained-build.owner", + "hack.e2e.retained-build.stage", + ]; + const values = [ + { + ...imageRow, + parent: secondBuildParent, + size: 1000, + digests: [`fixture-db@${image}`], + labelNames: [...labels, "com.docker.compose.image.builder"], + }, + { + ...imageRow, + id: firstBuildParent, + parent: baseBuildImage, + size: 800, + tags: [], + digests: [], + labelNames: labels, + }, + { + ...imageRow, + id: secondBuildParent, + parent: firstBuildParent, + size: 900, + tags: [], + digests: [], + labelNames: labels, + }, + ]; + const capture = ( + rows: readonly unknown[] = values, + originals = [baseBuildImage] + ) => + retainedBuildFixtureObjectGraph({ + values: rows, + selected, + originalImageIds: originals, + baseImage: baseBuildImage, + }); + return { selected, values, capture }; +} +test("explicit builder graph captures only the owned child-to-base chain", () => { + const { capture, values } = capturedBuildGraph(); + const graph = capture(); + expect(graph.map((row) => row.id)).toEqual([ + image, + secondBuildParent, + firstBuildParent, + ]); + expect(Object.isFrozen(graph)).toBe(true); + expect(Object.isFrozen(graph[0]?.labelNames)).toBe(true); + expect(capture([{ ...values[0], parent: "" }]).map((row) => row.id)).toEqual([ + image, + ]); + for (const rows of [ + [values[0], values[1]], + [values[0], values[1], { ...values[2], owner: CANARY }], + [values[0], values[1], { ...values[2], parent: image }], + [values[0], { ...values[1], tags: ["foreign:latest"] }, values[2]], + [...values, { ...values[1], id: `sha256:${"4".repeat(64)}` }], + [ + { ...values[0], labelNames: [...values[0].labelNames, CANARY] }, + values[1], + values[2], + ], + [{ ...values[0], parent: "" }, values[1], values[2]], + ]) + expect(() => capture(rows)).toThrow("values omitted"); + expect(() => capture(values, [baseBuildImage, firstBuildParent])).toThrow( + "values omitted" + ); +}); + +function buildCleanupModel() { + const { selected, values, capture } = capturedBuildGraph(); + const objects = capture(); + const instance = { + root: "/owned/fixture", + name: "fixture", + marker: "sql-marker", + }; + const remaining = new Set(objects.map((row) => row.id)); + const refs = new Set(); + const events: { stage: string; id?: string }[] = []; + let daemon = '"fixture-engine"'; + let afterJournal: (() => void) | undefined; + let afterEffect: (() => void) | undefined; + const current = new Map(values.map((row) => [row.id, row])); + const opts = { + engineId: daemon, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + builtImages: new Map([[instance, selected]]), + builtImageObjects: new Map([[instance, objects]]), + probe: async (args: readonly string[]) => { + if (args.join() === "info,--format,{{json .ID}}") return daemon; + if (args.join() === "image,ls,--all,--no-trunc,--format,{{.ID}}") + return [baseBuildImage, ...remaining].sort().join("\n"); + if ( + args[0] === "image" && + args[1] === "inspect" && + args[3] === "{{.Id}}" && + args[4] === "postgres:17.6-alpine" + ) + return baseBuildImage; + if ( + args[0] === "image" && + args[1] === "inspect" && + args[2] === "--format" && + args[3] === RETAINED_BUILD_OBJECT_FORMAT && + args.length === 5 && + args[4] && + remaining.has(args[4]) + ) + return JSON.stringify(current.get(args[4])); + if ( + args[0] === "container" && + args[1] === "ls" && + args[2] === "--all" && + args[3] === "--no-trunc" && + args[4] === "--filter" && + args[5]?.startsWith("ancestor=") && + args[6] === "--format" && + args[7] === "{{.ID}}" && + args.length === 8 + ) + return refs.has(args[5].slice(9)) ? id : ""; + throw new Error("Unexpected fixed fixture read"); + }, + journal: async (value: unknown) => { + if ( + !( + value && + typeof value === "object" && + "stage" in value && + typeof value.stage === "string" + ) + ) + throw new Error("Missing fixed journal stage"); + events.push({ stage: value.stage }); + if (value.stage === "before-image-remove") afterJournal?.(); + }, + effect: async (args: readonly string[]) => { + expect(args.slice(0, 3)).toEqual(["image", "rm", "--no-prune"]); + const selectedId = args[3]; + if (!selectedId || !remaining.delete(selectedId)) + throw new Error("Unexpected exact image effect"); + events.push({ stage: "remove", id: selectedId }); + afterEffect?.(); + }, + }; + return { + opts, + remaining, + refs, + events, + current, + changeDaemon: () => { + daemon = '"foreign-engine"'; + }, + setAfterJournal: (action: () => void) => { + afterJournal = action; + }, + setAfterEffect: (action: () => void) => { + afterEffect = action; + }, + }; +} +test("owned image cleanup journals and removes exact children before parents without prune", async () => { + const model = buildCleanupModel(); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual( + [image, secondBuildParent, firstBuildParent].flatMap((id) => [ + { stage: "before-image-remove" }, + { stage: "remove", id }, + { stage: "after-image-remove" }, + ]) + ); +}); +for (const mode of [ + "reference-after-journal", + "daemon-after-journal", + "changed-birth", + "foreign-after-effect", +] as const) { + test(`owned image cleanup refuses ${mode} without another effect`, async () => { + const model = buildCleanupModel(); + if (mode === "reference-after-journal") + model.setAfterJournal(() => model.refs.add(image)); + if (mode === "daemon-after-journal") + model.setAfterJournal(model.changeDaemon); + if (mode === "changed-birth") { + const row = model.current.get(secondBuildParent); + if (!row) throw new Error("Missing fixed image row"); + model.current.set(secondBuildParent, { + ...row, + created: "2026-10-08T21:00:00Z", + }); + } + if (mode === "foreign-after-effect") + model.setAfterEffect(() => + model.remaining.add(`sha256:${"f".repeat(64)}`) + ); + await expect(cleanupRetainedBuildFixtureImages(model.opts)).rejects.toThrow( + "values omitted" + ); + expect(model.events.filter((row) => row.stage === "remove").length).toBe( + mode === "foreign-after-effect" ? 1 : 0 + ); + expect(model.remaining.has(firstBuildParent)).toBe(true); + }); +} test("build read shim admits exact original/published images queries and captured image facts only", () => { for (const file of [ "/owned/root/.hack/docker-compose.yml", From 3bc063c55ae19814a50f30758c0e80278755718f Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:24:48 -0400 Subject: [PATCH 16/26] test: preserve fixed builder graph tuple types --- tests/native-compose-adoption-build-fixture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 9354611a3..61f6c9d4f 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -392,7 +392,7 @@ function capturedBuildGraph() { digests: [], labelNames: labels, }, - ]; + ] as const; const capture = ( rows: readonly unknown[] = values, originals = [baseBuildImage] From 38a545ba92474ffd87d4027066a46e9fb3b8ea2e Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:25:58 -0400 Subject: [PATCH 17/26] style: align retained build fixture control syntax --- .../native-compose-adoption-build-inputs.ts | 6 ++- .../native-compose-adoption-worktrees.ts | 4 +- ...ive-compose-adoption-build-fixture.test.ts | 44 +++++++++++++------ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts index 4b6707bdf..4ebbe4892 100644 --- a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -337,8 +337,10 @@ export function retainedBuildFixtureObject(opts: { refuse(); } if ( - !(value.tags === null || Array.isArray(value.tags)) || - !(value.digests === null || Array.isArray(value.digests)) + !( + (value.tags === null || Array.isArray(value.tags)) && + (value.digests === null || Array.isArray(value.digests)) + ) ) { refuse(); } diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index d1c9ff57f..9afc8e053 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -35,13 +35,13 @@ import { RETAINED_BUILD_IMAGE_OWNER, RETAINED_BUILD_OBJECT_FORMAT, type RetainedBuildFixtureMode, - type RetainedFixtureImage, type RetainedFixtureBuildObject, + type RetainedFixtureImage, retainedBuildFixtureDefinition, retainedBuildFixtureImage, + retainedBuildFixtureMarker, retainedBuildFixtureObject, retainedBuildFixtureObjectGraph, - retainedBuildFixtureMarker, retainedBuildFixtureSourceSnapshot, } from "./native-compose-adoption-build-inputs.ts"; import { diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 61f6c9d4f..907446453 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -23,8 +23,8 @@ import { retainedBuildFixtureCopiedFiles, retainedBuildFixtureDefinition, retainedBuildFixtureImage, - retainedBuildFixtureObjectGraph, retainedBuildFixtureMutationAllowed, + retainedBuildFixtureObjectGraph, retainedBuildFixtureReadAllowed, retainedBuildFixtureSourceSnapshot, } from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; @@ -430,8 +430,9 @@ test("explicit builder graph captures only the owned child-to-base chain", () => values[2], ], [{ ...values[0], parent: "" }, values[1], values[2]], - ]) + ]) { expect(() => capture(rows)).toThrow("values omitted"); + } expect(() => capture(values, [baseBuildImage, firstBuildParent])).toThrow( "values omitted" ); @@ -459,16 +460,20 @@ function buildCleanupModel() { builtImages: new Map([[instance, selected]]), builtImageObjects: new Map([[instance, objects]]), probe: async (args: readonly string[]) => { - if (args.join() === "info,--format,{{json .ID}}") return daemon; - if (args.join() === "image,ls,--all,--no-trunc,--format,{{.ID}}") + if (args.join() === "info,--format,{{json .ID}}") { + return daemon; + } + if (args.join() === "image,ls,--all,--no-trunc,--format,{{.ID}}") { return [baseBuildImage, ...remaining].sort().join("\n"); + } if ( args[0] === "image" && args[1] === "inspect" && args[3] === "{{.Id}}" && args[4] === "postgres:17.6-alpine" - ) + ) { return baseBuildImage; + } if ( args[0] === "image" && args[1] === "inspect" && @@ -477,8 +482,9 @@ function buildCleanupModel() { args.length === 5 && args[4] && remaining.has(args[4]) - ) + ) { return JSON.stringify(current.get(args[4])); + } if ( args[0] === "container" && args[1] === "ls" && @@ -489,8 +495,9 @@ function buildCleanupModel() { args[6] === "--format" && args[7] === "{{.ID}}" && args.length === 8 - ) + ) { return refs.has(args[5].slice(9)) ? id : ""; + } throw new Error("Unexpected fixed fixture read"); }, journal: async (value: unknown) => { @@ -501,16 +508,20 @@ function buildCleanupModel() { "stage" in value && typeof value.stage === "string" ) - ) + ) { throw new Error("Missing fixed journal stage"); + } events.push({ stage: value.stage }); - if (value.stage === "before-image-remove") afterJournal?.(); + if (value.stage === "before-image-remove") { + afterJournal?.(); + } }, effect: async (args: readonly string[]) => { expect(args.slice(0, 3)).toEqual(["image", "rm", "--no-prune"]); const selectedId = args[3]; - if (!selectedId || !remaining.delete(selectedId)) + if (!(selectedId && remaining.delete(selectedId))) { throw new Error("Unexpected exact image effect"); + } events.push({ stage: "remove", id: selectedId }); afterEffect?.(); }, @@ -552,22 +563,27 @@ for (const mode of [ ] as const) { test(`owned image cleanup refuses ${mode} without another effect`, async () => { const model = buildCleanupModel(); - if (mode === "reference-after-journal") + if (mode === "reference-after-journal") { model.setAfterJournal(() => model.refs.add(image)); - if (mode === "daemon-after-journal") + } + if (mode === "daemon-after-journal") { model.setAfterJournal(model.changeDaemon); + } if (mode === "changed-birth") { const row = model.current.get(secondBuildParent); - if (!row) throw new Error("Missing fixed image row"); + if (!row) { + throw new Error("Missing fixed image row"); + } model.current.set(secondBuildParent, { ...row, created: "2026-10-08T21:00:00Z", }); } - if (mode === "foreign-after-effect") + if (mode === "foreign-after-effect") { model.setAfterEffect(() => model.remaining.add(`sha256:${"f".repeat(64)}`) ); + } await expect(cleanupRetainedBuildFixtureImages(model.opts)).rejects.toThrow( "values omitted" ); From a3cd379094bd48002b24897f210f000cfed6fc85 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:27:51 -0400 Subject: [PATCH 18/26] test: qualify retained build fixture driver and COPY evidence --- docs/reference/native-compose-adoption.md | 8 + .../native-compose-adoption-build-evidence.ts | 157 ++++++++++++++++++ .../native-compose-adoption-worktrees.ts | 89 ++++++++-- ...ve-compose-adoption-build-evidence.test.ts | 148 +++++++++++++++++ 4 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-build-evidence.ts create mode 100644 tests/native-compose-adoption-build-evidence.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index bf30a0698..61bf17f89 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -196,6 +196,14 @@ descendants: `**` followed by `!.hack` does not isolate future private outputs. Such a context refuses unless later literal exclusions close those paths. Root and `.hack` contexts can qualify through these exact exclusions. +The maintained `native-compose-adoption-build-worktrees` acceptance explicitly +requires Buildx's default Docker driver, `DOCKER_BUILDKIT=1` and +`compose build --builder default` for fixture bootstrap. It never creates or +bootstraps a separate builder. Its private fixed-stage evidence retains the +complete fatal-decoded synthetic COPY reply before applying the unchanged file +and hash oracle. This separates builder qualification from source/image guards; +it does not infer which builder caused an earlier failure or prove cache ownership. + One acquisition is limited to 16 builds, depth 32, 256 captured entries, 4096 directory names, 16 MiB of bytes and a 48 KiB private proof; the existing stable file owner also limits each file to 1 MiB. Included byte, identity or mode changes, diff --git a/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts b/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts new file mode 100644 index 000000000..7d1a7cd69 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts @@ -0,0 +1,157 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { + assertRetainedBuildFixtureCopy, + type RetainedBuildFixtureMode, +} from "./native-compose-adoption-build-inputs.ts"; + +export const RETAINED_BUILD_BOOTSTRAP_ENV = Object.freeze({ + DOCKER_BUILDKIT: "1", + BUILDX_BUILDER: "default", +}); +export const RETAINED_BUILD_BUILDER_FORMAT = + '{{if eq .DriverEndpoint "docker"}}{"name":{{json .Builder.Name}},"driver":{{json .DriverEndpoint}},"nodes":[{{range $i,$n := .Builder.Nodes}}{{if $i}},{{end}}{"name":{{json $n.Name}},"endpoint":{{json $n.Endpoint}}}{{end}}]}{{end}}'; +type Stage = + | "builder-begin" + | "builder-qualified" + | "build-begin" + | "build-settled" + | "image-admission-begin" + | "image-admitted" + | "graph-admitted" + | "original-start-begin" + | "original-start-settled" + | "original-ids-captured" + | "readiness-begin" + | "readiness-qualified" + | "sql-begin" + | "sql-qualified" + | "full-check-begin" + | "full-check-qualified" + | "source-recheck-begin" + | "source-rechecked" + | "runtime-image-begin" + | "runtime-image-rechecked" + | "copy-begin" + | "copy-captured" + | "copy-qualified"; +export type RetainedBuildFixtureEvidence = ( + mode: RetainedBuildFixtureMode, + stage: Stage, + bytes?: Uint8Array +) => Promise; + +function refuse(): never { + throw new Error("Retained build fixture evidence refused; values omitted."); +} + +/** Fixed stages and complete synthetic COPY bytes only; no subprocess argv, arbitrary errors or host environment. */ +export function createRetainedBuildFixtureEvidence(opts: { + readonly tempRoot: string; +}): RetainedBuildFixtureEvidence { + const root = join(opts.tempRoot, "retained-build-stages"); + let count = 0; + const started = performance.now(); + return async (mode, stage, bytes) => { + if ( + ++count > 512 || + (bytes !== undefined && + (stage !== "copy-captured" || bytes.byteLength > 16_384)) + ) { + refuse(); + } + await mkdir(root, { mode: 0o700, recursive: true }); + await writeFile( + join(root, `${String(count).padStart(4, "0")}.json`), + JSON.stringify({ + evidence_version: 1, + mode, + stage, + elapsedMs: Math.round(performance.now() - started), + ...(bytes === undefined + ? {} + : { + byteLength: bytes.byteLength, + copyBase64: Buffer.from(bytes).toString("base64"), + }), + }), + { mode: 0o600, flag: "wx" } + ); + }; +} + +/** Select only the engine's default Docker driver; no builder creation, bootstrap, remote driver or fallback. */ +export async function qualifyRetainedBuildFixtureBuilder(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly read: (args: readonly string[]) => Promise; + readonly record: RetainedBuildFixtureEvidence; + readonly env: Readonly>; +}): Promise { + await opts.record(opts.mode, "builder-begin"); + if ( + opts.env.DOCKER_BUILDKIT !== "1" || + opts.env.BUILDX_BUILDER !== "default" || + opts.env.DOCKER_CONTEXT !== undefined || + !opts.env.DOCKER_HOST?.startsWith("unix:///") + ) { + refuse(); + } + const version = await opts.read(["buildx", "version"]); + if ( + !/^github\.com\/docker\/buildx v\d+\.\d+\.\d+(?:[-+][\w.-]+)? [a-f0-9]+\s*$/.test( + version + ) + ) { + refuse(); + } + const text = await opts.read([ + "buildx", + "ls", + "--timeout", + "10s", + "--format", + RETAINED_BUILD_BUILDER_FORMAT, + ]); + const rows = text.split("\n").filter((line) => line.trim()); + if (rows.length !== 1) { + refuse(); + } + let value: unknown; + try { + value = JSON.parse(rows[0] ?? ""); + } catch { + refuse(); + } + if ( + !isRecord(value) || + Object.keys(value).sort().join() !== "driver,name,nodes" || + value.name !== "default" || + value.driver !== "docker" || + !Array.isArray(value.nodes) || + value.nodes.length !== 1 || + !isRecord(value.nodes[0]) || + Object.keys(value.nodes[0]).sort().join() !== "endpoint,name" || + value.nodes[0].name !== "default" || + !( + value.nodes[0].endpoint === "default" || + value.nodes[0].endpoint === opts.env.DOCKER_HOST + ) + ) { + refuse(); + } + await opts.record(opts.mode, "builder-qualified"); +} + +/** Persist the untrimmed, fatal-decoded reply before the unchanged complete COPY oracle can refuse. */ +export async function qualifyRetainedBuildFixtureCopy(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly read: () => Promise; + readonly record: RetainedBuildFixtureEvidence; +}): Promise { + await opts.record(opts.mode, "copy-begin"); + const text = await opts.read(); + await opts.record(opts.mode, "copy-captured", new TextEncoder().encode(text)); + assertRetainedBuildFixtureCopy({ mode: opts.mode, text }); + await opts.record(opts.mode, "copy-qualified"); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 9e5c79ff4..09e7d829c 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -26,7 +26,6 @@ import { type ScenarioContext, } from "../harness.ts"; import { - assertRetainedBuildFixtureCopy, assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, RETAINED_BUILD_BASE_TAG, @@ -44,6 +43,12 @@ import { retainedBuildFixtureObjectGraph, retainedBuildFixtureSourceSnapshot, } from "./native-compose-adoption-build-inputs.ts"; +import { + createRetainedBuildFixtureEvidence, + qualifyRetainedBuildFixtureBuilder, + qualifyRetainedBuildFixtureCopy, + RETAINED_BUILD_BOOTSTRAP_ENV, +} from "./native-compose-adoption-build-evidence.ts"; import { adoptionDependencyHealthcheck, assertAdoptionDependencyControl, @@ -931,6 +936,14 @@ function createFixtureRuntime( const managedAnchors = new Map(); const localAnchors = new Map(); const buildSourceAnchors = new Map(); + const buildEvidence = createRetainedBuildFixtureEvidence({ + tempRoot: ctx.tempRoot, + }); + const buildEnv = Object.freeze({ + ...RETAINED_BUILD_BOOTSTRAP_ENV, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + }); const builtImages = new Map(); const builtImageObjects = new Map< Instance, @@ -1177,6 +1190,7 @@ function createFixtureRuntime( if (!instance.basicBuild) { refused(); } + await buildEvidence(instance.basicBuild, "image-admission-begin"); await assertFixtureBuildImages({ probe, builtImages, @@ -1184,11 +1198,19 @@ function createFixtureRuntime( originalImageIds, instance, }); + await buildEvidence(instance.basicBuild, "image-admitted"); + await buildEvidence(instance.basicBuild, "source-recheck-begin"); if ( (await retainedBuildFixtureSourceSnapshot({ root: instance.root, mode: instance.basicBuild, - })) !== buildSourceAnchors.get(instance) || + })) !== buildSourceAnchors.get(instance) + ) { + refused(); + } + await buildEvidence(instance.basicBuild, "source-rechecked"); + await buildEvidence(instance.basicBuild, "runtime-image-begin"); + if ( (await fixtureRuntimeImages({ probe, instance, @@ -1199,16 +1221,19 @@ function createFixtureRuntime( ) { refused(); } - assertRetainedBuildFixtureCopy({ + await buildEvidence(instance.basicBuild, "runtime-image-rechecked"); + await qualifyRetainedBuildFixtureCopy({ mode: instance.basicBuild, - text: await probe([ - "container", - "exec", - container(instance, "db"), - "/bin/sh", - "-c", - RETAINED_BUILD_COPY_ORACLE, - ]), + record: buildEvidence, + read: () => + createNativeComposeProbe({ timeoutMs: 30_000 })([ + "container", + "exec", + container(instance, "db"), + "/bin/sh", + "-c", + RETAINED_BUILD_COPY_ORACLE, + ]), }); await owned(instance, "container", container(instance, "db")); }; @@ -1342,6 +1367,8 @@ function createFixtureRuntime( managedAnchors, localAnchors, buildSourceAnchors, + buildEvidence, + buildEnv, buildImageAnchors, builtImages, builtImageObjects, @@ -1634,12 +1661,29 @@ async function bootstrapFixtureBuildImage( reference, stage: "before-build", }); + await qualifyRetainedBuildFixtureBuilder({ + mode: instance.basicBuild, + env: h.buildEnv, + read: h.probe, + record: h.buildEvidence, + }); + await h.buildEvidence(instance.basicBuild, "build-begin"); await requirePreparedEngine(h); const built = await runCommand({ - argv: [h.engine, ...fixtureComposePrefix(instance), "build", "db"], + argv: [ + h.engine, + ...fixtureComposePrefix(instance), + "build", + "--builder", + "default", + "db", + ], cwd: h.fixtureRoot, + env: RETAINED_BUILD_BOOTSTRAP_ENV, timeoutMs: TIMEOUT, }); + await h.buildEvidence(instance.basicBuild, "build-settled"); + await h.buildEvidence(instance.basicBuild, "image-admission-begin"); const observation = object( await h.probe([ "image", @@ -1661,6 +1705,7 @@ async function bootstrapFixtureBuildImage( owner: instance.name, originalImageIds: h.originalImageIds, }); + await h.buildEvidence(instance.basicBuild, "image-admitted"); const observedIds = await fixtureImageInventory(h.probe); if (!previousIds.every((id) => observedIds.includes(id))) { refused(); @@ -1698,6 +1743,7 @@ async function bootstrapFixtureBuildImage( h.builtImages.set(instance, image); h.builtImageObjects.set(instance, objects); await saveFixtureBuildRecovery(h); + await h.buildEvidence(instance.basicBuild, "graph-admitted"); successful(built); await assertFixtureBuildImages({ ...h, instance }); if ( @@ -1792,6 +1838,9 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { await typedLocalAdoptionFixtureSourceSnapshot({ primary, instance }) ); } + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "original-start-begin"); + } await requirePreparedEngine(h); const started = await runCommand({ argv: [ @@ -1813,6 +1862,9 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { cwd: fixtureRoot, timeoutMs: TIMEOUT, }); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "original-start-settled"); + } const captured = await resources(instance); anchors.set(instance, { resources: captured, @@ -1835,8 +1887,14 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { containers: captured.container, }) ); + await h.buildEvidence(instance.basicBuild, "original-ids-captured"); + await h.buildEvidence(instance.basicBuild, "readiness-begin"); } await waitReady(instance); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "readiness-qualified"); + await h.buildEvidence(instance.basicBuild, "sql-begin"); + } await waitForAdoptionFixtureSql({ read: async () => { await sql( @@ -1847,7 +1905,14 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { }, expected: instance.marker, }); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "sql-qualified"); + await h.buildEvidence(instance.basicBuild, "full-check-begin"); + } await check(instance); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "full-check-qualified"); + } } async function checkInheritedRefusal(h: FixtureRuntime) { const { ctx, primary, first, second, env, cli, check, assertNoState } = h; diff --git a/tests/native-compose-adoption-build-evidence.test.ts b/tests/native-compose-adoption-build-evidence.test.ts new file mode 100644 index 000000000..1f16eb576 --- /dev/null +++ b/tests/native-compose-adoption-build-evidence.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { lstat, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createRetainedBuildFixtureEvidence, + qualifyRetainedBuildFixtureBuilder, + qualifyRetainedBuildFixtureCopy, + RETAINED_BUILD_BOOTSTRAP_ENV, + RETAINED_BUILD_BUILDER_FORMAT, +} from "./e2e/scenarios/native-compose-adoption-build-evidence.ts"; +import { retainedBuildFixtureCopiedFiles } from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; + +const host = "unix:///synthetic-owned/docker.sock"; +const env = { ...RETAINED_BUILD_BOOTSTRAP_ENV, DOCKER_HOST: host }; +const version = `github.com/docker/buildx v0.28.0 ${"a".repeat(40)}\n`; +const builder = { + name: "default", + driver: "docker", + nodes: [{ name: "default", endpoint: "default" }], +}; + +test("retained builder qualification uses fixed read-only Buildx queries and the default Docker driver", async () => { + const calls: (readonly string[])[] = []; + const stages: string[] = []; + await qualifyRetainedBuildFixtureBuilder({ + mode: "root-specific", + env, + record: async (_mode, stage) => { + stages.push(stage); + }, + read: async (args) => { + calls.push(args); + return args[1] === "version" ? version : `\n${JSON.stringify(builder)}\n`; + }, + }); + expect(calls).toEqual([ + ["buildx", "version"], + [ + "buildx", + "ls", + "--timeout", + "10s", + "--format", + RETAINED_BUILD_BUILDER_FORMAT, + ], + ]); + expect(stages).toEqual(["builder-begin", "builder-qualified"]); + expect( + calls + .flat() + .some((arg) => ["create", "use", "build", "--bootstrap"].includes(arg)) + ).toBe(false); +}); + +for (const [name, changed] of [ + ["disabled BuildKit", { ...env, DOCKER_BUILDKIT: "0" }], + ["foreign builder", { ...env, BUILDX_BUILDER: "foreign" }], + ["foreign context", { ...env, DOCKER_CONTEXT: "foreign" }], + ["remote socket", { ...env, DOCKER_HOST: "tcp://foreign:2375" }], +] as const) { + test(`retained builder refuses unqualified transport ${name}`, async () => { + let reads = 0; + await expect( + qualifyRetainedBuildFixtureBuilder({ + mode: "hack-default", + env: changed, + record: async () => {}, + read: async () => { + reads += 1; + return version; + }, + }) + ).rejects.toThrow("values omitted"); + expect(reads).toBe(0); + }); +} + +test.each([ + { ...builder, driver: "docker-container" }, + { ...builder, name: "foreign" }, + { ...builder, nodes: [{ name: "default", endpoint: "ssh://foreign" }] }, + { ...builder, nodes: [...builder.nodes, ...builder.nodes] }, + { ...builder, nodes: [] }, +])("retained builder refuses remote/custom/multiple-node facts before qualification", async (row) => { + const stages: string[] = []; + await expect( + qualifyRetainedBuildFixtureBuilder({ + mode: "root-specific", + env, + record: async (_mode, stage) => { + stages.push(stage); + }, + read: async (args) => + args[1] === "version" ? version : JSON.stringify(row), + }) + ).rejects.toThrow("values omitted"); + expect(stages).toEqual(["builder-begin"]); +}); + +for (const mode of ["root-specific", "hack-default"] as const) { + test(`complete ${mode} COPY bytes are retained before an unchanged oracle refusal`, async () => { + const root = await mkdtemp(join(tmpdir(), "retained-copy-evidence-")); + try { + const record = createRetainedBuildFixtureEvidence({ tempRoot: root }); + const text = `wrong-marker ${"a".repeat(64)}\n\n`; + await expect( + qualifyRetainedBuildFixtureCopy({ + mode, + record, + read: async () => text, + }) + ).rejects.toThrow("values omitted"); + const directory = join(root, "retained-build-stages"); + const paths = (await readdir(directory)).sort(); + expect(paths).toHaveLength(2); + const row = JSON.parse( + await readFile(join(directory, paths[1] ?? ""), "utf8") + ); + expect(row.stage).toBe("copy-captured"); + expect(row.byteLength).toBe(Buffer.byteLength(text)); + expect(Buffer.from(row.copyBase64, "base64").toString()).toBe(text); + expect((await lstat(join(directory, paths[1] ?? ""))).mode & 0o777).toBe( + 0o600 + ); + const valid = + retainedBuildFixtureCopiedFiles(mode) + .map((item) => `${item.path} ${item.hash}`) + .join("\n") + "\n"; + await qualifyRetainedBuildFixtureCopy({ + mode, + record, + read: async () => valid, + }); + const final = JSON.parse( + await readFile(join(directory, "0005.json"), "utf8") + ); + expect(final.stage).toBe("copy-qualified"); + const captured = JSON.parse( + await readFile(join(directory, "0004.json"), "utf8") + ); + expect(Buffer.from(captured.copyBase64, "base64").toString()).toBe(valid); + expect(JSON.stringify(final)).not.toContain(valid); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From 94b5a5a2edf0b62d8b6ece6e70f81c3dad428c62 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:30:21 -0400 Subject: [PATCH 19/26] test: preserve single-object build cleanup and lint continuity --- src/lib/native-compose-adoption-generation.ts | 35 ++++++++++++++----- src/lib/native-compose-adoption-plan.ts | 2 +- .../native-compose-adoption-worktrees.ts | 12 +++---- ...ve-compose-adoption-build-evidence.test.ts | 9 +++-- ...ive-compose-adoption-build-fixture.test.ts | 25 ++++++++++--- 5 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 82b968dd3..b45fca7f5 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -802,6 +802,26 @@ function manifestVersion( } return projection.projectionProof.projection_version === 2 ? 4 : 3; } +function preparedManifestVersion(opts: { + readonly build: boolean; + readonly retainedPlan: LegacyComposeRetainedPlan; + readonly binding: LegacyComposeVerifiedBinding; + readonly projection?: { + readonly projectionProof: { readonly projection_version: number }; + }; +}): Manifest["adoption_generation_version"] { + if (opts.build) { + return 9; + } + if (opts.retainedPlan.requiresV7) { + return 7; + } + return manifestVersion( + opts.binding, + opts.retainedPlan.requiresV5, + opts.projection + ); +} async function prepare( ctx: Context, binary: string | undefined @@ -889,15 +909,12 @@ async function prepare( await originals.file.sync(); await originals.file.close(); const meta: Manifest = { - adoption_generation_version: acquired.build - ? 9 - : retainedPlan.requiresV7 - ? 7 - : manifestVersion( - acquired.binding, - retainedPlan.requiresV5, - acquired.projection - ), + adoption_generation_version: preparedManifestVersion({ + build: Boolean(acquired.build), + retainedPlan, + binding: acquired.binding, + projection: acquired.projection, + }), kind: KIND, projectRoot: ctx.root, id, diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index f6d7862e7..4ddf0caaa 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -7,8 +7,8 @@ import { import { freezeImportValue, mapLegacyNativeAdoptionBaseline, - mapLegacyNativeRetainedBasicBuild, mapLegacyNativeCompletedJobAdoptionBaseline, + mapLegacyNativeRetainedBasicBuild, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import { diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 09e7d829c..04196f781 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -25,6 +25,12 @@ import { type Scenario, type ScenarioContext, } from "../harness.ts"; +import { + createRetainedBuildFixtureEvidence, + qualifyRetainedBuildFixtureBuilder, + qualifyRetainedBuildFixtureCopy, + RETAINED_BUILD_BOOTSTRAP_ENV, +} from "./native-compose-adoption-build-evidence.ts"; import { assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, @@ -43,12 +49,6 @@ import { retainedBuildFixtureObjectGraph, retainedBuildFixtureSourceSnapshot, } from "./native-compose-adoption-build-inputs.ts"; -import { - createRetainedBuildFixtureEvidence, - qualifyRetainedBuildFixtureBuilder, - qualifyRetainedBuildFixtureCopy, - RETAINED_BUILD_BOOTSTRAP_ENV, -} from "./native-compose-adoption-build-evidence.ts"; import { adoptionDependencyHealthcheck, assertAdoptionDependencyControl, diff --git a/tests/native-compose-adoption-build-evidence.test.ts b/tests/native-compose-adoption-build-evidence.test.ts index 1f16eb576..050db0b83 100644 --- a/tests/native-compose-adoption-build-evidence.test.ts +++ b/tests/native-compose-adoption-build-evidence.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { lstat, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -123,10 +123,9 @@ for (const mode of ["root-specific", "hack-default"] as const) { expect((await lstat(join(directory, paths[1] ?? ""))).mode & 0o777).toBe( 0o600 ); - const valid = - retainedBuildFixtureCopiedFiles(mode) - .map((item) => `${item.path} ${item.hash}`) - .join("\n") + "\n"; + const valid = `${retainedBuildFixtureCopiedFiles(mode) + .map((item) => `${item.path} ${item.hash}`) + .join("\n")}\n`; await qualifyRetainedBuildFixtureCopy({ mode, record, diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 907446453..1d154a990 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -438,9 +438,11 @@ test("explicit builder graph captures only the owned child-to-base chain", () => ); }); -function buildCleanupModel() { +function buildCleanupModel(opts?: { readonly withoutExposedParent: true }) { const { selected, values, capture } = capturedBuildGraph(); - const objects = capture(); + const objects = opts?.withoutExposedParent + ? capture([{ ...values[0], parent: "" }]) + : capture(); const instance = { root: "/owned/fixture", name: "fixture", @@ -452,8 +454,8 @@ function buildCleanupModel() { let daemon = '"fixture-engine"'; let afterJournal: (() => void) | undefined; let afterEffect: (() => void) | undefined; - const current = new Map(values.map((row) => [row.id, row])); - const opts = { + const current = new Map(objects.map((row) => [row.id, row])); + const controls = { engineId: daemon, originalImageIds: [baseBuildImage], baseImage: baseBuildImage, @@ -527,7 +529,7 @@ function buildCleanupModel() { }, }; return { - opts, + opts: controls, remaining, refs, events, @@ -555,6 +557,19 @@ test("owned image cleanup journals and removes exact children before parents wit ]) ); }); +test("one final image without exposed Parent retains exact facts and only its ID removal authority", async () => { + const model = buildCleanupModel({ withoutExposedParent: true }); + expect([...model.remaining]).toEqual([image]); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual([ + { stage: "before-image-remove" }, + { stage: "remove", id: image }, + { stage: "after-image-remove" }, + ]); + expect(model.current.get(image)?.parent).toBe(""); + expect(model.current.has(baseBuildImage)).toBe(false); +}); for (const mode of [ "reference-after-journal", "daemon-after-journal", From 9736e51431138a51357e74e0cd0391312d585b77 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:57:08 -0400 Subject: [PATCH 20/26] test(runtime): synchronize abandoned relay fence observation --- packages/runtime-core/src/provider/relay_owner/managed.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/relay_owner/managed.rs b/packages/runtime-core/src/provider/relay_owner/managed.rs index 95fc43f6e..976f6eaa3 100644 --- a/packages/runtime-core/src/provider/relay_owner/managed.rs +++ b/packages/runtime-core/src/provider/relay_owner/managed.rs @@ -2361,8 +2361,9 @@ mod tests { .unwrap(); let canceled = Arc::clone(&retry.0.canceled); drop(retry); - assert!(canceled.load(Ordering::Acquire)); + // The reactor may still hold the command clone after delivering its reply. fixture.owner.verify_alive().unwrap(); + assert!(canceled.load(Ordering::Acquire)); assert!(authenticate(&fixture.sockets[0], &fresh.credential).is_err()); assert!( fixture From 0189e97f23e0df0e3518c281fd673911bd740e71 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 21:09:22 -0400 Subject: [PATCH 21/26] test(e2e): expose closed retained-job start substages --- ...native-compose-adoption-job-diagnostics.ts | 122 ++++++++++++++ .../native-compose-adoption-job-worktrees.ts | 63 ++++--- ...e-compose-adoption-job-diagnostics.test.ts | 155 ++++++++++++++++++ 3 files changed, 320 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts create mode 100644 tests/native-compose-adoption-job-diagnostics.test.ts diff --git a/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts new file mode 100644 index 000000000..10ba128d0 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts @@ -0,0 +1,122 @@ +import { isRecord } from "../../../src/lib/guards.ts"; +import type { CliResult } from "../harness.ts"; + +const STAGES = [ + "prior-state", + "cli-result", + "fresh-state", + "fresh-exit", + "pending-clear", + "sql-ready", + "sql-counts", + "sibling-isolation", +] as const; +type Stage = (typeof STAGES)[number]; +const CODES = [ + "E_CONFIG_INVALID", + "E_LIFECYCLE_FAILED", + "E_STATE", + "E_NATIVE_COMPOSE_ADOPTION", + "E_NATIVE_COMPOSE_OWNERSHIP", + "E_NATIVE_COMPOSE_PROBE", + "E_NATIVE_COMPOSE_PROBE_TIMEOUT", +] as const; +const MAX_REPLY_BYTES = 64 * 1024; + +function isStage(value: unknown): value is Stage { + return STAGES.some((stage) => stage === value); +} + +function cliCode(stdout: string): string { + if (Buffer.byteLength(stdout) > MAX_REPLY_BYTES) { + return "unavailable"; + } + try { + const value: unknown = JSON.parse(stdout); + if (!isRecord(value)) { + return "unavailable"; + } + if (value.ok === true) { + return "none"; + } + if (value.ok === false && isRecord(value.error)) { + const error = value.error; + return CODES.find((code) => code === error.code) ?? "unavailable"; + } + } catch { + // A diagnostic cannot replace the original command outcome. + } + return "unavailable"; +} + +/** + * Record only closed start/restart substages and allowlisted CLI codes. No reply + * values, paths, argv, identifiers, arbitrary errors or elapsed-budget claims are + * emitted. Logging failure never replaces the original result or thrown error. + */ +export function createCompletedJobFixtureStartDiagnostics(opts: { + readonly log: (message: string) => void; + readonly operation: unknown; + readonly scope: unknown; +}) { + const log = opts.log; + const operation = + opts.operation === "up" || opts.operation === "restart" + ? opts.operation + : "unavailable"; + const scope = + opts.scope === "alpha" || opts.scope === "beta" + ? opts.scope + : "unavailable"; + const prefix = `start-operation=${operation} worktree=${scope}`; + const emit = (message: string) => { + try { + log(`${prefix} ${message}`); + } catch { + // Diagnostics cannot skip cleanup or replace its original refusal. + } + }; + return Object.freeze({ + step: async ( + stage: unknown, + action: () => T | Promise + ): Promise => { + if (!isStage(stage)) { + return await action(); + } + emit(`stage=${stage} status=begin`); + try { + const value = await action(); + emit(`stage=${stage} status=end`); + return value; + } catch (error) { + emit(`stage=${stage} status=failed`); + throw error; + } + }, + cliOutcome: ( + result: Pick + ) => { + try { + const exit = exitClass(result.exitCode); + const timeout = result.timedOut ? "yes" : "no"; + emit( + `stage=cli-result exit=${exit} timed-out=${timeout} code=${cliCode(result.stdout)}` + ); + } catch { + emit( + "stage=cli-result exit=unavailable timed-out=unavailable code=unavailable" + ); + } + }, + }); +} + +function exitClass(value: unknown): "zero" | "nonzero" | "unavailable" { + if (value === 0) { + return "zero"; + } + return typeof value === "number" && Number.isSafeInteger(value) + ? "nonzero" + : "unavailable"; +} diff --git a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts index e3bc6ce68..0a70476d8 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts @@ -16,6 +16,7 @@ import { type Scenario, type ScenarioContext, } from "../harness.ts"; +import { createCompletedJobFixtureStartDiagnostics } from "./native-compose-adoption-job-diagnostics.ts"; import { completedJobFixtureSources } from "./native-compose-adoption-job-inputs.ts"; import { createAdoptionFixtureProbe, @@ -752,30 +753,48 @@ function runtime(input: Awaited>) { expected: readonly number[], restart = false ) => { - const previous = await state(instance, "seed"); - requireValue(typeof previous.startedAt === "string"); - successful( - await cli( + const diagnostics = createCompletedJobFixtureStartDiagnostics({ + log: ctx.log, + operation: restart ? "restart" : "up", + scope: instance === input.first ? "alpha" : "beta", + }); + const previous = await diagnostics.step("prior-state", async () => { + const value = await state(instance, "seed"); + requireValue(typeof value.startedAt === "string"); + return value; + }); + await diagnostics.step("cli-result", async () => { + const result = await cli( instance, restart ? ["restart", "--json"] : ["up", "--detach", "--json"] - ) + ); + diagnostics.cliOutcome(result); + successful(result); + }); + const current = await diagnostics.step("fresh-state", () => + state(instance, "seed") ); - const current = await state(instance, "seed"); - if (typeof previous.startedAt !== "string") { - refused(); - } - completedJobFixtureFreshExit({ - id: id(instance, "seed"), - priorStartedAt: previous.startedAt, - observed: current, + await diagnostics.step("fresh-exit", () => { + if (typeof previous.startedAt !== "string") { + refused(); + } + completedJobFixtureFreshExit({ + id: id(instance, "seed"), + priorStartedAt: previous.startedAt, + observed: current, + }); }); - await pending(instance, null); - await waitSql( - instance, - "SELECT count(*) FROM app_starts", - String(expected.length) + await diagnostics.step("pending-clear", () => pending(instance, null)); + await diagnostics.step("sql-ready", () => + waitSql( + instance, + "SELECT count(*) FROM app_starts", + String(expected.length) + ) + ); + await diagnostics.step("sql-counts", () => + counts(instance, attempts, expected) ); - await counts(instance, attempts, expected); }; const control = async ( instance: Instance, @@ -1397,7 +1416,11 @@ export const nativeComposeAdoptionJobWorktreesScenario: Scenario = { await h.freshUp(first, 3, [1, 2, 3]); phases.mark("alpha-restart-4"); await h.freshUp(first, 4, [1, 2, 3, 4], true); - await h.counts(second, 1, [1]); + await createCompletedJobFixtureStartDiagnostics({ + log: ctx.log, + operation: "restart", + scope: "alpha", + }).step("sibling-isolation", () => h.counts(second, 1, [1])); phases.mark("alpha-nonzero-job-5"); await h.control(first, "fail"); await stop(h, first); diff --git a/tests/native-compose-adoption-job-diagnostics.test.ts b/tests/native-compose-adoption-job-diagnostics.test.ts new file mode 100644 index 000000000..419d7f1b3 --- /dev/null +++ b/tests/native-compose-adoption-job-diagnostics.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test"; +import { createCompletedJobFixtureStartDiagnostics } from "./e2e/scenarios/native-compose-adoption-job-diagnostics.ts"; + +function diagnostics(messages: string[]) { + return createCompletedJobFixtureStartDiagnostics({ + operation: "restart", + scope: "alpha", + log: (message) => messages.push(message), + }); +} + +test("completed-job start diagnostics distinguish CLI refusal from a later oracle", async () => { + for (const failureStage of ["cli-result", "fresh-exit"] as const) { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + const original = new Error("private-original-refusal-canary"); + const calls: string[] = []; + let observed: unknown; + try { + for (const stage of [ + "cli-result", + "fresh-exit", + "pending-clear", + ] as const) { + await diagnostic.step(stage, () => { + calls.push(stage); + if (stage === failureStage) { + throw original; + } + }); + } + } catch (error) { + observed = error; + } + expect(observed).toBe(original); + expect(calls).toEqual( + failureStage === "cli-result" + ? ["cli-result"] + : ["cli-result", "fresh-exit"] + ); + expect(messages.at(-1)).toBe( + `start-operation=restart worktree=alpha stage=${failureStage} status=failed` + ); + expect(messages.join("\n")).not.toContain("canary"); + expect(messages.join("\n")).not.toContain("pending-clear"); + } +}); + +test("completed-job start diagnostics preserve success order and callback results", async () => { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + const value = { privateValue: "private-result-canary" }; + for (const stage of [ + "prior-state", + "cli-result", + "fresh-state", + "fresh-exit", + "pending-clear", + "sql-ready", + "sql-counts", + "sibling-isolation", + ]) { + expect(await diagnostic.step(stage, () => value)).toBe(value); + expect(messages.slice(-2)).toEqual([ + `start-operation=restart worktree=alpha stage=${stage} status=begin`, + `start-operation=restart worktree=alpha stage=${stage} status=end`, + ]); + } + expect(messages).toHaveLength(16); + expect(messages.join("\n")).not.toContain("private-result-canary"); +}); + +test("completed-job start diagnostics omit reply values and unknown codes or labels", async () => { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + for (const code of ["E_CONFIG_INVALID", "private-code-canary"]) { + diagnostic.cliOutcome({ + exitCode: 17, + timedOut: false, + stdout: JSON.stringify({ + ok: false, + error: { code, message: "private-message-canary" }, + env: "private-env-canary", + argv: ["private-argv-canary"], + }), + }); + } + diagnostic.cliOutcome({ + exitCode: 0, + timedOut: false, + stdout: "private-malformed-canary", + }); + diagnostic.cliOutcome({ + exitCode: 17, + timedOut: true, + stdout: "private-oversized-canary".repeat(10_000), + }); + const unknown = createCompletedJobFixtureStartDiagnostics({ + operation: "private-operation-canary", + scope: "private-scope-canary", + log: (message) => messages.push(message), + }); + let calls = 0; + expect(await unknown.step("private-stage-canary", () => ++calls)).toBe(1); + await unknown.step("fresh-exit", () => ++calls); + expect(calls).toBe(2); + expect(messages[0]).toContain("code=E_CONFIG_INVALID"); + expect(messages[1]).toContain("code=unavailable"); + expect(messages[3]).toContain("timed-out=yes code=unavailable"); + expect(messages.at(-1)).toBe( + "start-operation=unavailable worktree=unavailable stage=fresh-exit status=end" + ); + expect(messages.join("\n")).not.toContain("canary"); +}); + +test("completed-job start diagnostics cannot replace an outcome when the logger throws", async () => { + const diagnostic = createCompletedJobFixtureStartDiagnostics({ + operation: "up", + scope: "beta", + log: () => { + throw new Error("private-logger-canary"); + }, + }); + const original = new Error("private-original-canary"); + expect(await diagnostic.step("prior-state", () => 42)).toBe(42); + let observed: unknown; + try { + await diagnostic.step("cli-result", () => { + diagnostic.cliOutcome({ + exitCode: 17, + timedOut: false, + stdout: "private-reply-canary", + }); + throw original; + }); + } catch (error) { + observed = error; + } + expect(observed).toBe(original); +}); + +test("completed-job CLI diagnostics treat an unreadable reply as unavailable", () => { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + const result = { exitCode: 1, timedOut: false, stdout: "" }; + Object.defineProperty(result, "stdout", { + get: () => { + throw new Error("private-unreadable-reply-canary"); + }, + }); + expect(() => diagnostic.cliOutcome(result)).not.toThrow(); + expect(messages).toEqual([ + "start-operation=restart worktree=alpha stage=cli-result exit=unavailable timed-out=unavailable code=unavailable", + ]); +}); From a372aca9d0dee229cb9e8855e63ed4b2c2841540 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:27:39 -0400 Subject: [PATCH 22/26] test: preserve retained build parent presence and compose labels --- docs/reference/native-compose-adoption.md | 6 +- .../native-compose-adoption-build-inputs.ts | 69 +++++- .../native-compose-adoption-worktrees.ts | 11 + tests/helpers/docker-image-format.ts | 201 ++++++++++++++++++ ...ive-compose-adoption-build-fixture.test.ts | 97 +++++++++ ...tive-compose-adoption-build-format.test.ts | 173 +++++++++++++++ 6 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 tests/helpers/docker-image-format.ts create mode 100644 tests/native-compose-adoption-build-format.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 61bf17f89..4d2fcbc8b 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -247,7 +247,11 @@ image graph after each bootstrap build. Only fixture-labelled, untagged parents on its complete chain to the captured original base qualify for disposal, in child-before-parent order with nonforce `image rm --no-prune`. A builder exposing no parent qualifies only its single final object. Unexplained new images, foreign -labels or references retain the failed fixture. Full original image inventory +labels or references retain the failed fixture. The formatter treats only an +absent `Parent` key as no exposed parent; malformed present values still refuse. +The known Compose project, service and version labels must match the exact +fixture project, `db` service and selected Compose release. Other label names +remain refused. Full original image inventory and tags must be restored; inventory bounds are not relaxed for new objects. General builder cache is retained; cache reclamation and historical image-from-source provenance remain unqualified. Fixture source/model checks alone do not establish live builder or diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts index 4ebbe4892..40e471ad2 100644 --- a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -11,7 +11,7 @@ export type RetainedBuildFixtureMode = "root-specific" | "hack-default"; export const RETAINED_BUILD_BASE_TAG = "postgres:17.6-alpine"; export const RETAINED_BUILD_IMAGE_OWNER = "hack.e2e.retained-build.owner"; export const RETAINED_BUILD_IMAGE_FORMAT = `{"id":{{json .Id}},"created":{{json .Created}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}}}`; -export const RETAINED_BUILD_OBJECT_FORMAT = `{"id":{{json .Id}},"parent":{{json .Parent}},"created":{{json .Created}},"size":{{json .Size}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}},"labelNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}]}`; +export const RETAINED_BUILD_OBJECT_FORMAT = `{"id":{{json .Id}},"parent":{{$parent := ""}}{{range $key, $value := .}}{{if eq $key "Parent"}}{{$parent = $value}}{{end}}{{end}}{{json $parent}},"created":{{json .Created}},"size":{{json .Size}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}},"composeProject":{{json (index .Config.Labels "com.docker.compose.project")}},"composeService":{{json (index .Config.Labels "com.docker.compose.service")}},"composeVersion":{{json (index .Config.Labels "com.docker.compose.version")}},"labelNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}]}`; const ID = /^[a-f0-9]{64}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; const BIRTH = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; @@ -267,20 +267,81 @@ export type RetainedFixtureBuildObject = { readonly tags: readonly string[] | null; readonly digests: readonly string[] | null; readonly labelNames: readonly string[]; + readonly composeProject: string | null; + readonly composeService: "db" | null; + readonly composeVersion: string | null; }; +const COMPOSE_IMAGE_LABELS = [ + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", +] as const; +const COMPOSE_RELEASE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +/** + * Release-only short version matches Compose's public core-version image label. + * https://github.com/docker/compose/blob/v2.40.3/pkg/api/labels.go + * https://github.com/docker/compose/blob/v2.40.3/pkg/compose/build.go + */ +export function retainedBuildFixtureComposeVersion(text: string) { + const version = text.trim(); + if (!COMPOSE_RELEASE.test(version)) { + refuse(); + } + return version; +} +function capturedComposeImageLabels(opts: { + readonly value: Record; + readonly selected: RetainedFixtureImage; + readonly composeVersion?: string; +}) { + const { value, selected, composeVersion } = opts; + const labels = value.labelNames; + if (!Array.isArray(labels)) { + refuse(); + } + const present = COMPOSE_IMAGE_LABELS.filter((name) => labels.includes(name)); + if (present.length === 0) { + if ( + value.composeProject !== null || + value.composeService !== null || + value.composeVersion !== null || + (value.id === selected.id && composeVersion !== undefined) + ) { + refuse(); + } + return { composeProject: null, composeService: null, composeVersion: null }; + } + if ( + present.length !== 3 || + composeVersion === undefined || + !COMPOSE_RELEASE.test(composeVersion) || + value.composeProject !== selected.owner || + value.composeService !== "db" || + value.composeVersion !== composeVersion + ) { + refuse(); + } + return { + composeProject: selected.owner, + composeService: "db" as const, + composeVersion, + }; +} + /** Only exact fixture-labelled objects, never arbitrary dangling images or cache entries. */ export function retainedBuildFixtureObject(opts: { readonly value: unknown; readonly selected: RetainedFixtureImage; readonly originalImageIds: readonly string[]; + readonly composeVersion?: string; }): RetainedFixtureBuildObject { const { value, selected, originalImageIds } = opts; if ( !( isRecord(value) && Object.keys(value).sort().join() === - "created,digests,id,labelNames,owner,parent,size,stage,tags" && + "composeProject,composeService,composeVersion,created,digests,id,labelNames,owner,parent,size,stage,tags" && typeof value.id === "string" && IMAGE.test(value.id) && !originalImageIds.includes(value.id) && @@ -305,12 +366,14 @@ export function retainedBuildFixtureObject(opts: { RETAINED_BUILD_IMAGE_OWNER, "hack.e2e.retained-build.stage", "com.docker.compose.image.builder", + ...COMPOSE_IMAGE_LABELS, ].includes(name) ) ) ) { refuse(); } + const composeLabels = capturedComposeImageLabels({ ...opts, value }); if (value.id === selected.id) { const current = retainedBuildFixtureImage({ value: { @@ -354,6 +417,7 @@ export function retainedBuildFixtureObject(opts: { tags: value.tags === null ? null : Object.freeze([...value.tags]), digests: value.digests === null ? null : Object.freeze([...value.digests]), labelNames: Object.freeze([...value.labelNames].sort()), + ...composeLabels, }); } @@ -368,6 +432,7 @@ export function retainedBuildFixtureObjectGraph(opts: { readonly selected: RetainedFixtureImage; readonly originalImageIds: readonly string[]; readonly baseImage: string; + readonly composeVersion?: string; }): readonly RetainedFixtureBuildObject[] { if ( opts.values.length < 1 || diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 04196f781..904901b99 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -42,6 +42,7 @@ import { type RetainedBuildFixtureMode, type RetainedFixtureBuildObject, type RetainedFixtureImage, + retainedBuildFixtureComposeVersion, retainedBuildFixtureDefinition, retainedBuildFixtureImage, retainedBuildFixtureMarker, @@ -1492,6 +1493,9 @@ async function assertFixtureBuildImages( ), selected: captured, originalImageIds: opts.originalImageIds, + ...(expected.composeVersion !== null + ? { composeVersion: expected.composeVersion } + : {}), }); if (JSON.stringify(current) !== JSON.stringify(expected)) { refused(); @@ -1656,6 +1660,9 @@ async function bootstrapFixtureBuildImage( ) { refused(); } + const composeVersion = retainedBuildFixtureComposeVersion( + await h.probe(["compose", "version", "--short"]) + ); await saveFixtureBuildRecovery(h, { project: instance.name, reference, @@ -1739,6 +1746,7 @@ async function bootstrapFixtureBuildImage( selected: image, originalImageIds: h.originalImageIds, baseImage: h.baseImage, + composeVersion, }); h.builtImages.set(instance, image); h.builtImageObjects.set(instance, objects); @@ -3053,6 +3061,9 @@ async function requireRemainingBuildObjects( ), selected, originalImageIds: h.originalImageIds, + ...(captured.composeVersion !== null + ? { composeVersion: captured.composeVersion } + : {}), }); if ( JSON.stringify(current) !== JSON.stringify(captured) || diff --git a/tests/helpers/docker-image-format.ts b/tests/helpers/docker-image-format.ts new file mode 100644 index 000000000..a34e65947 --- /dev/null +++ b/tests/helpers/docker-image-format.ts @@ -0,0 +1,201 @@ +import { constants } from "node:fs"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { createNativeComposeProbe } from "../../src/lib/native-compose-ownership.ts"; + +export const DOCKER_FORMAT_IMAGE_ID = `sha256:${"c".repeat(64)}`; +const HASH = /^[a-f0-9]{64}$/; +const INSPECT_PATH = `/v1.41/images/${DOCKER_FORMAT_IMAGE_ID}/json`; +function refuse(): never { + throw new Error("Synthetic Docker formatting fixture is unsafe or changed."); +} +function sameIdentity( + a: Awaited>, + b: Awaited> +) { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.mode === b.mode && + a.uid === b.uid && + a.gid === b.gid && + a.nlink === b.nlink && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs + ); +} +async function binaryIdentity(path: string, hash: string) { + if ( + !(isAbsolute(path) && HASH.test(hash)) || + (await realpath(path)) !== path + ) { + refuse(); + } + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const stat = await file.stat(); + if ( + !stat.isFile() || + stat.nlink !== 1 || + (stat.mode & 0o111) === 0 || + (stat.mode & 0o022) !== 0 || + stat.size <= 0 || + stat.size > 128 * 1024 * 1024 + ) { + refuse(); + } + const bytes = await file.readFile(), + current = await file.stat(), + named = await lstat(path); + if ( + bytes.byteLength !== stat.size || + new Bun.CryptoHasher("sha256").update(bytes).digest("hex") !== hash || + !named.isFile() || + named.isSymbolicLink() || + !sameIdentity(stat, current) || + !sameIdentity(stat, named) || + (await realpath(path)) !== path + ) { + refuse(); + } + return stat; + } finally { + await file.close(); + } +} + +/** + * The pinned real Docker client formats only synthetic image inspect JSON from this + * owned Unix socket. No request is forwarded and no caller config is inherited. + * The existing bounded probe settles each client and both output pipes. + */ +export async function withDockerImageFormatFixture(opts: { + readonly binary: string; + readonly sha256: string; + readonly image: Readonly>; + readonly observe: (probe: (format: string) => Promise) => Promise; +}): Promise { + const binary = opts.binary, + sha256 = opts.sha256, + response = JSON.stringify(opts.image), + observe = opts.observe; + if (response.length > 64 * 1024) { + refuse(); + } + const anchor = await binaryIdentity(binary, sha256); + const root = await realpath(await mkdtemp(join(tmpdir(), "docker-fmt-"))); + await chmod(root, 0o700); + const rootIdentity = await lstat(root), + config = join(root, "config"), + socket = join(root, "engine.sock"), + alias = join(root, "docker"); + const assertRoot = async () => { + const current = await lstat(root); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== rootIdentity.dev || + current.ino !== rootIdentity.ino || + current.uid !== rootIdentity.uid || + current.mode !== rootIdentity.mode || + (await realpath(root)) !== root + ) { + refuse(); + } + }; + let server: ReturnType | undefined; + let unexpected = false; + try { + await mkdir(config, { mode: 0o700 }); + await writeFile(join(config, "config.json"), "{}", { + mode: 0o600, + flag: "wx", + }); + await symlink(binary, alias); + server = Bun.serve({ + unix: socket, + fetch(request) { + const url = new URL(request.url); + if ( + url.pathname === "/_ping" && + ["HEAD", "GET"].includes(request.method) + ) { + return new Response("OK", { + headers: { "Api-Version": "1.41", Ostype: "linux" }, + }); + } + if (request.method === "GET" && url.pathname === INSPECT_PATH) { + return new Response(response, { + headers: { "Content-Type": "application/json" }, + }); + } + unexpected = true; + return new Response("Synthetic fixture refuses this request", { + status: 403, + }); + }, + }); + const socketIdentity = await lstat(socket), + environment = process.env; + let owner: ReturnType; + try { + // The owner captures this selection synchronously before returning. + process.env = { + PATH: root, + HOME: root, + DOCKER_CONFIG: config, + DOCKER_HOST: `unix://${socket}`, + DOCKER_API_VERSION: "1.41", + }; + owner = createNativeComposeProbe({ timeoutMs: 15_000 }); + } finally { + process.env = environment; + } + const result = await observe(async (format) => { + await assertRoot(); + const selected = await lstat(socket); + if ( + !selected.isSocket() || + selected.dev !== socketIdentity.dev || + selected.ino !== socketIdentity.ino || + (await realpath(alias)) !== binary + ) { + refuse(); + } + return await owner([ + "image", + "inspect", + "--format", + format, + DOCKER_FORMAT_IMAGE_ID, + ]); + }); + await assertRoot(); + if ( + unexpected || + !sameIdentity(anchor, await binaryIdentity(binary, sha256)) + ) { + refuse(); + } + return result; + } finally { + await server?.stop(true); + await assertRoot(); + await rm(root, { recursive: true }); + } +} diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 1d154a990..cf4f9cb5d 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -21,6 +21,7 @@ import { prepareRetainedBuildFixtureSources, RETAINED_BUILD_OBJECT_FORMAT, retainedBuildFixtureCopiedFiles, + retainedBuildFixtureComposeVersion, retainedBuildFixtureDefinition, retainedBuildFixtureImage, retainedBuildFixtureMutationAllowed, @@ -355,6 +356,11 @@ test("fixture image capture pins the exact observed same-ID repository digest", const baseBuildImage = `sha256:${"1".repeat(64)}`; const firstBuildParent = `sha256:${"2".repeat(64)}`; const secondBuildParent = `sha256:${"3".repeat(64)}`; +const noComposeImageLabels = { + composeProject: null, + composeService: null, + composeVersion: null, +}; function capturedBuildGraph() { const selected = retainedBuildFixtureImage({ value: { ...imageRow, digests: [`fixture-db@${image}`] }, @@ -369,6 +375,7 @@ function capturedBuildGraph() { const values = [ { ...imageRow, + ...noComposeImageLabels, parent: secondBuildParent, size: 1000, digests: [`fixture-db@${image}`], @@ -376,6 +383,7 @@ function capturedBuildGraph() { }, { ...imageRow, + ...noComposeImageLabels, id: firstBuildParent, parent: baseBuildImage, size: 800, @@ -385,6 +393,7 @@ function capturedBuildGraph() { }, { ...imageRow, + ...noComposeImageLabels, id: secondBuildParent, parent: firstBuildParent, size: 900, @@ -438,6 +447,94 @@ test("explicit builder graph captures only the owned child-to-base chain", () => ); }); +test("captured Compose image labels require the exact project, service and selected release", () => { + const { selected, values } = capturedBuildGraph(); + const composeVersion = retainedBuildFixtureComposeVersion("2.40.3\n"); + const publicLabels = [ + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", + ]; + const row = { + ...values[0], + parent: "", + composeProject: "fixture", + composeService: "db", + composeVersion, + labelNames: [...values[0].labelNames, ...publicLabels], + }; + const capture = ( + value: unknown, + version: string | undefined = composeVersion + ) => + retainedBuildFixtureObjectGraph({ + values: [value], + selected, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + ...(version !== undefined ? { composeVersion: version } : {}), + }); + const captured = capture(row); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + id: selected.id, + parent: "", + created: selected.created, + owner: selected.owner, + composeProject: "fixture", + composeService: "db", + composeVersion, + tags: row.tags, + digests: row.digests, + }); + for (const malformed of [ + { ...row, composeProject: CANARY }, + { ...row, composeService: "worker" }, + { ...row, composeVersion: "2.40.4" }, + { ...row, composeVersion: null }, + { + ...row, + labelNames: row.labelNames.filter((name) => name !== publicLabels[2]), + }, + { + ...row, + labelNames: [...row.labelNames, "com.docker.compose.private-canary"], + }, + { ...row, labelNames: [...row.labelNames, CANARY] }, + { ...row, ...noComposeImageLabels, labelNames: values[0].labelNames }, + ]) { + let error: unknown; + try { + capture(malformed); + } catch (caught: unknown) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("values omitted"); + expect(String(error)).not.toContain(CANARY); + } + expect(() => capture(row, "2.40.4")).toThrow("values omitted"); + expect(() => + retainedBuildFixtureObjectGraph({ + values: [row], + selected, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + }) + ).toThrow("values omitted"); + for (const version of [ + "v2.40.3", + "2.40.3-rc.1", + "2.40.3+local", + "02.40.3", + CANARY, + ]) { + expect(() => retainedBuildFixtureComposeVersion(version)).toThrow( + "values omitted" + ); + } +}); + function buildCleanupModel(opts?: { readonly withoutExposedParent: true }) { const { selected, values, capture } = capturedBuildGraph(); const objects = opts?.withoutExposedParent diff --git a/tests/native-compose-adoption-build-format.test.ts b/tests/native-compose-adoption-build-format.test.ts new file mode 100644 index 000000000..633aad9a9 --- /dev/null +++ b/tests/native-compose-adoption-build-format.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test"; +import { isRecord } from "../src/lib/guards.ts"; +import { nativeComposeProbeFailure } from "../src/lib/native-compose-ownership.ts"; +import { + RETAINED_BUILD_OBJECT_FORMAT, + retainedBuildFixtureImage, + retainedBuildFixtureObjectGraph, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { + DOCKER_FORMAT_IMAGE_ID, + withDockerImageFormatFixture, +} from "./helpers/docker-image-format.ts"; + +const binary = process.env.HACK_TEST_DOCKER_FORMAT_BINARY, + sha256 = process.env.HACK_TEST_DOCKER_FORMAT_SHA256; +const baseImage = `sha256:${"a".repeat(64)}`; +const composeVersion = "2.40.3"; +const image = { + Id: DOCKER_FORMAT_IMAGE_ID, + Created: "2026-10-08T20:00:00.123456789Z", + Size: 1000, + RepoTags: ["fixture-db:latest"], + RepoDigests: [`fixture-db@${DOCKER_FORMAT_IMAGE_ID}`], + Config: { + Labels: { + "hack.e2e.retained-build.owner": "fixture", + "hack.e2e.retained-build.stage": "retained", + "com.docker.compose.project": "fixture", + "com.docker.compose.service": "db", + "com.docker.compose.version": composeVersion, + }, + }, + RootFS: { Type: "layers", Layers: [`sha256:${"d".repeat(64)}`] }, +}; +const selected = retainedBuildFixtureImage({ + value: { + id: image.Id, + created: image.Created, + owner: "fixture", + stage: "retained", + tags: image.RepoTags, + digests: image.RepoDigests, + }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [baseImage], +}); +function capture(value: unknown) { + return retainedBuildFixtureObjectGraph({ + values: [value], + selected, + originalImageIds: [baseImage], + baseImage, + composeVersion, + }); +} + +test.skipIf(binary === undefined && sha256 === undefined)( + "real pinned Docker client qualifies only absent image Parent with exact Compose labels", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image, + observe: async (probe) => { + const old = RETAINED_BUILD_OBJECT_FORMAT.replace( + '{{$parent := ""}}{{range $key, $value := .}}{{if eq $key "Parent"}}{{$parent = $value}}{{end}}{{end}}{{json $parent}}', + "{{json .Parent}}" + ); + expect(old).not.toBe(RETAINED_BUILD_OBJECT_FORMAT); + let failure: unknown; + try { + await probe(old); + } catch (error: unknown) { + failure = error; + } + expect(nativeComposeProbeFailure(failure)).toBe("child"); + const value: unknown = JSON.parse( + await probe(RETAINED_BUILD_OBJECT_FORMAT) + ); + expect(capture(value)).toEqual([ + { + id: image.Id, + parent: "", + created: image.Created, + size: image.Size, + owner: "fixture", + stage: "retained", + tags: image.RepoTags, + digests: image.RepoDigests, + labelNames: Object.keys(image.Config.Labels).sort(), + composeProject: "fixture", + composeService: "db", + composeVersion, + }, + ]); + }, + }); + }, + 20_000 +); + +test + .skipIf(binary === undefined && sha256 === undefined) + .each([ + { parent: null }, + { parent: false }, + { parent: 0 }, + { parent: [] }, + { parent: {} }, + ])( + "real client preserves or refuses malformed present Parent without empty normalization: %j", + async ({ parent }) => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image: { ...image, Parent: parent }, + observe: async (probe) => { + let text: string; + try { + text = await probe(RETAINED_BUILD_OBJECT_FORMAT); + } catch (error: unknown) { + if (parent === null) { + throw error; + } + expect(nativeComposeProbeFailure(error)).toBe("child"); + return; + } + const value: unknown = JSON.parse(text); + if (!isRecord(value)) { + throw new Error("Synthetic image projection is malformed"); + } + expect(value.parent).toEqual(parent); + expect(value.parent).not.toBe(""); + expect(() => capture(value)).toThrow("values omitted"); + }, + }); + }, + 20_000 +); + +test.skipIf(binary === undefined && sha256 === undefined)( + "real client keeps an explicit empty image Parent and exact birth, tag and digest", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image: { ...image, Parent: "" }, + observe: async (probe) => { + expect( + capture(JSON.parse(await probe(RETAINED_BUILD_OBJECT_FORMAT)))[0] + ).toMatchObject({ + id: selected.id, + parent: "", + created: selected.created, + owner: selected.owner, + tags: image.RepoTags, + digests: image.RepoDigests, + }); + }, + }); + }, + 20_000 +); From 7f754763176663a6e59afcdd0b83481ba290d4ce Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:31:29 -0400 Subject: [PATCH 23/26] test: recheck captured compose release during image cleanup --- docs/reference/native-compose-adoption.md | 5 +- .../native-compose-adoption-worktrees.ts | 4 ++ ...ive-compose-adoption-build-fixture.test.ts | 72 ++++++++++++++++++- ...tive-compose-adoption-build-format.test.ts | 2 +- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 4d2fcbc8b..0c78aa1b4 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -247,8 +247,9 @@ image graph after each bootstrap build. Only fixture-labelled, untagged parents on its complete chain to the captured original base qualify for disposal, in child-before-parent order with nonforce `image rm --no-prune`. A builder exposing no parent qualifies only its single final object. Unexplained new images, foreign -labels or references retain the failed fixture. The formatter treats only an -absent `Parent` key as no exposed parent; malformed present values still refuse. +labels or references retain the failed fixture. Only an absent `Parent` key +defaults to empty; an explicit empty parent is preserved and malformed present +values still refuse. The known Compose project, service and version labels must match the exact fixture project, `db` service and selected Compose release. Other label names remain refused. Full original image inventory diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 904901b99..55f1d7f90 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -3114,6 +3114,9 @@ export async function cleanupRetainedBuildFixtureImages(h: BuildCleanupInputs) { } for (const [instance, objects] of h.builtImageObjects) { const selected = h.builtImages.get(instance); + const composeVersion = selected + ? objects.find((row) => row.id === selected.id)?.composeVersion + : undefined; if ( !selected || JSON.stringify( @@ -3122,6 +3125,7 @@ export async function cleanupRetainedBuildFixtureImages(h: BuildCleanupInputs) { selected, originalImageIds: h.originalImageIds, baseImage: h.baseImage, + ...(typeof composeVersion === "string" ? { composeVersion } : {}), }) ) !== JSON.stringify(objects) ) { diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index cf4f9cb5d..3af4af0f0 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -404,13 +404,15 @@ function capturedBuildGraph() { ] as const; const capture = ( rows: readonly unknown[] = values, - originals = [baseBuildImage] + originals = [baseBuildImage], + composeVersion?: string ) => retainedBuildFixtureObjectGraph({ values: rows, selected, originalImageIds: originals, baseImage: baseBuildImage, + ...(composeVersion !== undefined ? { composeVersion } : {}), }); return { selected, values, capture }; } @@ -535,10 +537,35 @@ test("captured Compose image labels require the exact project, service and selec } }); -function buildCleanupModel(opts?: { readonly withoutExposedParent: true }) { +function buildCleanupModel(opts?: { + readonly withoutExposedParent?: true; + readonly composeVersion?: string; +}) { const { selected, values, capture } = capturedBuildGraph(); const objects = opts?.withoutExposedParent - ? capture([{ ...values[0], parent: "" }]) + ? capture( + [ + { + ...values[0], + parent: "", + ...(opts.composeVersion + ? { + composeProject: "fixture", + composeService: "db", + composeVersion: opts.composeVersion, + labelNames: [ + ...values[0].labelNames, + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", + ], + } + : {}), + }, + ], + [baseBuildImage], + opts.composeVersion + ) : capture(); const instance = { root: "/owned/fixture", @@ -667,6 +694,45 @@ test("one final image without exposed Parent retains exact facts and only its ID expect(model.current.get(image)?.parent).toBe(""); expect(model.current.has(baseBuildImage)).toBe(false); }); +test("full captured Compose image triplet revalidates through exact nonforce cleanup", async () => { + const model = buildCleanupModel({ + withoutExposedParent: true, + composeVersion: "2.40.3", + }); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual([ + { stage: "before-image-remove" }, + { stage: "remove", id: image }, + { stage: "after-image-remove" }, + ]); + expect(model.current.get(image)).toMatchObject({ + composeProject: "fixture", + composeService: "db", + composeVersion: "2.40.3", + id: image, + parent: "", + }); + expect(model.current.has(baseBuildImage)).toBe(false); +}); +test("full captured Compose image triplet drift after journal permits no cleanup effect", async () => { + const model = buildCleanupModel({ + withoutExposedParent: true, + composeVersion: "2.40.3", + }); + model.setAfterJournal(() => { + const row = model.current.get(image); + if (!row) { + throw new Error("Missing fixed image row"); + } + model.current.set(image, { ...row, composeVersion: "2.40.4" }); + }); + await expect(cleanupRetainedBuildFixtureImages(model.opts)).rejects.toThrow( + "values omitted" + ); + expect(model.events).toEqual([{ stage: "before-image-remove" }]); + expect([...model.remaining]).toEqual([image]); +}); for (const mode of [ "reference-after-journal", "daemon-after-journal", diff --git a/tests/native-compose-adoption-build-format.test.ts b/tests/native-compose-adoption-build-format.test.ts index 633aad9a9..69ad05acc 100644 --- a/tests/native-compose-adoption-build-format.test.ts +++ b/tests/native-compose-adoption-build-format.test.ts @@ -56,7 +56,7 @@ function capture(value: unknown) { } test.skipIf(binary === undefined && sha256 === undefined)( - "real pinned Docker client qualifies only absent image Parent with exact Compose labels", + "real pinned Docker client accepts absent image Parent with exact Compose labels", async () => { if (!(binary && sha256)) { throw new Error("Explicit pinned Docker format client is required"); From 41ff78598047ad4c46c23357d448489132d1652d Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:40:05 -0400 Subject: [PATCH 24/26] test: sort retained build fixture imports --- tests/native-compose-adoption-build-fixture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts index 3af4af0f0..fb3882609 100644 --- a/tests/native-compose-adoption-build-fixture.test.ts +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -20,8 +20,8 @@ import { assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, RETAINED_BUILD_OBJECT_FORMAT, - retainedBuildFixtureCopiedFiles, retainedBuildFixtureComposeVersion, + retainedBuildFixtureCopiedFiles, retainedBuildFixtureDefinition, retainedBuildFixtureImage, retainedBuildFixtureMutationAllowed, From 0757e88e92d2d307a09f71312b3fad093c256a49 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 23:28:22 -0400 Subject: [PATCH 25/26] test: preserve retained build recovery-stop admission --- docs/reference/native-compose-adoption.md | 6 + ...tive-compose-adoption-build-diagnostics.ts | 178 +++++++++++ .../native-compose-adoption-build-inputs.ts | 8 +- .../native-compose-adoption-worktrees.ts | 32 +- ...compose-adoption-build-diagnostics.test.ts | 242 +++++++++++++++ ...-adoption-build-recovery-transport.test.ts | 280 ++++++++++++++++++ 6 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts create mode 100644 tests/native-compose-adoption-build-diagnostics.test.ts create mode 100644 tests/native-compose-adoption-build-recovery-transport.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 0c78aa1b4..a9b9fd75d 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -239,6 +239,12 @@ contexts, and then permits only captured metadata queries and journaled original starts/stops. Format switch and saved consumption cannot reach a builder. Its source and candidate drift controls retain pending ownership, preserve the other worktree's SQL row, and restore both original configurations through rollback. +The fixture issues a recovery-stop transport capability only for the exact +`down --recover --json` invocation. It can stop the original IDs while the active +generation retains its interrupted start journal; publication, generation, +complete service selection and daemon checks still precede each effect. Private +per-invocation evidence records closed operation/substage names and CLI exit/code +classifications before caller assertions, without reply values or arbitrary errors. Exact fixture image removal requires its captured new ID/birth, sole tag, fixture label, unchanged daemon and no remaining container references. A local final image may expose one digest for that exact repository and captured image ID; diff --git a/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts b/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts new file mode 100644 index 000000000..cacdd9232 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts @@ -0,0 +1,178 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import type { CliResult } from "../harness.ts"; + +const CODES = [ + "E_CONFIG_INVALID", + "E_LIFECYCLE_FAILED", + "E_STATE", + "E_NATIVE_COMPOSE_ADOPTION", + "E_NATIVE_COMPOSE_OWNERSHIP", + "E_NATIVE_COMPOSE_PROBE", + "E_NATIVE_COMPOSE_PROBE_TIMEOUT", +] as const; +type Context = { + readonly tempRoot: string; + readonly log?: (message: string) => void; +}; +type Outcome = Pick; +const owners = new WeakMap>(); + +function operation(args: readonly string[], drift: boolean) { + const joined = JSON.stringify(args); + const operations = [ + [["config", "adopt", "--dry-run", "--stop", "--json"], "preview"], + [["config", "adopt", "--stop", "--json"], "adopt-stop"], + [ + ["config", "adopt", "--recover", "--stop", "--json"], + "adopt-recover-stop", + ], + [["config", "adopt", "--rollback", "--json"], "rollback"], + [["up", "db", "--detach", "--json"], "partial-selection"], + [ + ["up", "--detach", "--json"], + drift ? "start-with-context-drift" : "start", + ], + [["down", "--recover", "--json"], "recover-stop"], + [["down", "--json"], "stop"], + [["run", "db", "--", "true"], "run-refusal"], + ] as const; + return ( + operations.find(([expected]) => JSON.stringify(expected) === joined)?.[1] ?? + "unavailable" + ); +} + +function code(stdout: string) { + if (Buffer.byteLength(stdout) > 65_536) { + return "unavailable"; + } + try { + const value: unknown = JSON.parse(stdout); + if (!isRecord(value)) { + return "unavailable"; + } + if (value.ok === true) { + return "none"; + } + if (value.ok === false && isRecord(value.error)) { + const error = value.error; + return CODES.find((known) => known === error.code) ?? "unavailable"; + } + return "unavailable"; + } catch { + return "unavailable"; + } +} +function outcome(result: Outcome) { + try { + const exitCode = + Number.isSafeInteger(result.exitCode) && + result.exitCode >= 0 && + result.exitCode <= 255 + ? result.exitCode + : "unavailable"; + return { + exitCode, + timedOut: result.timedOut === true, + code: code(result.stdout), + readGuardRefused: result.stderr.includes( + "retained-build-refused stage=read-admission code=93" + ), + mutationGuardRefused: result.stderr.includes( + "retained-build-refused stage=mutation-admission code=94" + ), + }; + } catch { + return { + exitCode: "unavailable", + timedOut: "unavailable", + code: "unavailable", + readGuardRefused: "unavailable", + mutationGuardRefused: "unavailable", + }; + } +} +function recorder(context: Context) { + const root = join(context.tempRoot, "retained-build-cli"); + const started = performance.now(); + let count = 0; + let invocation = 0; + return async (opts: { + readonly mode: unknown; + readonly args: readonly string[]; + readonly driftAfterStart: boolean; + readonly run: () => Promise; + }): Promise => { + const ordinal = ++invocation; + const mode = + opts.mode === "root-specific" || opts.mode === "hack-default" + ? opts.mode + : "unavailable"; + const selected = operation(opts.args, opts.driftAfterStart); + const emit = async ( + stage: "begin" | "result" | "settled" | "thrown", + detail?: ReturnType + ) => { + const row = { + evidence_version: 1, + invocation: ordinal, + mode, + operation: selected, + stage, + elapsedMs: Math.round(performance.now() - started), + ...detail, + }; + try { + const index = ++count; + if (index > 512) { + return; + } + await mkdir(root, { mode: 0o700, recursive: true }); + await writeFile( + join(root, `${String(index).padStart(4, "0")}.json`), + `${JSON.stringify(row)}\n`, + { mode: 0o600, flag: "wx" } + ); + } catch { + // Evidence failure cannot replace the original result or error. + } + try { + context.log?.(`retained-build-cli ${JSON.stringify(row)}`); + } catch { + // Logging cannot skip cleanup or change admission. + } + }; + await emit("begin"); + try { + const result = await opts.run(); + await emit("result", outcome(result)); + await emit("settled"); + return result; + } catch (error) { + await emit("thrown"); + throw error; + } + }; +} + +/** + * Fixture-only fixed CLI substages and bounded exit/error classifications. No + * argv, paths, response values or arbitrary errors are retained. Diagnostic + * failure preserves the original result/error and never changes an effect gate. + */ +export async function observeRetainedBuildFixtureCli(opts: { + readonly context: Context; + readonly mode: unknown; + readonly args: readonly string[]; + readonly driftAfterStart: boolean; + readonly run: () => Promise; +}): Promise { + let owner = owners.get(opts.context); + if (!owner) { + owner = recorder(opts.context); + owners.set(opts.context, owner); + } + return await owner(opts); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts index 40e471ad2..d7440b26b 100644 --- a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -558,6 +558,7 @@ export function retainedBuildFixtureMutationAllowed(opts: { readonly receipt: unknown; readonly ids: readonly string[]; readonly services: readonly string[]; + readonly recoverPendingStartStop?: true; }) { const { args, receipt, ids, services } = opts; if ( @@ -575,7 +576,12 @@ export function retainedBuildFixtureMutationAllowed(opts: { isRecord(receipt) && receipt.adoption_receipt_version === 9 && isRecord(receipt.pendingOperation) && - receipt.pendingOperation.operation === args[1] && + (receipt.pendingOperation.operation === args[1] || + (opts.recoverPendingStartStop === true && + args[1] === "stop" && + receipt.pendingOperation.operation === "start" && + isRecord(receipt.publication) && + receipt.publication.phase === "active")) && Array.isArray(receipt.pendingOperation.services) && JSON.stringify([...receipt.pendingOperation.services].sort()) === JSON.stringify([...services].sort()) && diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 55f1d7f90..ce092fc87 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -31,6 +31,7 @@ import { qualifyRetainedBuildFixtureCopy, RETAINED_BUILD_BOOTSTRAP_ENV, } from "./native-compose-adoption-build-evidence.ts"; +import { observeRetainedBuildFixtureCli } from "./native-compose-adoption-build-diagnostics.ts"; import { assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, @@ -2578,7 +2579,8 @@ type BuildFixtureTransport = Pick< FixtureRuntime, "engine" | "engineId" | "baseImage" | "anchors" | "builtImages" | "cli" > & { - readonly ctx: Pick; + readonly ctx: Pick & + Partial>; }; function fixtureBuildScope(h: BuildFixtureTransport, instance: Instance) { const anchor = h.anchors.get(instance); @@ -2606,14 +2608,15 @@ function fixtureBuildScope(h: BuildFixtureTransport, instance: Instance) { function buildMutationGuard( h: BuildFixtureTransport, instance: Instance, - receipt: string + receipt: string, + recoverPendingStartStop = false ) { const helper = fileURLToPath( new URL("./native-compose-adoption-build-inputs.ts", import.meta.url) ); return `const {retainedBuildFixtureMutationAllowed}=await import(${JSON.stringify(helper)}); const mutationReceipt=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); -if(!retainedBuildFixtureMutationAllowed({args,receipt:mutationReceipt,ids:${JSON.stringify(fixtureBuildScope(h, instance).containerIds)},services:['db','worker']})) {console.error('retained-build-refused stage=mutation-admission code=94');process.exit(94);}`; +if(!retainedBuildFixtureMutationAllowed({args,receipt:mutationReceipt,ids:${JSON.stringify(fixtureBuildScope(h, instance).containerIds)},services:['db','worker']${recoverPendingStartStop ? ",recoverPendingStartStop:true" : ""}})) {console.error('retained-build-refused stage=mutation-admission code=94');process.exit(94);}`; } function buildReadGuard( h: BuildFixtureTransport, @@ -2641,6 +2644,12 @@ export async function buildFixtureCli( args: readonly string[], driftAfterStart = false ) { + const invocation = Object.freeze([...args]); + const recoverPendingStartStop = + invocation.length === 3 && + invocation[0] === "down" && + invocation[1] === "--recover" && + invocation[2] === "--json"; if (!instance.basicBuild) { refused(); } @@ -2649,8 +2658,8 @@ export async function buildFixtureCli( ".hack/.internal/legacy-compose-adoption-v1/receipt.json" ); const firstPrepare = - args[0] === "config" && - args[1] === "adopt" && + invocation[0] === "config" && + invocation[1] === "adopt" && !(await Bun.file(receipt).exists()) ? await captureAdoptionDependencyFirstPrepare({ projectRoot: instance.root, @@ -2668,7 +2677,7 @@ export async function buildFixtureCli( `#!${process.execPath} const args=process.argv.slice(2); const engine=${JSON.stringify(h.engine)}; if(args[0]==='container' && ['start','stop'].includes(args[1])) { - ${buildMutationGuard(h, instance, receipt)} + ${buildMutationGuard(h, instance, receipt, recoverPendingStartStop)} ${dependencyEngineCheck(h)} const child=Bun.spawn([engine,...args],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); const code=await child.exited; @@ -2680,8 +2689,15 @@ const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr: ` ); await chmod(shim, 0o700); - const result = await h.cli(instance, args, { - PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, + const result = await observeRetainedBuildFixtureCli({ + context: h.ctx, + mode: instance.basicBuild, + args: invocation, + driftAfterStart, + run: () => + h.cli(instance, invocation, { + PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }), }); if (driftAfterStart) { assertAdoptionDependencyControl({ diff --git a/tests/native-compose-adoption-build-diagnostics.test.ts b/tests/native-compose-adoption-build-diagnostics.test.ts new file mode 100644 index 000000000..10d4d5fdd --- /dev/null +++ b/tests/native-compose-adoption-build-diagnostics.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { observeRetainedBuildFixtureCli } from "./e2e/scenarios/native-compose-adoption-build-diagnostics.ts"; + +const CANARY = "private-build-cli-diagnostic-canary"; +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-cli-diagnostic-")) + ); + await chmod(root, 0o700); + const messages: string[] = []; + const context = { + tempRoot: root, + log: (message: string) => messages.push(message), + }; + return { + root, + messages, + context, + close: () => rm(root, { recursive: true, force: true }), + }; +} +function observation< + T extends { + exitCode: number; + timedOut: boolean; + stdout: string; + stderr: string; + }, +>(context: { tempRoot: string; log?: (message: string) => void }, value: T) { + return observeRetainedBuildFixtureCli({ + context, + mode: "root-specific", + args: ["down", "--recover", "--json"], + driftAfterStart: false, + run: async () => value, + }); +} +const refused = { + exitCode: 94, + timedOut: false, + stdout: JSON.stringify({ + ok: false, + error: { code: "E_STATE", message: CANARY }, + private: CANARY, + }), + stderr: "retained-build-refused stage=mutation-admission code=94\n", +}; + +test("retained build CLI captures a nonzero result before caller assertions and preserves result identity", async () => { + const h = await fixture(); + try { + expect(await observation(h.context, refused)).toBe(refused); + const directory = join(h.root, "retained-build-cli"); + const files = (await readdir(directory)).sort(); + expect(files).toEqual(["0001.json", "0002.json", "0003.json"]); + const rows = await Promise.all( + files.map(async (file) => { + const path = join(directory, file); + expect((await stat(path)).mode & 0o777).toBe(0o600); + return JSON.parse(await readFile(path, "utf8")); + }) + ); + expect(rows.map((row) => row.stage)).toEqual([ + "begin", + "result", + "settled", + ]); + expect(rows[1]).toMatchObject({ + invocation: 1, + mode: "root-specific", + operation: "recover-stop", + exitCode: 94, + timedOut: false, + code: "E_STATE", + readGuardRefused: false, + mutationGuardRefused: true, + }); + expect( + rows.every( + (row) => Number.isSafeInteger(row.elapsedMs) && row.elapsedMs >= 0 + ) + ).toBe(true); + expect(rows[2].elapsedMs).toBeGreaterThanOrEqual(rows[0].elapsedMs); + expect(JSON.stringify(rows)).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(h.root); + expect(h.messages.join("\n")).not.toContain("--recover"); + } finally { + await h.close(); + } +}); + +test("retained build CLI preserves the original thrown error and records its failing invocation without its contents", async () => { + const h = await fixture(); + try { + await observation(h.context, { + ...refused, + exitCode: 0, + stdout: '{"ok":true}', + stderr: "", + }); + const original = new Error(CANARY); + let observed: unknown; + try { + await observeRetainedBuildFixtureCli({ + context: h.context, + mode: "hack-default", + args: ["up", "--detach", "--json"], + driftAfterStart: true, + run: async () => { + throw original; + }, + }); + } catch (error) { + observed = error; + } + expect(observed).toBe(original); + const row = JSON.parse( + await readFile(join(h.root, "retained-build-cli/0005.json"), "utf8") + ); + expect(row).toMatchObject({ + invocation: 2, + operation: "start-with-context-drift", + mode: "hack-default", + stage: "thrown", + }); + expect(h.messages.join("\n")).toContain('"code":"none"'); + expect(h.messages.join("\n")).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(original.stack ?? CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI omits unknown labels, codes, malformed replies and oversized private replies", async () => { + const h = await fixture(); + try { + for (const stdout of [ + CANARY, + JSON.stringify({ ok: false, error: { code: CANARY } }), + CANARY.repeat(5000), + ]) { + const result = { ...refused, stdout, stderr: CANARY }; + expect( + await observeRetainedBuildFixtureCli({ + context: h.context, + mode: CANARY, + args: [CANARY], + driftAfterStart: false, + run: async () => result, + }) + ).toBe(result); + } + const results = h.messages + .map((message) => JSON.parse(message.slice("retained-build-cli ".length))) + .filter((row) => row.stage === "result"); + expect(results).toHaveLength(3); + for (const row of results) + expect(row).toMatchObject({ + mode: "unavailable", + operation: "unavailable", + code: "unavailable", + readGuardRefused: false, + mutationGuardRefused: false, + }); + expect(h.messages.join("\n")).not.toContain(CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI treats unreadable result fields as unavailable without replacing the result", async () => { + const h = await fixture(); + try { + const result = { ...refused }; + Object.defineProperty(result, "stdout", { + get: () => { + throw new Error(CANARY); + }, + }); + expect(await observation(h.context, result)).toBe(result); + const row = JSON.parse( + await readFile(join(h.root, "retained-build-cli/0002.json"), "utf8") + ); + expect(row).toMatchObject({ + exitCode: "unavailable", + timedOut: "unavailable", + code: "unavailable", + readGuardRefused: "unavailable", + mutationGuardRefused: "unavailable", + }); + expect(h.messages.join("\n")).not.toContain(CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI capture and logger failures preserve success and the original thrown error", async () => { + const h = await fixture(); + try { + const blocked = join(h.root, "not-a-directory"); + await writeFile(blocked, CANARY, { mode: 0o600 }); + const context = { + tempRoot: blocked, + log: () => { + throw new Error(CANARY); + }, + }; + expect(await observation(context, refused)).toBe(refused); + const original = new Error(CANARY); + let observed: unknown; + try { + await observeRetainedBuildFixtureCli({ + context, + mode: "root-specific", + args: ["down", "--json"], + driftAfterStart: false, + run: async () => { + throw original; + }, + }); + } catch (error) { + observed = error; + } + expect(observed).toBe(original); + expect(await readFile(blocked, "utf8")).toBe(CANARY); + } finally { + await h.close(); + } +}); diff --git a/tests/native-compose-adoption-build-recovery-transport.test.ts b/tests/native-compose-adoption-build-recovery-transport.test.ts new file mode 100644 index 000000000..2737b40bf --- /dev/null +++ b/tests/native-compose-adoption-build-recovery-transport.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCommand } from "./e2e/harness.ts"; +import { + retainedBuildFixtureImage, + retainedBuildFixtureMutationAllowed, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { buildFixtureCli } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; + +const ID = "a".repeat(64); +const OTHER = "b".repeat(64); +const IMAGE = `sha256:${"c".repeat(64)}`; +const ANCHOR = { + id: "e".repeat(32), + manifest: { dev: 1, ino: 2, hash: "f".repeat(64) }, +}; +const PENDING_START = { + adoption_receipt_version: 9, + prepared: ANCHOR, + publication: { phase: "active", generation: ANCHOR }, + pendingOperation: { + generation: ANCHOR, + operation: "start", + services: ["db", "worker"], + }, +}; +const RECOVER = ["down", "--recover", "--json"]; + +async function emitted(opts: { + readonly cliArgs: string[]; + readonly receipt?: unknown; + readonly effectArgs?: readonly string[]; + readonly observedEngineId?: string; + readonly mutateCaller?: boolean; +}) { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-recovery-transport-")) + ); + const root = join(outer, "checkout"); + const engine = join(outer, "synthetic-engine"); + const forwarded = join(outer, "forwarded"); + const saved = join( + root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const instance = { + root, + name: "fixture", + marker: "synthetic-sql-marker", + basicBuild: "root-specific" as const, + }; + try { + await chmod(outer, 0o700); + await mkdir(join(root, ".hack/.internal/legacy-compose-adoption-v1"), { + mode: 0o700, + recursive: true, + }); + const before = JSON.stringify(opts.receipt ?? PENDING_START); + await writeFile(saved, before, { mode: 0o600 }); + await writeFile( + engine, + [ + `#!${process.execPath}`, + "import {appendFile} from 'node:fs/promises';", + "const args=process.argv.slice(2);", + `await appendFile(${JSON.stringify(forwarded)},JSON.stringify(args)+'\\n');`, + `if(JSON.stringify(args)===JSON.stringify(['info','--format','{{json .ID}}'])){console.log(${JSON.stringify(opts.observedEngineId ?? "synthetic-daemon")});process.exit(0);}`, + `if(JSON.stringify(args)===JSON.stringify(['container','stop',${JSON.stringify(ID)}]))process.exit(0);`, + "process.exit(97);", + ].join("\n"), + { mode: 0o700 } + ); + let captured: readonly string[] = []; + const cli: Parameters[0]["cli"] = async ( + selected, + args, + extra + ) => { + expect(selected).toBe(instance); + captured = args; + if (opts.mutateCaller) { + opts.cliArgs.splice(0, opts.cliArgs.length, "up", "--detach", "--json"); + } + return await runCommand({ + argv: ["docker", ...(opts.effectArgs ?? ["container", "stop", ID])], + cwd: root, + env: extra, + timeoutMs: 5000, + }); + }; + const result = await buildFixtureCli( + { + ctx: { tempRoot: outer }, + engine, + engineId: "synthetic-daemon", + baseImage: `sha256:${"1".repeat(64)}`, + anchors: new Map([ + [ + instance, + { + source: "synthetic-original-source", + resources: { + container: [ + { id: ID, service: "db" }, + { id: OTHER, service: "worker" }, + ], + network: [{ id: "d".repeat(64) }], + volume: [{ id: "fixture_data" }], + }, + }, + ], + ]), + builtImages: new Map([ + [ + instance, + retainedBuildFixtureImage({ + value: { + id: IMAGE, + created: "2026-10-08T20:00:00.123456789Z", + owner: "fixture", + stage: "retained", + tags: ["fixture-db:latest"], + digests: null, + }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }), + ], + ]), + cli, + }, + instance, + opts.cliArgs + ); + const calls: string[][] = (await Bun.file(forwarded).exists()) + ? (await readFile(forwarded, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + return { + result, + calls, + captured, + before, + after: await readFile(saved, "utf8"), + }; + } finally { + await rm(outer, { recursive: true, force: true }); + } +} + +test("pending-start stop counterexample requires the explicit recovery capability and active original anchor", () => { + const selection = { + args: ["container", "stop", ID], + receipt: PENDING_START, + ids: [ID, OTHER], + services: ["db", "worker"], + }; + expect(retainedBuildFixtureMutationAllowed(selection)).toBe(false); + expect( + retainedBuildFixtureMutationAllowed({ + ...selection, + recoverPendingStartStop: true, + }) + ).toBe(true); + for (const publication of [null, { phase: "prepared", generation: ANCHOR }]) { + expect( + retainedBuildFixtureMutationAllowed({ + ...selection, + receipt: { ...PENDING_START, publication }, + recoverPendingStartStop: true, + }) + ).toBe(false); + } +}); + +test("emitted exact recovery-stop forwards only the journaled original ID without rewriting pending start", async () => { + const observed = await emitted({ cliArgs: [...RECOVER], mutateCaller: true }); + expect(observed.captured).toEqual(RECOVER); + expect(Object.isFrozen(observed.captured)).toBe(true); + expect(observed.result.exitCode).toBe(0); + expect(observed.result.timedOut).toBe(false); + expect(observed.calls).toEqual([ + ["info", "--format", "{{json .ID}}"], + ["container", "stop", ID], + ]); + expect(observed.after).toBe(observed.before); +}); + +for (const [name, cliArgs] of [ + ["ordinary", ["down", "--json"]], + ["reordered", ["down", "--json", "--recover"]], + ["extended", [...RECOVER, "--force"]], +] as const) { + test(`emitted pending-start stop rejects an unissued ${name} recovery spelling`, async () => { + const observed = await emitted({ cliArgs: [...cliArgs] }); + expect(observed.result.exitCode).toBe(94); + expect(observed.result.stderr).toBe( + "retained-build-refused stage=mutation-admission code=94\n" + ); + expect(observed.calls).toEqual([]); + expect(observed.after).toBe(observed.before); + }); +} + +for (const [name, receipt] of [ + ["unpublished", { ...PENDING_START, publication: null }], + [ + "foreign-generation", + { + ...PENDING_START, + publication: { + phase: "active", + generation: { ...ANCHOR, id: "1".repeat(32) }, + }, + }, + ], + [ + "partial-services", + { + ...PENDING_START, + pendingOperation: { ...PENDING_START.pendingOperation, services: ["db"] }, + }, + ], + ["foreign-version", { ...PENDING_START, adoption_receipt_version: 10 }], +] as const) { + test(`emitted exact recovery-stop refuses ${name} before engine forwarding`, async () => { + const observed = await emitted({ cliArgs: [...RECOVER], receipt }); + expect(observed.result.exitCode).toBe(94); + expect(observed.calls).toEqual([]); + expect(observed.after).toBe(observed.before); + }); +} + +test("emitted exact recovery-stop refuses a foreign original ID and a changed daemon", async () => { + const foreign = await emitted({ + cliArgs: [...RECOVER], + effectArgs: ["container", "stop", "f".repeat(64)], + }); + expect(foreign.result.exitCode).toBe(94); + expect(foreign.calls).toEqual([]); + const changed = await emitted({ + cliArgs: [...RECOVER], + observedEngineId: "different-daemon", + }); + expect(changed.result.exitCode).toBe(95); + expect(changed.calls).toEqual([["info", "--format", "{{json .ID}}"]]); + expect(changed.after).toBe(changed.before); +}); + +test("emitted ordinary journaled stop retains its previous admission", async () => { + const observed = await emitted({ + cliArgs: ["down", "--json"], + receipt: { + ...PENDING_START, + pendingOperation: { + ...PENDING_START.pendingOperation, + operation: "stop", + }, + }, + }); + expect(observed.result.exitCode).toBe(0); + expect(observed.calls).toEqual([ + ["info", "--format", "{{json .ID}}"], + ["container", "stop", ID], + ]); + expect(observed.after).toBe(observed.before); +}); From cf0bf3a12661913c61bdab89997d4d89c837c1c5 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 23:30:13 -0400 Subject: [PATCH 26/26] test: align retained build diagnostic formatting --- tests/e2e/scenarios/native-compose-adoption-worktrees.ts | 2 +- tests/native-compose-adoption-build-diagnostics.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index ce092fc87..bdc73146d 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -25,13 +25,13 @@ import { type Scenario, type ScenarioContext, } from "../harness.ts"; +import { observeRetainedBuildFixtureCli } from "./native-compose-adoption-build-diagnostics.ts"; import { createRetainedBuildFixtureEvidence, qualifyRetainedBuildFixtureBuilder, qualifyRetainedBuildFixtureCopy, RETAINED_BUILD_BOOTSTRAP_ENV, } from "./native-compose-adoption-build-evidence.ts"; -import { observeRetainedBuildFixtureCli } from "./native-compose-adoption-build-diagnostics.ts"; import { assertRetainedFixtureImageUnchanged, prepareRetainedBuildFixtureSources, diff --git a/tests/native-compose-adoption-build-diagnostics.test.ts b/tests/native-compose-adoption-build-diagnostics.test.ts index 10d4d5fdd..3490f8382 100644 --- a/tests/native-compose-adoption-build-diagnostics.test.ts +++ b/tests/native-compose-adoption-build-diagnostics.test.ts @@ -2,8 +2,8 @@ import { expect, test } from "bun:test"; import { chmod, mkdtemp, - readFile, readdir, + readFile, realpath, rm, stat, @@ -167,7 +167,7 @@ test("retained build CLI omits unknown labels, codes, malformed replies and over .map((message) => JSON.parse(message.slice("retained-build-cli ".length))) .filter((row) => row.stage === "result"); expect(results).toHaveLength(3); - for (const row of results) + for (const row of results) { expect(row).toMatchObject({ mode: "unavailable", operation: "unavailable", @@ -175,6 +175,7 @@ test("retained build CLI omits unknown labels, codes, malformed replies and over readGuardRefused: false, mutationGuardRefused: false, }); + } expect(h.messages.join("\n")).not.toContain(CANARY); } finally { await h.close();