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/23] 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 d7b290162df30989949e53894ac2f17f8eb38044 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 14:25:35 -0400 Subject: [PATCH 02/23] feat: preview file-backed config and secret grants Map closed file declarations and explicit read-only service grants without acquiring file material. Preserve authored provenance and compiler validation, and keep retained adoption refused until its runtime ownership proof is qualified. --- docs/reference/native-config-import.md | 27 + src/lib/native-compose-adoption-plan.ts | 4 +- src/lib/native-config-import-files.ts | 202 ++++++ src/lib/native-config-import-plan.ts | 172 ++++- tests/native-config-import-files.test.ts | 876 +++++++++++++++++++++++ 5 files changed, 1275 insertions(+), 6 deletions(-) create mode 100644 src/lib/native-config-import-files.ts create mode 100644 tests/native-config-import-files.test.ts diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index aa5724488..c03bc0056 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -34,6 +34,8 @@ reported as successful. | Nonempty canonical `profiles` lists | Native service selection and the union of declared profiles | | Short `depends_on` lists, or long edges with `service_started` / `service_healthy` | Native service `started` / `ready` edges; `required` must be absent/true and `restart` absent/false | | `healthcheck.test: [CMD, executable, ...args]` with authored positive `interval`, `timeout`, `retries` | Native exec readiness; complete argument dollar pairs decode once, and the compiler validates timings | +| File-only top-level `configs` and `secrets` | Checkout-relative native file declarations, rebased from the known `.hack` Compose source directory | +| Explicit service `configs` and `secrets` grants | Native read-only file mounts with canonical Linux targets and normalized `0444` permission intent | Names in this slice use lowercase letters, digits and single hyphen separators. Overlay aliases additionally accept ASCII case, underscores and spaces and apply @@ -71,6 +73,31 @@ conversion and retained execution slice. Unknown HTTP/TCP fields also refuse. The compiler rejects missing, cyclic or inactive dependency targets and ready edges whose target has no explicit readiness. Refusals include inactive profiles. +## File declarations and grants + +Only own `{file: string}` declarations are supported, including unused declarations. +Relative paths are lexically rebased from `.hack` into the checkout; `../settings` +becomes `settings`. Absolute paths, checkout escapes and unsupported source options +refuse. No referenced material is read or inspected. Complete dollar pairs in file +paths and grant targets decode once; ambient interpolation refuses. + +Declaring a file does not grant access. Explicit short or long service grants map +to read-only mounts. A short config targets `/`; a short secret targets +`/run/secrets/`. Long grants require `source` and may select a canonical +absolute target; a secret basename target is placed under `/run/secrets`. +Omitted mode, quoted `"0444"` and numeric `292` normalize to `"0444"`. +UID/GID, alternate modes and unknown grant fields refuse, including inactive +services. Bare YAML `0444` becomes unsupported decimal `444`; explicit `0o444` +retains the parser's compatibility refusal. Quoted `"0444"` and numeric `292` +are supported. The compiler validates source references and target overlaps. + +These are Linux declaration defaults. Compose's file-backed binds can ignore +permission options, so this preview does not establish original file modes or +runtime binding equivalence. Retained adoption still refuses these declarations +and grants before private values, keys or engine probes. See Docker's +[config grants](https://docs.docker.com/reference/compose-file/services/#configs) +and [secret grants](https://docs.docker.com/reference/compose-file/services/#secrets). + ## Parsing and input boundary The pinned maintained `yaml` AST parser checks decoded key uniqueness, strict diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 1ddba2383..2f18dfa54 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -4,7 +4,7 @@ import { } from "./native-config-import-parser.ts"; import { freezeImportValue, - mapLegacyNativeImport, + mapLegacyNativeAdoptionBaseline, } from "./native-config-import-plan.ts"; import { type LegacyComposeStorageIntent, @@ -63,7 +63,7 @@ export function planLegacyComposeAdoption(opts: { readonly configText: string; readonly composeText: string; }): LegacyComposeAdoptionPlan { - const baseline = mapLegacyNativeImport(opts); + const baseline = mapLegacyNativeAdoptionBaseline(opts); const config = parseImportDocument({ text: opts.configText, document: "config", diff --git a/src/lib/native-config-import-files.ts b/src/lib/native-config-import-files.ts new file mode 100644 index 000000000..012050d6d --- /dev/null +++ b/src/lib/native-config-import-files.ts @@ -0,0 +1,202 @@ +import { posix } from "node:path"; +import { isRecord } from "./guards.ts"; +import { literalComposeArg } from "./native-config-import-argv.ts"; + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const UNSAFE_PATH = /[\\\0\r\n:]/; + +export type ImportedFileKind = "config" | "secret"; +export type ImportedFileGrant = + | { + readonly config: string; + readonly target: string; + readonly access: "read-only"; + readonly mode: "0444"; + } + | { + readonly secret: string; + readonly target: string; + readonly access: "read-only"; + readonly mode: "0444"; + }; +export type ImportedFileGrantMapping = { + readonly grant: ImportedFileGrant; + readonly fields: readonly { + readonly source: string; + readonly target: string; + readonly code: string; + }[]; +}; + +/** Read only own enumerable data fields; accessor/prototype authority is never acquired. */ +function dataRecord( + value: unknown, + allowed: readonly string[] +): Record | undefined { + if ( + !isRecord(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return undefined; + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Reflect.ownKeys(value).some( + (key) => typeof key !== "string" || !allowed.includes(key) + ) + ) { + return undefined; + } + const entries: [string, unknown][] = []; + for (const [key, descriptor] of Object.entries(descriptors)) { + if (!(descriptor.enumerable && Object.hasOwn(descriptor, "value"))) { + return undefined; + } + entries.push([key, descriptor.value]); + } + return Object.fromEntries(entries); +} + +function name(value: unknown): value is string { + return typeof value === "string" && value.length <= 63 && NAME.test(value); +} + +/** Raw file declarations are based at `.hack`, not cwd. No material/path/env lookup occurs. */ +export function mapLegacyComposeFileDeclaration( + value: unknown +): { readonly file: string } | undefined { + const source = dataRecord(value, ["file"]); + const decoded = + source && Object.hasOwn(source, "file") + ? literalComposeArg(source.file) + : undefined; + if ( + decoded === undefined || + decoded.length === 0 || + UNSAFE_PATH.test(decoded) || + decoded.startsWith("/") || + decoded.startsWith("~") || + decoded.endsWith("/") + ) { + return undefined; + } + const file = posix.normalize(`.hack/${decoded}`); + if ( + file === "." || + file === ".hack" || + file === ".." || + file.startsWith("../") + ) { + return undefined; + } + return { file }; +} + +function absoluteTarget(value: string): string | undefined { + return value.startsWith("/") && + value !== "/" && + !UNSAFE_PATH.test(value) && + !value.endsWith("/") && + posix.normalize(value) === value + ? value + : undefined; +} + +function target(kind: ImportedFileKind, raw: unknown): string | undefined { + const decoded = literalComposeArg(raw); + if ( + decoded === undefined || + decoded.length === 0 || + UNSAFE_PATH.test(decoded) + ) { + return undefined; + } + if (decoded.startsWith("/")) { + return absoluteTarget(decoded); + } + return kind === "secret" && + decoded !== "." && + decoded !== ".." && + !decoded.includes("/") && + !decoded.startsWith("~") + ? `/run/secrets/${decoded}` + : undefined; +} + +function supportedMode(source: Record): boolean { + return ( + !Object.hasOwn(source, "mode") || + source.mode === "0444" || + source.mode === 0o444 + ); +} + +/** Explicit Linux grants only; defaults normalize declaration intent, not source-file mode or bind fidelity. */ +export function mapLegacyComposeFileGrant(opts: { + readonly kind: ImportedFileKind; + readonly value: unknown; +}): ImportedFileGrantMapping | undefined { + const shorthand = typeof opts.value === "string"; + const source: Record | undefined = shorthand + ? { source: opts.value } + : dataRecord(opts.value, ["source", "target", "mode"]); + if ( + !( + source && + Object.hasOwn(source, "source") && + name(source.source) && + supportedMode(source) + ) + ) { + return undefined; + } + const defaultTarget = + opts.kind === "config" + ? `/${source.source}` + : `/run/secrets/${source.source}`; + const destination = Object.hasOwn(source, "target") + ? target(opts.kind, source.target) + : defaultTarget; + if (destination === undefined) { + return undefined; + } + const grant: ImportedFileGrant = + opts.kind === "config" + ? { + config: source.source, + target: destination, + access: "read-only", + mode: "0444", + } + : { + secret: source.source, + target: destination, + access: "read-only", + mode: "0444", + }; + const fields = [ + { source: "", target: "", code: "compose_linux_file_grant_policy" }, + ]; + if (!shorthand) { + fields.push({ source: "source", target: opts.kind, code: "exact" }); + if (Object.hasOwn(source, "target")) { + fields.push({ + source: "target", + target: "target", + code: + source.target === destination + ? "exact" + : "compose_file_target_normalized", + }); + } + if (Object.hasOwn(source, "mode")) { + fields.push({ + source: "mode", + target: "mode", + code: source.mode === "0444" ? "exact" : "compose_file_mode_octal", + }); + } + } + return { grant, fields }; +} diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 4d2c98dd4..b6e389fc9 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -1,5 +1,9 @@ import { isRecord } from "./guards.ts"; import { literalComposeArg } from "./native-config-import-argv.ts"; +import { + mapLegacyComposeFileDeclaration, + mapLegacyComposeFileGrant, +} from "./native-config-import-files.ts"; import { type ImportDocument, type ImportField, @@ -133,7 +137,7 @@ function mappingFields( function mapLegacyNativeInput(opts: { readonly configText: string; readonly composeText: string; - readonly storageAdoption: boolean; + readonly purpose: "preview" | "adoption-baseline" | "storage-adoption"; }): NativeImportPlan { const config = parseImportDocument({ text: opts.configText, @@ -173,7 +177,10 @@ function mapLegacyNativeInput(opts: { mapOverlay(context); mapWorktree(context); mapServices({ source: compose.value.services, candidate, mark, refuse }); - if (opts.storageAdoption) { + if (opts.purpose === "preview") { + mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); + } + if (opts.purpose === "storage-adoption") { mapStorageCandidate({ config: config.value, compose: compose.value, @@ -193,10 +200,18 @@ export function mapLegacyNativeImport(opts: { return mapLegacyNativeInput({ configText: opts.configText, composeText: opts.composeText, - storageAdoption: false, + purpose: "preview", }); } +/** Retained identity planning must not inherit preview-only file authority. */ +export function mapLegacyNativeAdoptionBaseline(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ ...opts, purpose: "adoption-baseline" }); +} + /** Private static candidate with the same closed mappings plus strictly qualified local named storage. No ownership grant. */ export function mapLegacyNativeStorageAdoption(opts: { readonly configText: string; @@ -205,10 +220,159 @@ export function mapLegacyNativeStorageAdoption(opts: { return mapLegacyNativeInput({ configText: opts.configText, composeText: opts.composeText, - storageAdoption: true, + purpose: "storage-adoption", + }); +} + +type FileMappingContext = Pick; + +function mapFileDeclarations( + opts: FileMappingContext & { + readonly namespace: string; + readonly source: unknown; + } +): void { + const { namespace, source } = opts; + if (!isRecord(source)) { + opts.refuse("compose", `/${namespace}`, "invalid_file_declarations"); + return; + } + const definitions: Record = Object.create(null); + opts.mark("compose", `/${namespace}`, `/${namespace}`); + for (const [name, raw] of Object.entries(source)) { + const pointer = importPointer(`/${namespace}`, name); + const declaration = mapLegacyComposeFileDeclaration(raw); + if (!NAME.test(name) || name.length > 63 || !declaration) { + opts.refuse( + "compose", + pointer, + "invalid_or_unsupported_file_declaration" + ); + continue; + } + definitions[name] = declaration; + opts.mark("compose", pointer, pointer); + opts.mark( + "compose", + importPointer(pointer, "file"), + importPointer(pointer, "file"), + "compose_file_source_rebased" + ); + } + Object.defineProperty(opts.candidate, namespace, { + value: definitions, + enumerable: true, + configurable: true, + writable: true, }); } +function mapFileGrantEntries( + opts: FileMappingContext & { + readonly kind: "config" | "secret"; + readonly grants: unknown; + readonly pointer: string; + readonly servicePointer: string; + readonly mounts: unknown[]; + } +): void { + if (!Array.isArray(opts.grants)) { + opts.refuse("compose", opts.pointer, "invalid_file_grants"); + return; + } + opts.mark( + "compose", + opts.pointer, + `${opts.servicePointer}/mounts`, + "compose_explicit_file_grants" + ); + for (const [index, raw] of opts.grants.entries()) { + const entryPointer = importPointer(opts.pointer, index); + const mapped = mapLegacyComposeFileGrant({ kind: opts.kind, value: raw }); + if (!mapped) { + opts.refuse("compose", entryPointer, "invalid_or_unsupported_file_grant"); + continue; + } + const target = `${opts.servicePointer}/mounts/${opts.mounts.length}`; + opts.mounts.push(mapped.grant); + for (const field of mapped.fields) { + opts.mark( + "compose", + field.source === "" + ? entryPointer + : importPointer(entryPointer, field.source), + field.target === "" ? target : importPointer(target, field.target), + field.code + ); + } + } +} + +function mapServiceFileGrants( + opts: FileMappingContext & { + readonly name: string; + readonly source: Record; + readonly service: Record; + } +): void { + const hasExistingMounts = Object.hasOwn(opts.service, "mounts"); + const existingMounts = hasExistingMounts ? opts.service.mounts : undefined; + const servicePointer = importPointer("/services", opts.name); + if (hasExistingMounts && !Array.isArray(existingMounts)) { + opts.refuse("compose", servicePointer, "invalid_existing_mount_projection"); + return; + } + const mounts: unknown[] = Array.isArray(existingMounts) + ? [...existingMounts] + : []; + for (const kind of ["config", "secret"] as const) { + const namespace = `${kind}s`; + if (Object.hasOwn(opts.source, namespace)) { + mapFileGrantEntries({ + ...opts, + kind, + grants: opts.source[namespace], + pointer: importPointer(servicePointer, namespace), + servicePointer, + mounts, + }); + } + } + if (mounts.length > 0) { + Object.defineProperty(opts.service, "mounts", { + value: mounts, + enumerable: true, + configurable: true, + writable: true, + }); + } +} + +function mapFileCandidate( + opts: FileMappingContext & { readonly compose: Record } +): void { + for (const namespace of ["configs", "secrets"]) { + if (Object.hasOwn(opts.compose, namespace)) { + mapFileDeclarations({ + ...opts, + namespace, + source: opts.compose[namespace], + }); + } + } + const sources = opts.compose.services; + const services = opts.candidate.services; + if (!(isRecord(sources) && isRecord(services))) { + return; + } + for (const [name, source] of Object.entries(sources)) { + const service = Object.hasOwn(services, name) ? services[name] : undefined; + if (isRecord(source) && isRecord(service)) { + mapServiceFileGrants({ ...opts, name, source, service }); + } + } +} + function mapStorageCandidate( opts: MappingContext & { readonly compose: Record } ) { diff --git a/tests/native-config-import-files.test.ts b/tests/native-config-import-files.test.ts new file mode 100644 index 000000000..04833e2b1 --- /dev/null +++ b/tests/native-config-import-files.test.ts @@ -0,0 +1,876 @@ +import { expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { acquireLegacyComposeAdoptionBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { planLegacyComposeAdoption } from "../src/lib/native-compose-adoption-plan.ts"; +import { compileNativeConfig } from "../src/lib/native-config-compiler.ts"; +import { + mapLegacyComposeFileDeclaration, + mapLegacyComposeFileGrant, +} from "../src/lib/native-config-import-files.ts"; +import { + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeImport, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { previewNativeConfigImport } from "../src/lib/native-config-import-preview.ts"; + +const CANARY = "synthetic-private-file-import"; +const CONFIG = '{"name":"fixture"}'; +const BINARY = process.env.HACK_TEST_NATIVE_COMPILER_BINARY; +const fixture = { + configs: { + settings: { file: `./config-${CANARY}` }, + unused: { file: "../unused" }, + }, + secrets: { token: { file: `../secrets-${CANARY}` } }, + services: { + reader: { image: "fixture:1", configs: ["settings"], secrets: ["token"] }, + sibling: { + image: "fixture:1", + profiles: ["later"], + configs: [{ source: "settings", target: "/etc/settings", mode: "0444" }], + secrets: [{ source: "token", target: "renamed", mode: 0o444 }], + }, + ungranted: { image: "fixture:1" }, + }, +}; +function mapped(compose: unknown) { + return mapLegacyNativeImport({ + configText: CONFIG, + composeText: JSON.stringify(compose), + }); +} +function refused(result: ReturnType, code?: string) { + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + if (code) { + expect(result.report.fields).toContainEqual( + expect.objectContaining({ status: "refused", code }) + ); + } + expect(JSON.stringify(result)).not.toContain(CANARY); +} + +test("file declarations and Linux explicit grants preserve symbolic values without implicit access", () => { + const raw = JSON.stringify(fixture); + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { + settings: { file: `.hack/config-${CANARY}` }, + unused: { file: "unused" }, + }, + secrets: { token: { file: `secrets-${CANARY}` } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/token", + access: "read-only", + mode: "0444", + }, + ], + }, + sibling: { + profiles: ["later"], + mounts: [ + { + config: "settings", + target: "/etc/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/renamed", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(result.candidate).not.toHaveProperty("services.ungranted.mounts"); + expect(JSON.stringify(fixture)).toBe(raw); + expect(Object.keys(result)).toEqual(["report"]); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(JSON.stringify({ ...result })).not.toContain(CANARY); + expect(Object.isFrozen(result.candidate?.configs)).toBe(true); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/sibling/secrets/0/mode", + code: "compose_file_mode_octal", + target: "/services/sibling/mounts/1/mode", + status: "normalized", + }) + ); +}); + +test("raw file and target locations remain authored while dollars decode exactly once", () => { + const result = mapLegacyNativeImport({ + configText: CONFIG, + composeText: + "configs:\n settings:\n file: ../config-$${AMBIENT}-$$$$\nservices:\n reader:\n image: fixture:1\n configs:\n - source: settings\n target: /etc/config-$${AMBIENT}-$$$$\n", + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { settings: { file: "config-${AMBIENT}-$$" } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/etc/config-${AMBIENT}-$$", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(result.report.fields).toContainEqual({ + document: "compose", + pointer: "/configs/settings/file", + line: 3, + column: 5, + status: "normalized", + code: "compose_file_source_rebased", + target: "/configs/settings/file", + }); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/reader/configs/0/target", + line: 9, + column: 9, + status: "normalized", + code: "compose_file_target_normalized", + }) + ); + expect(JSON.stringify(result)).not.toContain("${AMBIENT}"); +}); + +test.each([ + { file: "settings", expected: ".hack/settings" }, + { file: "../settings", expected: "settings" }, + { file: "./directory/../settings", expected: ".hack/settings" }, + { file: "../config/./settings", expected: "config/settings" }, + { file: "../missing-$${NAME}-$$$$", expected: "missing-${NAME}-$$" }, +])("file source %j rebases without reading missing material", ({ + file, + expected, +}) => { + const reads = spyOn(Bun, "file").mockImplementation(() => { + throw new Error("Material lookup forbidden"); + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Execution forbidden"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("Execution forbidden"); + }); + try { + expect(mapLegacyComposeFileDeclaration({ file })).toEqual({ + file: expected, + }); + expect( + mapped({ + configs: { settings: { file } }, + services: { reader: { image: "fixture", configs: ["settings"] } }, + }).report.complete + ).toBe(true); + expect(reads).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } finally { + reads.mockRestore(); + spawn.mockRestore(); + spawnSync.mockRestore(); + } +}); + +test.each([ + "content", + "environment", + "external", + "name", + "driver", + "labels", + "env_ref", + "unknown", +])("unused definition option %s refuses all namespaces even when empty", (key) => { + for (const namespace of ["configs", "secrets"]) { + for (const value of [null, false, [], {}, CANARY]) { + const result = mapped({ + [namespace]: { unused: { file: "./missing", [key]: value } }, + services: { reader: { image: "fixture" } }, + }); + refused(result, "invalid_or_unsupported_file_declaration"); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: `/${namespace}/unused/${key}`, + status: "refused", + code: "unsupported_field", + }) + ); + } + } +}); + +test.each( + [ + undefined, + null, + false, + 7, + [], + "missing", + {}, + { file: null }, + { file: 7 }, + { file: "" }, + { file: "." }, + { file: ".." }, + { file: "./" }, + { file: "missing/" }, + { file: "../../outside" }, + { file: "missing/../../.." }, + { file: "/absolute" }, + { file: "~/home" }, + { file: "C:\\drive" }, + { file: "file:external" }, + { file: "line\nfile" }, + { file: "nul\0file" }, + { file: "../$NAME" }, + { file: "../${NAME}" }, + ].map((value) => [value] as const) +)("malformed declaration %p cannot become a managed or external source", (value) => { + expect(mapLegacyComposeFileDeclaration(value)).toBeUndefined(); +}); + +test.each( + [null, false, [], 7].map((value) => [value] as const) +)("bad authored file namespace %p refuses instead of vanishing", (value) => { + refused( + mapped({ configs: value, services: { reader: { image: "fixture" } } }), + "invalid_file_declarations" + ); +}); + +test.each([ + "config", + "secret", +] as const)("closed %s grant shape preserves defaults and explicit canonical targets", (kind) => { + const destination = kind === "config" ? "/settings" : "/run/secrets/settings"; + expect(mapLegacyComposeFileGrant({ kind, value: "settings" })?.grant).toEqual( + { + ...(kind === "config" ? { config: "settings" } : { secret: "settings" }), + target: destination, + access: "read-only", + mode: "0444", + } + ); + for (const mode of [undefined, "0444", 0o444]) { + expect( + mapLegacyComposeFileGrant({ + kind, + value: { + source: "settings", + target: "/etc/settings", + ...(mode === undefined ? {} : { mode }), + }, + })?.grant + ).toEqual({ + ...(kind === "config" ? { config: "settings" } : { secret: "settings" }), + target: "/etc/settings", + access: "read-only", + mode: "0444", + }); + } + expect( + mapLegacyComposeFileGrant({ kind, value: { source: "constructor" } })?.grant + ).toMatchObject({ [kind]: "constructor" }); +}); + +test.each( + [ + null, + false, + 7, + [], + {}, + { source: null }, + { source: "Bad" }, + { source: "a".repeat(64) }, + { source: "$NAME" }, + { source: "settings", mode: null }, + { source: "settings", mode: 444 }, + { source: "settings", mode: "444" }, + { source: "settings", mode: "0400" }, + { source: "settings", uid: 0 }, + { source: "settings", gid: "0" }, + { source: "settings", read_only: true }, + { source: "settings", access: "read-only" }, + { source: "settings", required: true }, + { source: "settings", unknown: CANARY }, + { source: "settings", target: null }, + { source: "settings", target: "/" }, + { source: "settings", target: "/a/../b" }, + { source: "settings", target: "/a//b" }, + { source: "settings", target: "/a/" }, + { source: "settings", target: "/$NAME" }, + ].map((value) => [value] as const) +)("malformed/unsupported grant %p refuses even in inactive service", (value) => { + for (const kind of ["config", "secret"] as const) { + expect(mapLegacyComposeFileGrant({ kind, value })).toBeUndefined(); + refused( + mapped({ + [`${kind}s`]: { settings: { file: "./missing" } }, + services: { + inactive: { + image: "fixture", + profiles: ["later"], + [`${kind}s`]: [value], + }, + }, + }), + "invalid_or_unsupported_file_grant" + ); + } +}); + +test("secret basename target is explicit; config relative and secret path-like names refuse", () => { + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings", target: "name-$${LITERAL}" }, + })?.grant + ).toMatchObject({ target: "/run/secrets/name-${LITERAL}" }); + for (const target of [ + "relative", + "../escape", + "folder/name", + "~/file", + "", + ".", + "..", + "C:\\file", + ]) { + expect( + mapLegacyComposeFileGrant({ + kind: "config", + value: { source: "settings", target }, + }) + ).toBeUndefined(); + if (target !== "relative") { + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings", target }, + }) + ).toBeUndefined(); + } + } +}); + +test("accessor/symbol/prototype definitions and grants refuse without evaluating authority", () => { + let invoked = false; + for (const field of ["file", "content", "external"]) { + const value = Object.defineProperty({ file: "missing" }, field, { + enumerable: true, + get() { + invoked = true; + throw new Error(CANARY); + }, + }); + expect(mapLegacyComposeFileDeclaration(value)).toBeUndefined(); + } + for (const field of ["source", "target", "mode", "uid"]) { + const value = Object.defineProperty({ source: "settings" }, field, { + enumerable: true, + get() { + invoked = true; + throw new Error(CANARY); + }, + }); + expect( + mapLegacyComposeFileGrant({ kind: "secret", value }) + ).toBeUndefined(); + } + expect( + mapLegacyComposeFileDeclaration(Object.create({ file: "missing" })) + ).toBeUndefined(); + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: Object.create({ source: "settings" }), + }) + ).toBeUndefined(); + expect( + mapLegacyComposeFileDeclaration({ + file: "missing", + [Symbol("unknown")]: CANARY, + }) + ).toBeUndefined(); + expect(invoked).toBe(false); +}); + +test("missing required own fields cannot acquire inherited Object.prototype getters", () => { + const originals = ["file", "source"].map((field) => ({ + field, + descriptor: Object.getOwnPropertyDescriptor(Object.prototype, field), + })); + let invoked = 0; + try { + Object.defineProperty(Object.prototype, "file", { + configurable: true, + get() { + invoked++; + return `../${CANARY}`; + }, + }); + Object.defineProperty(Object.prototype, "source", { + configurable: true, + get() { + invoked++; + return "settings"; + }, + set(value: unknown) { + // YAML scalars author their own source field; retain that write without authorizing an inherited read. + Object.defineProperty(this, "source", { + value, + enumerable: true, + configurable: true, + writable: true, + }); + }, + }); + expect(mapLegacyComposeFileDeclaration({})).toBeUndefined(); + expect( + mapLegacyComposeFileGrant({ kind: "config", value: {} }) + ).toBeUndefined(); + expect(mapLegacyComposeFileDeclaration({ file: "./missing" })).toEqual({ + file: ".hack/missing", + }); + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings" }, + })?.grant + ).toMatchObject({ secret: "settings", target: "/run/secrets/settings" }); + refused( + mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{}},"services":{"reader":{"image":"fixture"}}}', + }), + "invalid_or_unsupported_file_declaration" + ); + refused( + mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{"file":"./missing"}},"services":{"reader":{"image":"fixture","configs":[{}]}}}', + }), + "invalid_or_unsupported_file_grant" + ); + const own = mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{"file":"./missing"}},"services":{"reader":{"image":"fixture","configs":[{"source":"settings"}]}}}', + }); + expect(own.report.complete).toBe(true); + expect(invoked).toBe(0); + } finally { + for (const { field, descriptor } of originals) { + if (descriptor) { + Object.defineProperty(Object.prototype, field, descriptor); + } else { + Reflect.deleteProperty(Object.prototype, field); + } + } + } +}); + +test("inherited mounts cannot mint unauthored grants or invoke getters/setters", () => { + const original = Object.getOwnPropertyDescriptor(Object.prototype, "mounts"); + const foreign = [ + { + secret: "foreign", + target: "/foreign", + access: "read-only", + mode: "0444", + }, + ]; + let invoked = 0; + try { + for (const descriptor of [ + { + configurable: true, + get() { + invoked++; + return foreign; + }, + set() { + invoked++; + }, + }, + { configurable: true, writable: true, value: foreign }, + ]) { + Object.defineProperty(Object.prototype, "mounts", descriptor); + const result = mapped({ + configs: { settings: { file: "./missing" } }, + services: { + reader: { image: "fixture", configs: ["settings"] }, + ungranted: { image: "fixture" }, + }, + }); + expect(result.report.complete).toBe(true); + const services = result.candidate?.services; + expect(services).toMatchObject({ + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }); + if (!(isRecord(services) && isRecord(services.ungranted))) { + throw new Error("Expected explicit fixture services"); + } + expect(Object.hasOwn(services.ungranted, "mounts")).toBe(false); + expect(invoked).toBe(0); + } + } finally { + if (original) { + Object.defineProperty(Object.prototype, "mounts", original); + } else { + Reflect.deleteProperty(Object.prototype, "mounts"); + } + } +}); + +test("file declaration publication cannot invoke inherited namespace getters/setters", () => { + const originals = ["configs", "secrets"].map((field) => ({ + field, + descriptor: Object.getOwnPropertyDescriptor(Object.prototype, field), + })); + let invoked = 0; + try { + for (const { field } of originals) { + Object.defineProperty(Object.prototype, field, { + configurable: true, + get() { + invoked++; + return { foreign: { file: CANARY } }; + }, + set() { + invoked++; + }, + }); + } + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + const candidate = result.candidate; + if (!isRecord(candidate)) { + throw new Error("Expected explicit file declarations"); + } + expect(Object.hasOwn(candidate, "configs")).toBe(true); + expect(Object.hasOwn(candidate, "secrets")).toBe(true); + expect(candidate.configs).toMatchObject({ + settings: { file: `.hack/config-${CANARY}` }, + }); + expect(candidate.secrets).toMatchObject({ + token: { file: `secrets-${CANARY}` }, + }); + expect(invoked).toBe(0); + } finally { + for (const { field, descriptor } of originals) { + if (descriptor) { + Object.defineProperty(Object.prototype, field, descriptor); + } else { + Reflect.deleteProperty(Object.prototype, field); + } + } + } +}); + +test("file-only namespace presence does not create implicit grants, including empty maps/lists", () => { + const result = mapped({ + configs: {}, + secrets: {}, + services: { reader: { image: "fixture", configs: [], secrets: [] } }, + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ configs: {}, secrets: {} }); + expect(result.candidate).not.toHaveProperty("services.reader.mounts"); + const names = mapped({ + configs: { constructor: { file: "../missing" } }, + services: { constructor: { image: "fixture", configs: ["constructor"] } }, + }); + expect(names.report.complete).toBe(true); + expect(names.candidate).toMatchObject({ + configs: { constructor: { file: "missing" } }, + services: { + constructor: { + mounts: [{ config: "constructor", target: "/constructor" }], + }, + }, + }); +}); + +test("mode syntax remains authoritative; quoted0444 and numeric292 normalize", () => { + const source = (mode: string) => + `configs:\n settings:\n file: ./missing\nservices:\n reader:\n image: fixture\n configs:\n - source: settings\n mode: ${mode}\n`; + refused( + mapLegacyNativeImport({ configText: CONFIG, composeText: source("0444") }), + "invalid_or_unsupported_file_grant" + ); + refused( + mapLegacyNativeImport({ configText: CONFIG, composeText: source("0o444") }), + "invalid_syntax" + ); + const explicit = mapLegacyNativeImport({ + configText: CONFIG, + composeText: source("292"), + }); + expect(explicit.report.complete).toBe(true); + expect(explicit.candidate).toMatchObject({ + services: { reader: { mounts: [{ mode: "0444" }] } }, + }); + const quoted = mapLegacyNativeImport({ + configText: CONFIG, + composeText: source('"0444"'), + }); + expect(quoted.report.complete).toBe(true); + expect(quoted.candidate).toMatchObject({ + services: { reader: { mounts: [{ mode: "0444" }] } }, + }); +}); + +function retainedSource() { + return JSON.stringify({ + ...fixture, + name: "fixture", + volumes: { data: {} }, + services: { + reader: { ...fixture.services.reader, volumes: ["data:/data"] }, + }, + }); +} +test("retained adoption baselines stay closed even though pure preview recognizes file grants", () => { + const composeText = retainedSource(); + refused(mapLegacyNativeAdoptionBaseline({ configText: CONFIG, composeText })); + refused(mapLegacyNativeStorageAdoption({ configText: CONFIG, composeText })); + const planned = planLegacyComposeAdoption({ + configText: CONFIG, + composeText, + }); + expect(planned.report.supported).toBe(false); + expect(planned.intent).toBeUndefined(); + expect(planned.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/configs/settings/file", + status: "refused", + code: "unsupported_field", + }) + ); + expect(JSON.stringify(planned)).not.toContain(CANARY); +}); + +test("retained file adoption refuses before key/private values/compiler/engine probes", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "import-file-adoption-")) + ); + const directory = join(root, ".hack"); + await mkdir(directory); + await writeFile(join(directory, "hack.config.json"), CONFIG); + await writeFile(join(directory, "docker-compose.yml"), retainedSource()); + await writeFile(join(directory, "hack.env.key"), CANARY, { mode: 0o600 }); + const originalFile = Bun.file; + const composePath = join(directory, "docker-compose.yml"); + const reads = spyOn(Bun, "file").mockImplementation((path, options) => { + if (path !== composePath) { + throw new Error("Private material/key lookup forbidden"); + } + return originalFile(path, options); + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Compiler/engine forbidden"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("Compiler/engine forbidden"); + }); + try { + await expect( + acquireLegacyComposeAdoptionBinding({ + projectRoot: root, + binary: join(root, "unavailable"), + }) + ).rejects.toMatchObject({ code: "E_LEGACY_COMPOSE_BINDING_UNSUPPORTED" }); + expect(reads).toHaveBeenCalled(); + const paths: readonly unknown[] = reads.mock.calls.map(([path]) => path); + expect(paths.every((path) => path === composePath)).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } finally { + reads.mockRestore(); + spawn.mockRestore(); + spawnSync.mockRestore(); + await rm(root, { recursive: true }); + } +}); + +test.skipIf(!BINARY)( + "matching compiler validates file-only declarations and explicit grants without acquiring material", + async () => { + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + const compiled = await compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(result.candidate)), + binary: BINARY, + }); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + throw new Error("Compiled file-grant fixture refused"); + } + expect(compiled.plan).toMatchObject({ + configs: { + settings: { file: `.hack/config-${CANARY}` }, + unused: { file: "unused" }, + }, + secrets: { token: { file: `secrets-${CANARY}` } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/token", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + } +); + +test.skipIf(!BINARY)( + "public preview keeps absent file material symbolic and compiler refusals redacted", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "import-file-preview-")) + ); + const directory = join(root, ".hack"); + await mkdir(directory); + const composeText = JSON.stringify(fixture); + await writeFile(join(directory, "hack.config.json"), CONFIG); + await writeFile(join(directory, "docker-compose.yml"), composeText); + await writeFile(join(directory, "hack.env.default.yaml"), `${CANARY}: [`); + await symlink( + join(root, "absent-private-key"), + join(directory, "hack.env.key") + ); + const names = await readdir(directory); + try { + const result = await previewNativeConfigImport({ + projectRoot: root, + binary: BINARY, + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { settings: { file: `.hack/config-${CANARY}` } }, + secrets: { token: { file: `secrets-${CANARY}` } }, + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(Object.keys(result)).toEqual(["report"]); + expect(await readFile(join(directory, "hack.config.json"), "utf8")).toBe( + CONFIG + ); + expect( + await readFile(join(directory, "docker-compose.yml"), "utf8") + ).toBe(composeText); + expect(await readdir(directory)).toEqual(names); + await writeFile( + join(directory, "docker-compose.yml"), + JSON.stringify({ + configs: fixture.configs, + services: { reader: { image: "fixture", configs: ["missing"] } }, + }) + ); + const refusedResult = await previewNativeConfigImport({ + projectRoot: root, + binary: BINARY, + }); + refused(refusedResult, "candidate_compiler_refused"); + expect(await readdir(directory)).toEqual(names); + } finally { + await rm(root, { recursive: true }); + } + } +); + +test.skipIf(!BINARY).each([ + { + configs: { settings: { file: "./missing" } }, + services: { reader: { image: "fixture", configs: ["unknown"] } }, + }, + { + configs: { settings: { file: "./missing" } }, + secrets: { token: { file: "../missing" } }, + services: { + reader: { + image: "fixture", + configs: [{ source: "settings", target: "/same" }], + secrets: [{ source: "token", target: "/same" }], + }, + }, + }, + { + configs: { settings: { file: "./missing" } }, + services: { + inactive: { image: "fixture", profiles: ["later"], configs: ["unknown"] }, + }, + }, +])( + "matching compiler remains authoritative for unknown/overlapping grants, including inactive profiles %j", + async (source) => { + const result = mapped(source); + expect(result.report.complete).toBe(true); + const compiled = await compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(result.candidate)), + binary: BINARY, + }); + expect(compiled.ok).toBe(false); + } +); From 0a7331d46d8dee3af71f6314877e27e810c5e9c2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 14:25:35 -0400 Subject: [PATCH 03/23] feat: preview file-backed config and secret grants Map closed file declarations and explicit read-only service grants without acquiring file material. Preserve authored provenance and compiler validation, and keep retained adoption refused until its runtime ownership proof is qualified. --- docs/reference/native-config-import.md | 27 + src/lib/native-config-import-files.ts | 202 ++++++ src/lib/native-config-import-plan.ts | 158 +++- tests/native-config-import-files.test.ts | 876 +++++++++++++++++++++++ 4 files changed, 1262 insertions(+), 1 deletion(-) create mode 100644 src/lib/native-config-import-files.ts create mode 100644 tests/native-config-import-files.test.ts diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index 17c4ff188..52bb228a6 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -36,6 +36,8 @@ reported as successful. | Long edges with `service_completed_successfully` | The referenced declaration becomes a native job; the edge becomes `job` / `completed` | | Exact `hack.service.one-shot: "true"` label map or singleton `hack.service.one-shot=true` label list | An explicit standalone native job; the known marker is consumed as semantic provenance, without adding resource labels | | `healthcheck.test: [CMD, executable, ...args]` with authored positive `interval`, `timeout`, `retries` | Native exec readiness; complete argument dollar pairs decode once, and the compiler validates timings | +| File-only top-level `configs` and `secrets` | Checkout-relative native file declarations, rebased from the known `.hack` Compose source directory | +| Explicit service `configs` and `secrets` grants | Native read-only file mounts with canonical Linux targets and normalized `0444` permission intent | Names in this slice use lowercase letters, digits and single hyphen separators. Overlay aliases additionally accept ASCII case, underscores and spaces and apply @@ -98,6 +100,31 @@ execution remains a separate slice. Unknown HTTP/TCP fields also refuse. The compiler rejects missing, cyclic or inactive dependency targets and ready edges whose target has no explicit readiness. Refusals include inactive profiles. +## File declarations and grants + +Only own `{file: string}` declarations are supported, including unused declarations. +Relative paths are lexically rebased from `.hack` into the checkout; `../settings` +becomes `settings`. Absolute paths, checkout escapes and unsupported source options +refuse. No referenced material is read or inspected. Complete dollar pairs in file +paths and grant targets decode once; ambient interpolation refuses. + +Declaring a file does not grant access. Explicit short or long service grants map +to read-only mounts. A short config targets `/`; a short secret targets +`/run/secrets/`. Long grants require `source` and may select a canonical +absolute target; a secret basename target is placed under `/run/secrets`. +Omitted mode, quoted `"0444"` and numeric `292` normalize to `"0444"`. +UID/GID, alternate modes and unknown grant fields refuse, including inactive +services. Bare YAML `0444` becomes unsupported decimal `444`; explicit `0o444` +retains the parser's compatibility refusal. Quoted `"0444"` and numeric `292` +are supported. The compiler validates source references and target overlaps. + +These are Linux declaration defaults. Compose's file-backed binds can ignore +permission options, so this preview does not establish original file modes or +runtime binding equivalence. Retained adoption still refuses these declarations +and grants before private values, keys or engine probes. See Docker's +[config grants](https://docs.docker.com/reference/compose-file/services/#configs) +and [secret grants](https://docs.docker.com/reference/compose-file/services/#secrets). + ## Parsing and input boundary The pinned maintained `yaml` AST parser checks decoded key uniqueness, strict diff --git a/src/lib/native-config-import-files.ts b/src/lib/native-config-import-files.ts new file mode 100644 index 000000000..012050d6d --- /dev/null +++ b/src/lib/native-config-import-files.ts @@ -0,0 +1,202 @@ +import { posix } from "node:path"; +import { isRecord } from "./guards.ts"; +import { literalComposeArg } from "./native-config-import-argv.ts"; + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const UNSAFE_PATH = /[\\\0\r\n:]/; + +export type ImportedFileKind = "config" | "secret"; +export type ImportedFileGrant = + | { + readonly config: string; + readonly target: string; + readonly access: "read-only"; + readonly mode: "0444"; + } + | { + readonly secret: string; + readonly target: string; + readonly access: "read-only"; + readonly mode: "0444"; + }; +export type ImportedFileGrantMapping = { + readonly grant: ImportedFileGrant; + readonly fields: readonly { + readonly source: string; + readonly target: string; + readonly code: string; + }[]; +}; + +/** Read only own enumerable data fields; accessor/prototype authority is never acquired. */ +function dataRecord( + value: unknown, + allowed: readonly string[] +): Record | undefined { + if ( + !isRecord(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return undefined; + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Reflect.ownKeys(value).some( + (key) => typeof key !== "string" || !allowed.includes(key) + ) + ) { + return undefined; + } + const entries: [string, unknown][] = []; + for (const [key, descriptor] of Object.entries(descriptors)) { + if (!(descriptor.enumerable && Object.hasOwn(descriptor, "value"))) { + return undefined; + } + entries.push([key, descriptor.value]); + } + return Object.fromEntries(entries); +} + +function name(value: unknown): value is string { + return typeof value === "string" && value.length <= 63 && NAME.test(value); +} + +/** Raw file declarations are based at `.hack`, not cwd. No material/path/env lookup occurs. */ +export function mapLegacyComposeFileDeclaration( + value: unknown +): { readonly file: string } | undefined { + const source = dataRecord(value, ["file"]); + const decoded = + source && Object.hasOwn(source, "file") + ? literalComposeArg(source.file) + : undefined; + if ( + decoded === undefined || + decoded.length === 0 || + UNSAFE_PATH.test(decoded) || + decoded.startsWith("/") || + decoded.startsWith("~") || + decoded.endsWith("/") + ) { + return undefined; + } + const file = posix.normalize(`.hack/${decoded}`); + if ( + file === "." || + file === ".hack" || + file === ".." || + file.startsWith("../") + ) { + return undefined; + } + return { file }; +} + +function absoluteTarget(value: string): string | undefined { + return value.startsWith("/") && + value !== "/" && + !UNSAFE_PATH.test(value) && + !value.endsWith("/") && + posix.normalize(value) === value + ? value + : undefined; +} + +function target(kind: ImportedFileKind, raw: unknown): string | undefined { + const decoded = literalComposeArg(raw); + if ( + decoded === undefined || + decoded.length === 0 || + UNSAFE_PATH.test(decoded) + ) { + return undefined; + } + if (decoded.startsWith("/")) { + return absoluteTarget(decoded); + } + return kind === "secret" && + decoded !== "." && + decoded !== ".." && + !decoded.includes("/") && + !decoded.startsWith("~") + ? `/run/secrets/${decoded}` + : undefined; +} + +function supportedMode(source: Record): boolean { + return ( + !Object.hasOwn(source, "mode") || + source.mode === "0444" || + source.mode === 0o444 + ); +} + +/** Explicit Linux grants only; defaults normalize declaration intent, not source-file mode or bind fidelity. */ +export function mapLegacyComposeFileGrant(opts: { + readonly kind: ImportedFileKind; + readonly value: unknown; +}): ImportedFileGrantMapping | undefined { + const shorthand = typeof opts.value === "string"; + const source: Record | undefined = shorthand + ? { source: opts.value } + : dataRecord(opts.value, ["source", "target", "mode"]); + if ( + !( + source && + Object.hasOwn(source, "source") && + name(source.source) && + supportedMode(source) + ) + ) { + return undefined; + } + const defaultTarget = + opts.kind === "config" + ? `/${source.source}` + : `/run/secrets/${source.source}`; + const destination = Object.hasOwn(source, "target") + ? target(opts.kind, source.target) + : defaultTarget; + if (destination === undefined) { + return undefined; + } + const grant: ImportedFileGrant = + opts.kind === "config" + ? { + config: source.source, + target: destination, + access: "read-only", + mode: "0444", + } + : { + secret: source.source, + target: destination, + access: "read-only", + mode: "0444", + }; + const fields = [ + { source: "", target: "", code: "compose_linux_file_grant_policy" }, + ]; + if (!shorthand) { + fields.push({ source: "source", target: opts.kind, code: "exact" }); + if (Object.hasOwn(source, "target")) { + fields.push({ + source: "target", + target: "target", + code: + source.target === destination + ? "exact" + : "compose_file_target_normalized", + }); + } + if (Object.hasOwn(source, "mode")) { + fields.push({ + source: "mode", + target: "mode", + code: source.mode === "0444" ? "exact" : "compose_file_mode_octal", + }); + } + } + return { grant, fields }; +} diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 601687ae4..057163a76 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -1,5 +1,9 @@ import { isRecord } from "./guards.ts"; import { literalComposeArg } from "./native-config-import-argv.ts"; +import { + mapLegacyComposeFileDeclaration, + mapLegacyComposeFileGrant, +} from "./native-config-import-files.ts"; import { legacyComposeJobNames, legacyComposeOneShotMarker, @@ -185,6 +189,9 @@ function mapLegacyNativeInput(opts: { refuse, jobPreview: opts.purpose !== "adoption-baseline", }); + if (opts.purpose === "preview") { + mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); + } if (opts.purpose === "storage-adoption") { mapStorageCandidate({ config: config.value, @@ -233,7 +240,7 @@ export function mapLegacyNativeImport(opts: { }); } -/** Retained resource planning must not inherit preview-only job authority. */ +/** Retained resource planning must not inherit preview-only job or file authority. */ export function mapLegacyNativeAdoptionBaseline(opts: { readonly configText: string; readonly composeText: string; @@ -253,6 +260,155 @@ export function mapLegacyNativeStorageAdoption(opts: { }); } +type FileMappingContext = Pick; + +function mapFileDeclarations( + opts: FileMappingContext & { + readonly namespace: string; + readonly source: unknown; + } +): void { + const { namespace, source } = opts; + if (!isRecord(source)) { + opts.refuse("compose", `/${namespace}`, "invalid_file_declarations"); + return; + } + const definitions: Record = Object.create(null); + opts.mark("compose", `/${namespace}`, `/${namespace}`); + for (const [name, raw] of Object.entries(source)) { + const pointer = importPointer(`/${namespace}`, name); + const declaration = mapLegacyComposeFileDeclaration(raw); + if (!NAME.test(name) || name.length > 63 || !declaration) { + opts.refuse( + "compose", + pointer, + "invalid_or_unsupported_file_declaration" + ); + continue; + } + definitions[name] = declaration; + opts.mark("compose", pointer, pointer); + opts.mark( + "compose", + importPointer(pointer, "file"), + importPointer(pointer, "file"), + "compose_file_source_rebased" + ); + } + Object.defineProperty(opts.candidate, namespace, { + value: definitions, + enumerable: true, + configurable: true, + writable: true, + }); +} + +function mapFileGrantEntries( + opts: FileMappingContext & { + readonly kind: "config" | "secret"; + readonly grants: unknown; + readonly pointer: string; + readonly servicePointer: string; + readonly mounts: unknown[]; + } +): void { + if (!Array.isArray(opts.grants)) { + opts.refuse("compose", opts.pointer, "invalid_file_grants"); + return; + } + opts.mark( + "compose", + opts.pointer, + `${opts.servicePointer}/mounts`, + "compose_explicit_file_grants" + ); + for (const [index, raw] of opts.grants.entries()) { + const entryPointer = importPointer(opts.pointer, index); + const mapped = mapLegacyComposeFileGrant({ kind: opts.kind, value: raw }); + if (!mapped) { + opts.refuse("compose", entryPointer, "invalid_or_unsupported_file_grant"); + continue; + } + const target = `${opts.servicePointer}/mounts/${opts.mounts.length}`; + opts.mounts.push(mapped.grant); + for (const field of mapped.fields) { + opts.mark( + "compose", + field.source === "" + ? entryPointer + : importPointer(entryPointer, field.source), + field.target === "" ? target : importPointer(target, field.target), + field.code + ); + } + } +} + +function mapServiceFileGrants( + opts: FileMappingContext & { + readonly name: string; + readonly source: Record; + readonly service: Record; + } +): void { + const hasExistingMounts = Object.hasOwn(opts.service, "mounts"); + const existingMounts = hasExistingMounts ? opts.service.mounts : undefined; + const servicePointer = importPointer("/services", opts.name); + if (hasExistingMounts && !Array.isArray(existingMounts)) { + opts.refuse("compose", servicePointer, "invalid_existing_mount_projection"); + return; + } + const mounts: unknown[] = Array.isArray(existingMounts) + ? [...existingMounts] + : []; + for (const kind of ["config", "secret"] as const) { + const namespace = `${kind}s`; + if (Object.hasOwn(opts.source, namespace)) { + mapFileGrantEntries({ + ...opts, + kind, + grants: opts.source[namespace], + pointer: importPointer(servicePointer, namespace), + servicePointer, + mounts, + }); + } + } + if (mounts.length > 0) { + Object.defineProperty(opts.service, "mounts", { + value: mounts, + enumerable: true, + configurable: true, + writable: true, + }); + } +} + +function mapFileCandidate( + opts: FileMappingContext & { readonly compose: Record } +): void { + for (const namespace of ["configs", "secrets"]) { + if (Object.hasOwn(opts.compose, namespace)) { + mapFileDeclarations({ + ...opts, + namespace, + source: opts.compose[namespace], + }); + } + } + const sources = opts.compose.services; + const services = opts.candidate.services; + if (!(isRecord(sources) && isRecord(services))) { + return; + } + for (const [name, source] of Object.entries(sources)) { + const service = Object.hasOwn(services, name) ? services[name] : undefined; + if (isRecord(source) && isRecord(service)) { + mapServiceFileGrants({ ...opts, name, source, service }); + } + } +} + function mapStorageCandidate( opts: MappingContext & { readonly compose: Record } ) { diff --git a/tests/native-config-import-files.test.ts b/tests/native-config-import-files.test.ts new file mode 100644 index 000000000..04833e2b1 --- /dev/null +++ b/tests/native-config-import-files.test.ts @@ -0,0 +1,876 @@ +import { expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { acquireLegacyComposeAdoptionBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { planLegacyComposeAdoption } from "../src/lib/native-compose-adoption-plan.ts"; +import { compileNativeConfig } from "../src/lib/native-config-compiler.ts"; +import { + mapLegacyComposeFileDeclaration, + mapLegacyComposeFileGrant, +} from "../src/lib/native-config-import-files.ts"; +import { + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeImport, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { previewNativeConfigImport } from "../src/lib/native-config-import-preview.ts"; + +const CANARY = "synthetic-private-file-import"; +const CONFIG = '{"name":"fixture"}'; +const BINARY = process.env.HACK_TEST_NATIVE_COMPILER_BINARY; +const fixture = { + configs: { + settings: { file: `./config-${CANARY}` }, + unused: { file: "../unused" }, + }, + secrets: { token: { file: `../secrets-${CANARY}` } }, + services: { + reader: { image: "fixture:1", configs: ["settings"], secrets: ["token"] }, + sibling: { + image: "fixture:1", + profiles: ["later"], + configs: [{ source: "settings", target: "/etc/settings", mode: "0444" }], + secrets: [{ source: "token", target: "renamed", mode: 0o444 }], + }, + ungranted: { image: "fixture:1" }, + }, +}; +function mapped(compose: unknown) { + return mapLegacyNativeImport({ + configText: CONFIG, + composeText: JSON.stringify(compose), + }); +} +function refused(result: ReturnType, code?: string) { + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + if (code) { + expect(result.report.fields).toContainEqual( + expect.objectContaining({ status: "refused", code }) + ); + } + expect(JSON.stringify(result)).not.toContain(CANARY); +} + +test("file declarations and Linux explicit grants preserve symbolic values without implicit access", () => { + const raw = JSON.stringify(fixture); + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { + settings: { file: `.hack/config-${CANARY}` }, + unused: { file: "unused" }, + }, + secrets: { token: { file: `secrets-${CANARY}` } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/token", + access: "read-only", + mode: "0444", + }, + ], + }, + sibling: { + profiles: ["later"], + mounts: [ + { + config: "settings", + target: "/etc/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/renamed", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(result.candidate).not.toHaveProperty("services.ungranted.mounts"); + expect(JSON.stringify(fixture)).toBe(raw); + expect(Object.keys(result)).toEqual(["report"]); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(JSON.stringify({ ...result })).not.toContain(CANARY); + expect(Object.isFrozen(result.candidate?.configs)).toBe(true); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/sibling/secrets/0/mode", + code: "compose_file_mode_octal", + target: "/services/sibling/mounts/1/mode", + status: "normalized", + }) + ); +}); + +test("raw file and target locations remain authored while dollars decode exactly once", () => { + const result = mapLegacyNativeImport({ + configText: CONFIG, + composeText: + "configs:\n settings:\n file: ../config-$${AMBIENT}-$$$$\nservices:\n reader:\n image: fixture:1\n configs:\n - source: settings\n target: /etc/config-$${AMBIENT}-$$$$\n", + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { settings: { file: "config-${AMBIENT}-$$" } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/etc/config-${AMBIENT}-$$", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(result.report.fields).toContainEqual({ + document: "compose", + pointer: "/configs/settings/file", + line: 3, + column: 5, + status: "normalized", + code: "compose_file_source_rebased", + target: "/configs/settings/file", + }); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/reader/configs/0/target", + line: 9, + column: 9, + status: "normalized", + code: "compose_file_target_normalized", + }) + ); + expect(JSON.stringify(result)).not.toContain("${AMBIENT}"); +}); + +test.each([ + { file: "settings", expected: ".hack/settings" }, + { file: "../settings", expected: "settings" }, + { file: "./directory/../settings", expected: ".hack/settings" }, + { file: "../config/./settings", expected: "config/settings" }, + { file: "../missing-$${NAME}-$$$$", expected: "missing-${NAME}-$$" }, +])("file source %j rebases without reading missing material", ({ + file, + expected, +}) => { + const reads = spyOn(Bun, "file").mockImplementation(() => { + throw new Error("Material lookup forbidden"); + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Execution forbidden"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("Execution forbidden"); + }); + try { + expect(mapLegacyComposeFileDeclaration({ file })).toEqual({ + file: expected, + }); + expect( + mapped({ + configs: { settings: { file } }, + services: { reader: { image: "fixture", configs: ["settings"] } }, + }).report.complete + ).toBe(true); + expect(reads).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } finally { + reads.mockRestore(); + spawn.mockRestore(); + spawnSync.mockRestore(); + } +}); + +test.each([ + "content", + "environment", + "external", + "name", + "driver", + "labels", + "env_ref", + "unknown", +])("unused definition option %s refuses all namespaces even when empty", (key) => { + for (const namespace of ["configs", "secrets"]) { + for (const value of [null, false, [], {}, CANARY]) { + const result = mapped({ + [namespace]: { unused: { file: "./missing", [key]: value } }, + services: { reader: { image: "fixture" } }, + }); + refused(result, "invalid_or_unsupported_file_declaration"); + expect(result.report.fields).toContainEqual( + expect.objectContaining({ + pointer: `/${namespace}/unused/${key}`, + status: "refused", + code: "unsupported_field", + }) + ); + } + } +}); + +test.each( + [ + undefined, + null, + false, + 7, + [], + "missing", + {}, + { file: null }, + { file: 7 }, + { file: "" }, + { file: "." }, + { file: ".." }, + { file: "./" }, + { file: "missing/" }, + { file: "../../outside" }, + { file: "missing/../../.." }, + { file: "/absolute" }, + { file: "~/home" }, + { file: "C:\\drive" }, + { file: "file:external" }, + { file: "line\nfile" }, + { file: "nul\0file" }, + { file: "../$NAME" }, + { file: "../${NAME}" }, + ].map((value) => [value] as const) +)("malformed declaration %p cannot become a managed or external source", (value) => { + expect(mapLegacyComposeFileDeclaration(value)).toBeUndefined(); +}); + +test.each( + [null, false, [], 7].map((value) => [value] as const) +)("bad authored file namespace %p refuses instead of vanishing", (value) => { + refused( + mapped({ configs: value, services: { reader: { image: "fixture" } } }), + "invalid_file_declarations" + ); +}); + +test.each([ + "config", + "secret", +] as const)("closed %s grant shape preserves defaults and explicit canonical targets", (kind) => { + const destination = kind === "config" ? "/settings" : "/run/secrets/settings"; + expect(mapLegacyComposeFileGrant({ kind, value: "settings" })?.grant).toEqual( + { + ...(kind === "config" ? { config: "settings" } : { secret: "settings" }), + target: destination, + access: "read-only", + mode: "0444", + } + ); + for (const mode of [undefined, "0444", 0o444]) { + expect( + mapLegacyComposeFileGrant({ + kind, + value: { + source: "settings", + target: "/etc/settings", + ...(mode === undefined ? {} : { mode }), + }, + })?.grant + ).toEqual({ + ...(kind === "config" ? { config: "settings" } : { secret: "settings" }), + target: "/etc/settings", + access: "read-only", + mode: "0444", + }); + } + expect( + mapLegacyComposeFileGrant({ kind, value: { source: "constructor" } })?.grant + ).toMatchObject({ [kind]: "constructor" }); +}); + +test.each( + [ + null, + false, + 7, + [], + {}, + { source: null }, + { source: "Bad" }, + { source: "a".repeat(64) }, + { source: "$NAME" }, + { source: "settings", mode: null }, + { source: "settings", mode: 444 }, + { source: "settings", mode: "444" }, + { source: "settings", mode: "0400" }, + { source: "settings", uid: 0 }, + { source: "settings", gid: "0" }, + { source: "settings", read_only: true }, + { source: "settings", access: "read-only" }, + { source: "settings", required: true }, + { source: "settings", unknown: CANARY }, + { source: "settings", target: null }, + { source: "settings", target: "/" }, + { source: "settings", target: "/a/../b" }, + { source: "settings", target: "/a//b" }, + { source: "settings", target: "/a/" }, + { source: "settings", target: "/$NAME" }, + ].map((value) => [value] as const) +)("malformed/unsupported grant %p refuses even in inactive service", (value) => { + for (const kind of ["config", "secret"] as const) { + expect(mapLegacyComposeFileGrant({ kind, value })).toBeUndefined(); + refused( + mapped({ + [`${kind}s`]: { settings: { file: "./missing" } }, + services: { + inactive: { + image: "fixture", + profiles: ["later"], + [`${kind}s`]: [value], + }, + }, + }), + "invalid_or_unsupported_file_grant" + ); + } +}); + +test("secret basename target is explicit; config relative and secret path-like names refuse", () => { + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings", target: "name-$${LITERAL}" }, + })?.grant + ).toMatchObject({ target: "/run/secrets/name-${LITERAL}" }); + for (const target of [ + "relative", + "../escape", + "folder/name", + "~/file", + "", + ".", + "..", + "C:\\file", + ]) { + expect( + mapLegacyComposeFileGrant({ + kind: "config", + value: { source: "settings", target }, + }) + ).toBeUndefined(); + if (target !== "relative") { + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings", target }, + }) + ).toBeUndefined(); + } + } +}); + +test("accessor/symbol/prototype definitions and grants refuse without evaluating authority", () => { + let invoked = false; + for (const field of ["file", "content", "external"]) { + const value = Object.defineProperty({ file: "missing" }, field, { + enumerable: true, + get() { + invoked = true; + throw new Error(CANARY); + }, + }); + expect(mapLegacyComposeFileDeclaration(value)).toBeUndefined(); + } + for (const field of ["source", "target", "mode", "uid"]) { + const value = Object.defineProperty({ source: "settings" }, field, { + enumerable: true, + get() { + invoked = true; + throw new Error(CANARY); + }, + }); + expect( + mapLegacyComposeFileGrant({ kind: "secret", value }) + ).toBeUndefined(); + } + expect( + mapLegacyComposeFileDeclaration(Object.create({ file: "missing" })) + ).toBeUndefined(); + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: Object.create({ source: "settings" }), + }) + ).toBeUndefined(); + expect( + mapLegacyComposeFileDeclaration({ + file: "missing", + [Symbol("unknown")]: CANARY, + }) + ).toBeUndefined(); + expect(invoked).toBe(false); +}); + +test("missing required own fields cannot acquire inherited Object.prototype getters", () => { + const originals = ["file", "source"].map((field) => ({ + field, + descriptor: Object.getOwnPropertyDescriptor(Object.prototype, field), + })); + let invoked = 0; + try { + Object.defineProperty(Object.prototype, "file", { + configurable: true, + get() { + invoked++; + return `../${CANARY}`; + }, + }); + Object.defineProperty(Object.prototype, "source", { + configurable: true, + get() { + invoked++; + return "settings"; + }, + set(value: unknown) { + // YAML scalars author their own source field; retain that write without authorizing an inherited read. + Object.defineProperty(this, "source", { + value, + enumerable: true, + configurable: true, + writable: true, + }); + }, + }); + expect(mapLegacyComposeFileDeclaration({})).toBeUndefined(); + expect( + mapLegacyComposeFileGrant({ kind: "config", value: {} }) + ).toBeUndefined(); + expect(mapLegacyComposeFileDeclaration({ file: "./missing" })).toEqual({ + file: ".hack/missing", + }); + expect( + mapLegacyComposeFileGrant({ + kind: "secret", + value: { source: "settings" }, + })?.grant + ).toMatchObject({ secret: "settings", target: "/run/secrets/settings" }); + refused( + mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{}},"services":{"reader":{"image":"fixture"}}}', + }), + "invalid_or_unsupported_file_declaration" + ); + refused( + mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{"file":"./missing"}},"services":{"reader":{"image":"fixture","configs":[{}]}}}', + }), + "invalid_or_unsupported_file_grant" + ); + const own = mapLegacyNativeImport({ + configText: CONFIG, + composeText: + '{"configs":{"settings":{"file":"./missing"}},"services":{"reader":{"image":"fixture","configs":[{"source":"settings"}]}}}', + }); + expect(own.report.complete).toBe(true); + expect(invoked).toBe(0); + } finally { + for (const { field, descriptor } of originals) { + if (descriptor) { + Object.defineProperty(Object.prototype, field, descriptor); + } else { + Reflect.deleteProperty(Object.prototype, field); + } + } + } +}); + +test("inherited mounts cannot mint unauthored grants or invoke getters/setters", () => { + const original = Object.getOwnPropertyDescriptor(Object.prototype, "mounts"); + const foreign = [ + { + secret: "foreign", + target: "/foreign", + access: "read-only", + mode: "0444", + }, + ]; + let invoked = 0; + try { + for (const descriptor of [ + { + configurable: true, + get() { + invoked++; + return foreign; + }, + set() { + invoked++; + }, + }, + { configurable: true, writable: true, value: foreign }, + ]) { + Object.defineProperty(Object.prototype, "mounts", descriptor); + const result = mapped({ + configs: { settings: { file: "./missing" } }, + services: { + reader: { image: "fixture", configs: ["settings"] }, + ungranted: { image: "fixture" }, + }, + }); + expect(result.report.complete).toBe(true); + const services = result.candidate?.services; + expect(services).toMatchObject({ + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }); + if (!(isRecord(services) && isRecord(services.ungranted))) { + throw new Error("Expected explicit fixture services"); + } + expect(Object.hasOwn(services.ungranted, "mounts")).toBe(false); + expect(invoked).toBe(0); + } + } finally { + if (original) { + Object.defineProperty(Object.prototype, "mounts", original); + } else { + Reflect.deleteProperty(Object.prototype, "mounts"); + } + } +}); + +test("file declaration publication cannot invoke inherited namespace getters/setters", () => { + const originals = ["configs", "secrets"].map((field) => ({ + field, + descriptor: Object.getOwnPropertyDescriptor(Object.prototype, field), + })); + let invoked = 0; + try { + for (const { field } of originals) { + Object.defineProperty(Object.prototype, field, { + configurable: true, + get() { + invoked++; + return { foreign: { file: CANARY } }; + }, + set() { + invoked++; + }, + }); + } + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + const candidate = result.candidate; + if (!isRecord(candidate)) { + throw new Error("Expected explicit file declarations"); + } + expect(Object.hasOwn(candidate, "configs")).toBe(true); + expect(Object.hasOwn(candidate, "secrets")).toBe(true); + expect(candidate.configs).toMatchObject({ + settings: { file: `.hack/config-${CANARY}` }, + }); + expect(candidate.secrets).toMatchObject({ + token: { file: `secrets-${CANARY}` }, + }); + expect(invoked).toBe(0); + } finally { + for (const { field, descriptor } of originals) { + if (descriptor) { + Object.defineProperty(Object.prototype, field, descriptor); + } else { + Reflect.deleteProperty(Object.prototype, field); + } + } + } +}); + +test("file-only namespace presence does not create implicit grants, including empty maps/lists", () => { + const result = mapped({ + configs: {}, + secrets: {}, + services: { reader: { image: "fixture", configs: [], secrets: [] } }, + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ configs: {}, secrets: {} }); + expect(result.candidate).not.toHaveProperty("services.reader.mounts"); + const names = mapped({ + configs: { constructor: { file: "../missing" } }, + services: { constructor: { image: "fixture", configs: ["constructor"] } }, + }); + expect(names.report.complete).toBe(true); + expect(names.candidate).toMatchObject({ + configs: { constructor: { file: "missing" } }, + services: { + constructor: { + mounts: [{ config: "constructor", target: "/constructor" }], + }, + }, + }); +}); + +test("mode syntax remains authoritative; quoted0444 and numeric292 normalize", () => { + const source = (mode: string) => + `configs:\n settings:\n file: ./missing\nservices:\n reader:\n image: fixture\n configs:\n - source: settings\n mode: ${mode}\n`; + refused( + mapLegacyNativeImport({ configText: CONFIG, composeText: source("0444") }), + "invalid_or_unsupported_file_grant" + ); + refused( + mapLegacyNativeImport({ configText: CONFIG, composeText: source("0o444") }), + "invalid_syntax" + ); + const explicit = mapLegacyNativeImport({ + configText: CONFIG, + composeText: source("292"), + }); + expect(explicit.report.complete).toBe(true); + expect(explicit.candidate).toMatchObject({ + services: { reader: { mounts: [{ mode: "0444" }] } }, + }); + const quoted = mapLegacyNativeImport({ + configText: CONFIG, + composeText: source('"0444"'), + }); + expect(quoted.report.complete).toBe(true); + expect(quoted.candidate).toMatchObject({ + services: { reader: { mounts: [{ mode: "0444" }] } }, + }); +}); + +function retainedSource() { + return JSON.stringify({ + ...fixture, + name: "fixture", + volumes: { data: {} }, + services: { + reader: { ...fixture.services.reader, volumes: ["data:/data"] }, + }, + }); +} +test("retained adoption baselines stay closed even though pure preview recognizes file grants", () => { + const composeText = retainedSource(); + refused(mapLegacyNativeAdoptionBaseline({ configText: CONFIG, composeText })); + refused(mapLegacyNativeStorageAdoption({ configText: CONFIG, composeText })); + const planned = planLegacyComposeAdoption({ + configText: CONFIG, + composeText, + }); + expect(planned.report.supported).toBe(false); + expect(planned.intent).toBeUndefined(); + expect(planned.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/configs/settings/file", + status: "refused", + code: "unsupported_field", + }) + ); + expect(JSON.stringify(planned)).not.toContain(CANARY); +}); + +test("retained file adoption refuses before key/private values/compiler/engine probes", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "import-file-adoption-")) + ); + const directory = join(root, ".hack"); + await mkdir(directory); + await writeFile(join(directory, "hack.config.json"), CONFIG); + await writeFile(join(directory, "docker-compose.yml"), retainedSource()); + await writeFile(join(directory, "hack.env.key"), CANARY, { mode: 0o600 }); + const originalFile = Bun.file; + const composePath = join(directory, "docker-compose.yml"); + const reads = spyOn(Bun, "file").mockImplementation((path, options) => { + if (path !== composePath) { + throw new Error("Private material/key lookup forbidden"); + } + return originalFile(path, options); + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Compiler/engine forbidden"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("Compiler/engine forbidden"); + }); + try { + await expect( + acquireLegacyComposeAdoptionBinding({ + projectRoot: root, + binary: join(root, "unavailable"), + }) + ).rejects.toMatchObject({ code: "E_LEGACY_COMPOSE_BINDING_UNSUPPORTED" }); + expect(reads).toHaveBeenCalled(); + const paths: readonly unknown[] = reads.mock.calls.map(([path]) => path); + expect(paths.every((path) => path === composePath)).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } finally { + reads.mockRestore(); + spawn.mockRestore(); + spawnSync.mockRestore(); + await rm(root, { recursive: true }); + } +}); + +test.skipIf(!BINARY)( + "matching compiler validates file-only declarations and explicit grants without acquiring material", + async () => { + const result = mapped(fixture); + expect(result.report.complete).toBe(true); + const compiled = await compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(result.candidate)), + binary: BINARY, + }); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + throw new Error("Compiled file-grant fixture refused"); + } + expect(compiled.plan).toMatchObject({ + configs: { + settings: { file: `.hack/config-${CANARY}` }, + unused: { file: "unused" }, + }, + secrets: { token: { file: `secrets-${CANARY}` } }, + services: { + reader: { + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + { + secret: "token", + target: "/run/secrets/token", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + } +); + +test.skipIf(!BINARY)( + "public preview keeps absent file material symbolic and compiler refusals redacted", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "import-file-preview-")) + ); + const directory = join(root, ".hack"); + await mkdir(directory); + const composeText = JSON.stringify(fixture); + await writeFile(join(directory, "hack.config.json"), CONFIG); + await writeFile(join(directory, "docker-compose.yml"), composeText); + await writeFile(join(directory, "hack.env.default.yaml"), `${CANARY}: [`); + await symlink( + join(root, "absent-private-key"), + join(directory, "hack.env.key") + ); + const names = await readdir(directory); + try { + const result = await previewNativeConfigImport({ + projectRoot: root, + binary: BINARY, + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toMatchObject({ + configs: { settings: { file: `.hack/config-${CANARY}` } }, + secrets: { token: { file: `secrets-${CANARY}` } }, + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(Object.keys(result)).toEqual(["report"]); + expect(await readFile(join(directory, "hack.config.json"), "utf8")).toBe( + CONFIG + ); + expect( + await readFile(join(directory, "docker-compose.yml"), "utf8") + ).toBe(composeText); + expect(await readdir(directory)).toEqual(names); + await writeFile( + join(directory, "docker-compose.yml"), + JSON.stringify({ + configs: fixture.configs, + services: { reader: { image: "fixture", configs: ["missing"] } }, + }) + ); + const refusedResult = await previewNativeConfigImport({ + projectRoot: root, + binary: BINARY, + }); + refused(refusedResult, "candidate_compiler_refused"); + expect(await readdir(directory)).toEqual(names); + } finally { + await rm(root, { recursive: true }); + } + } +); + +test.skipIf(!BINARY).each([ + { + configs: { settings: { file: "./missing" } }, + services: { reader: { image: "fixture", configs: ["unknown"] } }, + }, + { + configs: { settings: { file: "./missing" } }, + secrets: { token: { file: "../missing" } }, + services: { + reader: { + image: "fixture", + configs: [{ source: "settings", target: "/same" }], + secrets: [{ source: "token", target: "/same" }], + }, + }, + }, + { + configs: { settings: { file: "./missing" } }, + services: { + inactive: { image: "fixture", profiles: ["later"], configs: ["unknown"] }, + }, + }, +])( + "matching compiler remains authoritative for unknown/overlapping grants, including inactive profiles %j", + async (source) => { + const result = mapped(source); + expect(result.report.complete).toBe(true); + const compiled = await compileNativeConfig({ + input: new TextEncoder().encode(JSON.stringify(result.candidate)), + binary: BINARY, + }); + expect(compiled.ok).toBe(false); + } +); From ef27492d0453fda605712e315afca6865c4f6c6c Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:32:38 -0400 Subject: [PATCH 04/23] feat: adopt verified retained file config bindings --- docs/reference/native-compose-adoption.md | 43 + src/lib/native-compose-adoption-binding.ts | 192 ++++- src/lib/native-compose-adoption-command.ts | 1 + src/lib/native-compose-adoption-files.ts | 783 ++++++++++++++++++ src/lib/native-compose-adoption-generation.ts | 402 +++++++-- src/lib/native-compose-adoption-plan.ts | 27 +- src/lib/native-compose-adoption-preview.ts | 9 +- src/lib/native-compose-adoption-receipt.ts | 9 +- src/lib/native-config-import-plan.ts | 49 +- ...e-compose-adoption-file-generation.test.ts | 530 ++++++++++++ tests/native-compose-adoption-files.test.ts | 466 +++++++++++ 11 files changed, 2420 insertions(+), 91 deletions(-) create mode 100644 src/lib/native-compose-adoption-files.ts create mode 100644 tests/native-compose-adoption-file-generation.test.ts create mode 100644 tests/native-compose-adoption-files.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index eb5f9a1b1..5f60ee426 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -224,6 +224,49 @@ it does not independently terminate a caller-owned engine callback. ## Rollback and interruption recovery +### Original file grants: private version 8 + +The distinct retained-file preparation owner supports a first closed subset: +static image services, the original default bridge, already-bound local named +volumes and explicit file-backed **config** grants with a verified read-only +0444 target. The ordinary adoption baseline remains closed; pure import preview +alone never authorizes retained files. Builds, jobs, profiles, routes, custom +networks, readiness/dependency intersections, managed/generated inputs and typed +locals remain refused by this file family before material or engine acquisition. +Unused declarations do not grant access or authorize material reads. + +Preparation checks every original bind's exact source path, target and read-only +flag alongside the existing original resource identities. Descriptor-held reads +require canonical owned directories and regular, single-link source files; no +symlink, hardlink, path escape, source replacement or unsafe writable material is +adopted. It never rewrites or chmods the original. Private source facts retain +device/inode, owner, mode, size, timestamps and a content digest. Fixed original-ID +guest `stat`/`sha256sum` queries must agree with the host content and target 0444 +policy; effective guest UID/GID and file identity are observed, not inferred as +root. Repeated observations fence drift, but do not atomically freeze the guest, +host filesystem or Docker. + +Private manifest and receipt version 8 retain that proof. Material bytes, paths, +identities and digests never enter public plans, reports or diagnostics. Current +material and guest proof are reacquired before retained start/restart or exec, +after effects and immediately before successful journal publication. The file +owner requires one finite mutation deadline. Failure, uncertainty, permission or +content drift leaves pending ownership for explicit stop recovery. + +Saved ps/logs, stop/recovery and rollback validate the saved closed proof and exact +original bind/resource identities without acquiring current material. They can +settle stopped originals even after a material source disappears; missing or +changed material still refuses a new start or exec. Rollback restores only the +exact held authored pair and never edits a material file or deletes retained data. + +This initial permission intersection deliberately refuses secret grants. Native +secret sources currently require 0400/0600 while generated targets require 0444; +an unchanged original bind cannot truthfully satisfy both. Ordinary 0400/0600 +retained secrets require a separately qualified canonical permission contract and +context-aware mapping. Initial config support is not complete file/secret or NC04 +parity. Original mount/mode/material ownership, linked-checkout isolation, real +retained lifecycle and recovery acceptance remain separate live gates. + After the original containers are stopped, `hack config adopt --rollback` journals `rolling-back`, holds the installed candidate and restores both exact legacy originals. Link-before-unlink restoration cannot overwrite another file; diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 05576fcc6..f428089df 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,15 +1,22 @@ import { resolve } from "node:path"; import { isRecord } from "./guards.ts"; import { legacyComposeAdoptionLayoutSupported } from "./native-compose-adoption-contract.ts"; +import { + type LegacyComposeRetainedFileProof, + legacyComposeRetainedFileGrants, + observeLegacyComposeRetainedFileProof, +} from "./native-compose-adoption-files.ts"; import { retainLegacyAdoptionLocalRefusal } from "./native-compose-adoption-local.ts"; import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, + planLegacyComposeRetainedFileAdoption, } from "./native-compose-adoption-plan.ts"; import { hasLegacyComposeGeneratedSources, LegacyComposeAdoptionProjection, } from "./native-compose-adoption-projection.ts"; +import { inspectLegacyComposeContainerStates } from "./native-compose-adoption-runtime.ts"; import { createNativeComposeProbe, NativeComposeOwnershipError, @@ -20,6 +27,7 @@ import { } from "./native-config-import-inputs.ts"; import { freezeImportValue, + mapLegacyNativeRetainedFileStorage, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -339,13 +347,21 @@ function containerMounts( readonly service: string; readonly intent: LegacyComposeStorageIntent; readonly volumes: readonly LegacyComposeVerifiedVolume[]; + readonly root: string; + readonly fileCandidate?: unknown; } ) { requireValue(Array.isArray(row.mounts)); const expected = opts.intent.mounts.filter( (mount) => mount.service === opts.service ); - requireValue(row.mounts.length === expected.length); + const files = + opts.fileCandidate === undefined + ? [] + : legacyComposeRetainedFileGrants(opts.fileCandidate).filter( + (grant) => grant.service === opts.service + ); + requireValue(row.mounts.length === expected.length + files.length); const targets = new Set(); for (const item of row.mounts) { requireValue(isRecord(item)); @@ -354,6 +370,19 @@ function containerMounts( const volume = opts.volumes.find( (entry) => entry.storage === mount?.storage ); + const file = files.find((grant) => grant.target === item.target); + requireValue(typeof item.target === "string" && !targets.has(item.target)); + if (file) { + requireValue( + !mount && + item.type === "bind" && + item.name === "" && + item.source === resolve(opts.root, file.file) && + item.rw === false + ); + targets.add(item.target); + continue; + } requireValue( mount && volume && @@ -375,6 +404,7 @@ function containerRows( readonly volumes: readonly LegacyComposeVerifiedVolume[]; readonly network: { readonly name: string; readonly id: string }; readonly composeFiles: readonly string[]; + readonly fileCandidate?: unknown; } ): LegacyComposeVerifiedContainer[] { requireValue(rows.length === opts.intent.services.length); @@ -413,6 +443,10 @@ function containerRows( service: row.service, intent: opts.intent, volumes: opts.volumes, + root: opts.root, + ...(opts.fileCandidate === undefined + ? {} + : { fileCandidate: opts.fileCandidate }), }); requireValue(Array.isArray(row.networks) && row.networks.length === 1); const network = row.networks[0]; @@ -490,6 +524,32 @@ export async function inspectLegacyComposeAdoptionResources(opts: { readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly composeFiles?: readonly string[]; +}): Promise { + return await inspectLegacyResources(opts); +} + +/** Only the closed file family may add exact authored original bind mounts. No effect authority. */ +export async function inspectLegacyComposeRetainedFileResources(opts: { + readonly root: string; + readonly intent: LegacyComposeStorageIntent; + readonly candidate: unknown; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + legacyComposeRetainedFileGrants(opts.candidate); + return await inspectLegacyResources({ + ...opts, + fileCandidate: opts.candidate, + }); +} + +async function inspectLegacyResources(opts: { + readonly root: string; + readonly intent: LegacyComposeStorageIntent; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly composeFiles?: readonly string[]; + readonly fileCandidate?: unknown; }): Promise { const composeFiles = canonicalComposeFiles(opts.root, opts.composeFiles); const probe = createNativeComposeProbe(opts); @@ -599,6 +659,7 @@ export type LegacyComposeAdoptionBinding = { readonly compose: NativeConfigImportSourceIdentity; }; readonly projection?: Readonly; + readonly fileProof?: LegacyComposeRetainedFileProof; }>; }; function translate(error: unknown, signal?: AbortSignal): never { @@ -633,6 +694,38 @@ export async function acquireLegacyComposeAdoptionBinding(input: { readonly timeoutMs?: number; readonly binary?: string; }): Promise { + return await acquireBinding(input, "legacy"); +} + +/** Separate proof-bearing family; never widens ordinary binding or the preview mapper. */ +export async function acquireLegacyComposeRetainedFileBinding(input: { + readonly projectRoot: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly binary?: string; +}): Promise { + return await acquireBinding(input, "files"); +} + +/** Durable preparation selects a closed family from authored sources, never a caller-supplied proof. */ +export async function acquireLegacyComposeAdoptionPreparationBinding(input: { + readonly projectRoot: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly binary?: string; +}): Promise { + return await acquireBinding(input, "preparation"); +} + +async function acquireBinding( + input: { + readonly projectRoot: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly binary?: string; + }, + family: "legacy" | "files" | "preparation" +): Promise { let signal: AbortSignal | undefined; try { const selected = selection(input); @@ -650,25 +743,45 @@ export async function acquireLegacyComposeAdoptionBinding(input: { if (!source.ok) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } - const planned = planLegacyComposeAdoption({ + const sourcePair = { configText: source.configText, composeText: source.composeText, - }); + }; + const ordinary = planLegacyComposeAdoption(sourcePair); + const files = + family === "files" || (family === "preparation" && !ordinary.intent); + const planned = files + ? planLegacyComposeRetainedFileAdoption(sourcePair) + : ordinary; const intent = planned.intent; if (!intent) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } - const mapped = mapLegacyNativeStorageAdoption({ - configText: source.configText, - composeText: source.composeText, - }); + const mapped = files + ? mapLegacyNativeRetainedFileStorage(sourcePair) + : mapLegacyNativeStorageAdoption(sourcePair); const candidate = mapped.candidate; + if (files) { + // Close the static family before any generated/env/material/engine acquisition. + legacyComposeRetainedFileGrants(candidate); + } let projection: LegacyComposeAdoptionProjection | undefined; let projected: Readonly | undefined; const generatedPresent = await hasLegacyComposeGeneratedSources( root, signal ); + if ( + files && + (generatedPresent || + !(await legacyComposeAdoptionLayoutSupported({ + projectRoot: root, + candidate, + signal, + }))) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); + } if ( candidate && (generatedPresent || @@ -714,13 +827,34 @@ export async function acquireLegacyComposeAdoptionBinding(input: { } }; await layoutSupported(signal); - const baseline = await inspectLegacyComposeAdoptionResources({ + const baseline = await inspectLegacyResources({ root, intent, signal, timeoutMs, composeFiles: projected?.composeFiles, + ...(files ? { fileCandidate: candidate } : {}), }); + const fileProof = files + ? await observeLegacyComposeRetainedFileProof({ + projectRoot: root, + candidate, + containers: await inspectLegacyComposeContainerStates({ + binding: baseline, + signal, + timeoutMs, + }).then((states) => + baseline.containers.map((container) => ({ + ...container, + running: + states.find((state) => state.id === container.id)?.running === + true, + })) + ), + signal, + probe: createNativeComposeProbe({ signal, timeoutMs }), + }) + : undefined; freezeImportValue(baseline); const assertFresh = async (current: { readonly projectRoot: string; @@ -742,16 +876,54 @@ export async function acquireLegacyComposeAdoptionBinding(input: { } await source.assertFresh({ signal: currentSignal }); await layoutSupported(currentSignal); - const observed = await inspectLegacyComposeAdoptionResources({ + const observed = await inspectLegacyResources({ root, intent, signal: currentSignal, timeoutMs, composeFiles: projected?.composeFiles, + ...(files ? { fileCandidate: candidate } : {}), }); if (JSON.stringify(observed) !== JSON.stringify(baseline)) { refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); } + if (fileProof) { + await observeLegacyComposeRetainedFileProof({ + projectRoot: root, + candidate, + containers: await inspectLegacyComposeContainerStates({ + binding: observed, + signal: currentSignal, + timeoutMs, + }).then((states) => + observed.containers.map((container) => ({ + ...container, + running: + states.find((state) => state.id === container.id)?.running === + true, + })) + ), + saved: fileProof, + signal: currentSignal, + probe: createNativeComposeProbe({ + signal: currentSignal, + timeoutMs, + }), + }); + if ( + JSON.stringify( + await inspectLegacyResources({ + root, + intent, + signal: currentSignal, + timeoutMs, + fileCandidate: candidate, + }) + ) !== JSON.stringify(baseline) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } + } await source.assertFresh({ signal: currentSignal }); await layoutSupported(currentSignal); cancelled(signal); @@ -783,6 +955,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: { binding: baseline, sourceFiles: source.sourceFiles, ...(projected ? { projection: projected } : {}), + ...(fileProof ? { fileProof } : {}), }; for (const key of [ "configText", @@ -790,6 +963,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: { "binding", "sourceFiles", "projection", + "fileProof", ]) { Object.defineProperty(result, key, { enumerable: false }); } diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 2c75a669d..f63ddb7a3 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -321,6 +321,7 @@ export async function tryLegacyComposeAdoptedCommand( } return await store.withLease({ generation, + material: options.operation === "exec" ? "verify" : "saved", run: async (privateInput) => await observe({ options, diff --git a/src/lib/native-compose-adoption-files.ts b/src/lib/native-compose-adoption-files.ts new file mode 100644 index 000000000..975ab9a20 --- /dev/null +++ b/src/lib/native-compose-adoption-files.ts @@ -0,0 +1,783 @@ +import { join, posix, resolve } from "node:path"; +import { isRecord } from "./guards.ts"; +import { + holdNativeComposeFile, + NATIVE_COMPOSE_FILE_BYTES_LIMIT, +} from "./native-compose-file-bytes.ts"; +import { + type HeldDirectory, + holdDirectory, + recheckDirectories, +} from "./native-compose-private-state.ts"; +import { freezeImportValue } from "./native-config-import-plan.ts"; + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ID = /^[a-f0-9]{64}$/; +const DIGEST = /^[a-f0-9]{64}$/; +const TRAILING_NEWLINE = /\n$/; +const DECIMAL = /^(0|[1-9]\d*)$/; +const HEX = /^[a-f0-9]+$/; +const PATH_FORBIDDEN = /[\\\0\r\n:]/; +type Kind = "config" | "secret"; +export type LegacyComposeRetainedFileGrant = { + readonly service: string; + readonly kind: Kind; + readonly name: string; + readonly file: string; + readonly target: string; +}; +type SourceFact = { + readonly kind: Kind; + readonly name: string; + readonly file: string; + readonly dev: number; + readonly ino: number; + readonly uid: number; + readonly gid: number; + readonly mode: number; + readonly size: number; + readonly mtimeMs: number; + readonly ctimeMs: number; + readonly digest: string; +}; +type GuestFact = { + readonly service: string; + readonly container: string; + readonly target: string; + readonly dev: number; + readonly ino: number; + readonly uid: number; + readonly gid: number; + readonly mode: "0444"; + readonly size: number; + readonly digest: string; +}; +/** Private material digests and identities. Never serialize this proof into a public report. */ +export type LegacyComposeRetainedFileProof = { + readonly file_proof_version: 1; + readonly sources: readonly SourceFact[]; + readonly guests: readonly GuestFact[]; +}; +export class LegacyComposeRetainedFileError extends Error { + constructor() { + super( + "Retained file material, permissions or original binding could not be verified; values omitted." + ); + this.name = "LegacyComposeRetainedFileError"; + } +} +function refuse(): never { + throw new LegacyComposeRetainedFileError(); +} +function record(value: unknown): Record { + if ( + !isRecord(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + refuse(); + } + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + typeof key !== "string" || + !descriptor?.enumerable || + !Object.hasOwn(descriptor, "value") + ) { + refuse(); + } + } + return value; +} +function keys( + value: Record, + required: readonly string[], + optional: readonly string[] = [] +): void { + if ( + required.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some( + (key) => !(required.includes(key) || optional.includes(key)) + ) + ) { + refuse(); + } +} +function array(value: unknown): readonly unknown[] { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype + ) { + refuse(); + } + const length = Object.getOwnPropertyDescriptor(value, "length"); + if ( + !( + length && + Object.hasOwn(length, "value") && + identityNumber(length.value) + ) || + Reflect.ownKeys(value).length !== length.value + 1 + ) { + refuse(); + } + const result: unknown[] = []; + for (let index = 0; index < length.value; index++) { + const entry = Object.getOwnPropertyDescriptor(value, String(index)); + if (!(entry?.enumerable && Object.hasOwn(entry, "value"))) { + refuse(); + } + result.push(entry.value); + } + return result; +} +function containers(value: unknown): readonly { + readonly id: string; + readonly service: string; + readonly running?: boolean; +}[] { + const selected = array(value).map((raw) => { + const item = record(raw); + keys(item, ["id", "service"], ["name", "running"]); + if ( + typeof item.id !== "string" || + !ID.test(item.id) || + typeof item.service !== "string" || + (Object.hasOwn(item, "name") && typeof item.name !== "string") || + (Object.hasOwn(item, "running") && typeof item.running !== "boolean") + ) { + refuse(); + } + name(item.service); + return { + id: item.id, + service: item.service, + ...(Object.hasOwn(item, "running") + ? { running: item.running as boolean } + : {}), + }; + }); + if ( + new Set(selected.map((item) => item.id)).size !== selected.length || + new Set(selected.map((item) => item.service)).size !== selected.length + ) { + refuse(); + } + return selected; +} +function name(value: string): void { + if (value.length > 63 || !NAME.test(value)) { + refuse(); + } +} +function relative(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + !value.startsWith("/") && + !PATH_FORBIDDEN.test(value) && + value + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); +} +function target(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith("/") && + value !== "/" && + !value.endsWith("/") && + !PATH_FORBIDDEN.test(value) && + posix.normalize(value) === value + ); +} +function identityNumber(value: unknown, positive = false): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= (positive ? 1 : 0) + ); +} +function fileDeclarations( + candidate: Record, + kind: Kind +): Record { + const namespace = `${kind}s`; + const definitions = Object.hasOwn(candidate, namespace) + ? record(candidate[namespace]) + : Object.create(null); + for (const [key, value] of Object.entries(definitions)) { + name(key); + const declaration = record(value); + keys(declaration, ["file"]); + if (!relative(declaration.file)) { + refuse(); + } + } + return definitions; +} +type GrantContext = { + readonly storage: Record; + readonly definitions: Readonly>>; + readonly grants: LegacyComposeRetainedFileGrant[]; +}; +function validateRetainedStorage(storage: Record): void { + for (const [logical, raw] of Object.entries(storage)) { + name(logical); + const declaration = record(raw); + keys(declaration, ["kind", "scope"]); + if (declaration.kind !== "persistent" || declaration.scope !== "worktree") { + refuse(); + } + } +} +function appendRetainedMount( + opts: GrantContext & { + readonly service: string; + readonly raw: unknown; + readonly targets: Set; + } +): void { + const mount = record(opts.raw); + if ( + !(Object.hasOwn(mount, "target") && target(mount.target)) || + opts.targets.has(mount.target) + ) { + refuse(); + } + opts.targets.add(mount.target); + if (Object.hasOwn(mount, "storage")) { + keys(mount, ["storage", "target", "access"]); + if ( + typeof mount.storage !== "string" || + !Object.hasOwn(opts.storage, mount.storage) || + !["read-only", "read-write"].includes(String(mount.access)) + ) { + refuse(); + } + return; + } + const kind = Object.hasOwn(mount, "config") ? "config" : "secret"; + keys(mount, [kind, "target", "access", "mode"]); + // A protected 0400/0600 original secret cannot also be an unchanged 0444 target. + // The broader canonical permission contract must be earned separately. + if (kind === "secret") { + refuse(); + } + const logical = mount[kind]; + if ( + typeof logical !== "string" || + !Object.hasOwn(opts.definitions[kind], logical) || + mount.access !== "read-only" || + mount.mode !== "0444" + ) { + refuse(); + } + const declaration = record(opts.definitions[kind][logical]); + if (!relative(declaration.file)) { + refuse(); + } + opts.grants.push({ + service: opts.service, + kind, + name: logical, + file: declaration.file, + target: mount.target, + }); +} +function appendRetainedWorkload( + opts: GrantContext & { + readonly service: string; + readonly raw: unknown; + } +): void { + name(opts.service); + const workload = record(opts.raw); + keys( + workload, + ["image"], + [ + "command", + "entrypoint", + "environment", + "restart", + "init", + "shutdown", + "mounts", + ] + ); + if (typeof workload.image !== "string" || workload.image.length === 0) { + refuse(); + } + if (!Object.hasOwn(workload, "mounts")) { + return; + } + const targets = new Set(); + for (const raw of array(workload.mounts)) { + appendRetainedMount({ ...opts, raw, targets }); + } +} +/** Closed original-file family: static image services, local named storage, no jobs/build/profile or generated/managed file sources. */ +export function legacyComposeRetainedFileGrants( + candidateValue: unknown +): readonly LegacyComposeRetainedFileGrant[] { + const candidate = record(candidateValue); + keys( + candidate, + ["schema_version", "name", "services", "storage"], + ["configs", "secrets"] + ); + if (candidate.schema_version !== 1 || typeof candidate.name !== "string") { + refuse(); + } + name(candidate.name); + const definitions = { + config: fileDeclarations(candidate, "config"), + secret: fileDeclarations(candidate, "secret"), + }; + const services = record(candidate.services); + const storage = record(candidate.storage); + if (Object.keys(services).length === 0 || Object.keys(storage).length === 0) { + refuse(); + } + validateRetainedStorage(storage); + const grants: LegacyComposeRetainedFileGrant[] = []; + for (const [service, raw] of Object.entries(services)) { + appendRetainedWorkload({ service, raw, storage, definitions, grants }); + } + if (grants.length === 0) { + refuse(); + } + grants.sort( + (a, b) => + a.service.localeCompare(b.service) || a.target.localeCompare(b.target) + ); + freezeImportValue(grants); + return grants; +} +function sourceKey(value: Pick): string { + return `${value.kind}/${value.name}`; +} +function guestKey(value: Pick): string { + return `${value.service}:${value.target}`; +} +function parseSource(value: unknown): SourceFact { + const item = record(value); + keys(item, [ + "kind", + "name", + "file", + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "mtimeMs", + "ctimeMs", + "digest", + ]); + if ( + (item.kind !== "config" && item.kind !== "secret") || + typeof item.name !== "string" || + !relative(item.file) || + !identityNumber(item.dev) || + !identityNumber(item.ino, true) || + !identityNumber(item.uid) || + !identityNumber(item.gid) || + !identityNumber(item.mode) || + item.mode > 0o777 || + !identityNumber(item.size) || + item.size > NATIVE_COMPOSE_FILE_BYTES_LIMIT || + typeof item.mtimeMs !== "number" || + !Number.isFinite(item.mtimeMs) || + typeof item.ctimeMs !== "number" || + !Number.isFinite(item.ctimeMs) || + typeof item.digest !== "string" || + !DIGEST.test(item.digest) + ) { + refuse(); + } + name(item.name); + return { + kind: item.kind, + name: item.name, + file: item.file, + dev: item.dev, + ino: item.ino, + uid: item.uid, + gid: item.gid, + mode: item.mode, + size: item.size, + mtimeMs: item.mtimeMs, + ctimeMs: item.ctimeMs, + digest: item.digest, + }; +} +function parseGuest(value: unknown): GuestFact { + const item = record(value); + keys(item, [ + "service", + "container", + "target", + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "digest", + ]); + if ( + typeof item.service !== "string" || + typeof item.container !== "string" || + !ID.test(item.container) || + !target(item.target) || + !identityNumber(item.dev) || + !identityNumber(item.ino, true) || + !identityNumber(item.uid) || + !identityNumber(item.gid) || + item.mode !== "0444" || + !identityNumber(item.size) || + item.size > NATIVE_COMPOSE_FILE_BYTES_LIMIT || + typeof item.digest !== "string" || + !DIGEST.test(item.digest) + ) { + refuse(); + } + name(item.service); + return { + service: item.service, + container: item.container, + target: item.target, + dev: item.dev, + ino: item.ino, + uid: item.uid, + gid: item.gid, + mode: item.mode, + size: item.size, + digest: item.digest, + }; +} +/** Saved proof parsing binds every private claim to the actual converted source and exact original container. */ +export function readLegacyComposeRetainedFileProof(opts: { + readonly proof: unknown; + readonly candidate: unknown; + readonly containers: readonly { + readonly id: string; + readonly service: string; + }[]; +}): LegacyComposeRetainedFileProof { + const grants = legacyComposeRetainedFileGrants(opts.candidate); + const originals = containers(opts.containers); + const value = record(opts.proof); + keys(value, ["file_proof_version", "sources", "guests"]); + if (value.file_proof_version !== 1) { + refuse(); + } + const sources = array(value.sources).map(parseSource); + const guests = array(value.guests).map(parseGuest); + const unique = new Map(grants.map((grant) => [sourceKey(grant), grant])); + if ( + sources.length !== unique.size || + guests.length !== grants.length || + new Set(sources.map(sourceKey)).size !== sources.length || + new Set(guests.map(guestKey)).size !== guests.length + ) { + refuse(); + } + for (const source of sources) { + const grant = unique.get(sourceKey(source)); + if ( + grant?.file !== source.file || + source.uid !== process.getuid?.() || + (source.kind === "secret" + ? ![0o400, 0o600].includes(source.mode) + : (source.mode & 0o022) !== 0) + ) { + refuse(); + } + } + for (const grant of grants) { + const source = sources.find( + (entry) => sourceKey(entry) === sourceKey(grant) + ); + const matches = originals.filter( + (container) => container.service === grant.service + ); + const guest = guests.find((entry) => guestKey(entry) === guestKey(grant)); + if ( + !(source && guest) || + matches.length !== 1 || + guest.container !== matches[0]?.id || + guest.size !== source.size || + guest.digest !== source.digest + ) { + refuse(); + } + } + sources.sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))); + guests.sort((a, b) => guestKey(a).localeCompare(guestKey(b))); + const result = { file_proof_version: 1 as const, sources, guests }; + freezeImportValue(result); + return result; +} +/** Descriptor-held reads preserve binary/empty material. The returned proof has no bytes; temporary buffers are zeroed and all FDs close. */ +export async function observeLegacyComposeRetainedFileSources(opts: { + readonly projectRoot: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}): Promise { + const grants = legacyComposeRetainedFileGrants(opts.candidate); + const selected = [ + ...new Map(grants.map((grant) => [sourceKey(grant), grant])).values(), + ].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))); + const root = resolve(opts.projectRoot); + if (root !== opts.projectRoot) { + refuse(); + } + const directories: HeldDirectory[] = []; + const facts: SourceFact[] = []; + let remaining = NATIVE_COMPOSE_FILE_BYTES_LIMIT; + try { + directories.push(await holdDirectory(root, false)); + for (const grant of selected) { + if (opts.signal?.aborted) { + refuse(); + } + let parent = root; + for (const part of grant.file.split("/").slice(0, -1)) { + parent = join(parent, part); + if (!directories.some((held) => held.path === parent)) { + directories.push(await holdDirectory(parent, false)); + } + } + await recheckDirectories(directories); + const held = await holdNativeComposeFile({ + path: join(root, grant.file), + modes: grant.kind === "secret" ? [0o400, 0o600] : [], + limit: remaining, + }); + try { + await held.assertFresh(); + const info = held.info; + facts.push({ + kind: grant.kind, + name: grant.name, + file: grant.file, + dev: info.dev, + ino: info.ino, + uid: info.uid, + gid: info.gid, + mode: info.mode & 0o777, + size: info.size, + mtimeMs: info.mtimeMs, + ctimeMs: info.ctimeMs, + digest: held.anchor.digest, + }); + remaining -= info.size; + await recheckDirectories(directories); + await held.assertFresh(); + } finally { + await held.close(); + } + } + if (opts.signal?.aborted) { + refuse(); + } + await recheckDirectories(directories); + freezeImportValue(facts); + return facts; + } finally { + await Promise.allSettled(directories.map((held) => held.file.close())); + } +} + +function parseGuestStat(raw: string, sourceSize: number) { + const parts = raw.replace(TRAILING_NEWLINE, "").split(":"); + if ( + parts.length !== 7 || + parts.slice(0, 5).some((part) => !DECIMAL.test(part)) || + !HEX.test(parts[5] ?? "") || + parts[6] !== "444" + ) { + refuse(); + } + const [dev, ino, uid, gid, size] = parts.slice(0, 5).map(Number); + if ( + !( + identityNumber(dev) && + identityNumber(ino, true) && + identityNumber(uid) && + identityNumber(gid) && + identityNumber(size) + ) || + size !== sourceSize || + (Number.parseInt(parts[5] ?? "", 16) & 0xf0_00) !== 0x80_00 + ) { + refuse(); + } + return { dev, ino, uid, gid, size }; +} +async function observeRunningGuest(opts: { + readonly grant: LegacyComposeRetainedFileGrant; + readonly container: { readonly id: string }; + readonly source: SourceFact; + readonly probe: (args: readonly string[]) => Promise; +}): Promise { + const { grant, container, source, probe } = opts; + const raw = await probe([ + "exec", + container.id, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + grant.target, + ]); + const { dev, ino, uid, gid, size } = parseGuestStat(raw, source.size); + const digest = await probe([ + "exec", + container.id, + "sha256sum", + "--", + grant.target, + ]); + if (digest !== `${source.digest} ${grant.target}\n`) { + refuse(); + } + const repeated = await probe([ + "exec", + container.id, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + grant.target, + ]); + if (repeated !== raw) { + refuse(); + } + return { + service: grant.service, + container: container.id, + target: grant.target, + dev, + ino, + uid, + gid, + mode: "0444", + size, + digest: source.digest, + }; +} +/** Fixed read-only guest commands emit only stat fields and a private digest. They cannot authorize effects or replace original bind checks. */ +export async function observeLegacyComposeRetainedFileGuests(opts: { + readonly candidate: unknown; + readonly containers: readonly { + readonly id: string; + readonly service: string; + readonly running: boolean; + }[]; + readonly sources: readonly SourceFact[]; + readonly saved?: LegacyComposeRetainedFileProof; + readonly probe: (args: readonly string[]) => Promise; +}): Promise { + const originals = containers(opts.containers); + const sources = array(opts.sources).map(parseSource); + const saved = + opts.saved === undefined + ? undefined + : readLegacyComposeRetainedFileProof({ + proof: opts.saved, + candidate: opts.candidate, + containers: originals, + }); + const guests: GuestFact[] = []; + for (const grant of legacyComposeRetainedFileGrants(opts.candidate)) { + const matches = originals.filter( + (container) => container.service === grant.service + ); + const container = matches[0]; + const source = sources.find( + (entry) => sourceKey(entry) === sourceKey(grant) + ); + if ( + matches.length !== 1 || + !container || + !ID.test(container.id) || + !source + ) { + refuse(); + } + if (!container.running) { + const old = saved?.guests.find( + (entry) => guestKey(entry) === guestKey(grant) + ); + if ( + !old || + old.container !== container.id || + old.size !== source.size || + old.digest !== source.digest + ) { + refuse(); + } + guests.push(old); + continue; + } + guests.push( + await observeRunningGuest({ grant, container, source, probe: opts.probe }) + ); + } + guests.sort((a, b) => guestKey(a).localeCompare(guestKey(b))); + freezeImportValue(guests); + return guests; +} + +/** Private full proof; repeated descriptor reads fence changes while guest observations run. */ +export async function observeLegacyComposeRetainedFileProof(opts: { + readonly projectRoot: string; + readonly candidate: unknown; + readonly containers: readonly { + readonly id: string; + readonly service: string; + readonly running: boolean; + }[]; + readonly saved?: LegacyComposeRetainedFileProof; + readonly signal?: AbortSignal; + readonly probe: (args: readonly string[]) => Promise; +}): Promise { + const originals = containers(opts.containers); + if (originals.some((container) => container.running === undefined)) { + refuse(); + } + const saved = + opts.saved === undefined + ? undefined + : readLegacyComposeRetainedFileProof({ + proof: opts.saved, + candidate: opts.candidate, + containers: originals, + }); + const sources = await observeLegacyComposeRetainedFileSources(opts); + if (saved && JSON.stringify(sources) !== JSON.stringify(saved.sources)) { + refuse(); + } + const guests = await observeLegacyComposeRetainedFileGuests({ + ...opts, + sources, + saved, + }); + if (saved && JSON.stringify(guests) !== JSON.stringify(saved.guests)) { + refuse(); + } + const repeated = await observeLegacyComposeRetainedFileSources(opts); + if (JSON.stringify(sources) !== JSON.stringify(repeated)) { + refuse(); + } + return readLegacyComposeRetainedFileProof({ + proof: { file_proof_version: 1, sources, guests }, + candidate: opts.candidate, + containers: opts.containers, + }); +} diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 7066e9f90..ba83164d4 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -4,8 +4,9 @@ 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, + inspectLegacyComposeRetainedFileResources, type LegacyComposeVerifiedBinding, } from "./native-compose-adoption-binding.ts"; import { acquireLegacyComposeAdoptionCheckout } from "./native-compose-adoption-checkout.ts"; @@ -14,7 +15,16 @@ import { legacyComposeAdoptionCandidateSupported, legacyComposeAdoptionLayoutSupported, } from "./native-compose-adoption-contract.ts"; -import { planLegacyComposeAdoption } from "./native-compose-adoption-plan.ts"; +import { + type LegacyComposeRetainedFileProof, + observeLegacyComposeRetainedFileProof, + readLegacyComposeRetainedFileProof, +} from "./native-compose-adoption-files.ts"; +import { + type LegacyComposeStorageIntent, + planLegacyComposeAdoption, + planLegacyComposeRetainedFileAdoption, +} from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; import { type LegacyComposeRetainedPlan, @@ -35,7 +45,9 @@ import { inspectLegacyComposeContainerStates, inspectLegacyComposeReadiness, inspectLegacyComposeRuntimeConfig, + type LegacyComposeContainerState, } from "./native-compose-adoption-runtime.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; import { createNativeComposePrivateMutationLock, type HeldDirectory, @@ -61,6 +73,7 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedFileStorage, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; @@ -80,13 +93,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 | 8; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly fileProof?: unknown; readonly sourceFiles: { readonly config: NativeConfigImportSourceIdentity; readonly compose: NativeConfigImportSourceIdentity; @@ -210,20 +224,27 @@ function sourceFileIdentity( value.uid === process.getuid?.() ); } +function manifestKeys(value: Record): string { + if (value.adoption_generation_version === 8) { + return "adoption_generation_version,binding,fileProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; + } + if ( + (value.adoption_generation_version === 5 && + Object.hasOwn(value, "projectionProof")) || + value.adoption_generation_version === 3 || + value.adoption_generation_version === 4 + ) { + return "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles"; + } + return "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 === 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, manifestKeys(value)) && (value.adoption_generation_version === 1 || + value.adoption_generation_version === 8 || (value.adoption_generation_version === 5 && (!Object.hasOwn(value, "projectionProof") || (isRecord(value.projectionProof) && @@ -257,6 +278,9 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { id, binding: value.binding, runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 8 + ? { fileProof: value.fileProof } + : {}), ...((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 | 8; readonly owner: "legacy-compose"; readonly status: "prepared" | "active"; readonly containers: number; @@ -348,6 +372,8 @@ export type LegacyComposeAdoptedGenerationStore = { readonly withLease: (opts: { readonly generation: LegacyComposeAdoptedGeneration; readonly run: (input: Readonly) => Promise; + /** Saved observation only; exec and mutations always reacquire current material. */ + readonly material?: "verify" | "saved"; }) => Promise; /** Journal retained-container effects before spawning. Failed or uncertain completion fences ordinary replay. */ readonly withMutation: (opts: { @@ -386,10 +412,133 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +function savedRetainedFileProof( + meta: SavedManifest, + candidate: unknown +): LegacyComposeRetainedFileProof { + if (!(isRecord(meta.binding) && Array.isArray(meta.binding.containers))) { + refuse(); + } + const containers = meta.binding.containers.map((value: unknown) => { + if ( + !( + isRecord(value) && + keys(value, "id,name,service") && + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.service === "string" + ) + ) { + refuse(); + } + return { id: value.id, service: value.service }; + }); + return readLegacyComposeRetainedFileProof({ + proof: meta.fileProof, + candidate, + containers, + }); +} +async function verifyRetainedFileMaterial(opts: { + readonly ctx: Context; + readonly candidate: unknown; + readonly intent: LegacyComposeStorageIntent; + readonly binding: LegacyComposeVerifiedBinding; + readonly proof: LegacyComposeRetainedFileProof; +}): Promise { + const { ctx, candidate, intent, binding, proof } = opts; + const states = await inspectLegacyComposeContainerStates({ + binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + const materialProbe = createNativeComposeProbe({ + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + await observeLegacyComposeRetainedFileProof({ + projectRoot: ctx.root, + candidate, + containers: binding.containers.map((container) => ({ + ...container, + running: + states.find((state) => state.id === container.id)?.running === true, + })), + saved: proof, + signal: ctx.signal, + probe: async (args) => { + await ctx.check(); + const output = await materialProbe(args); + await ctx.check(); + return output; + }, + }); + if ( + JSON.stringify( + await inspectLegacyComposeRetainedFileResources({ + root: ctx.root, + intent, + candidate, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }) + ) !== JSON.stringify(binding) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } +} +function savedProjectionRequired(meta: SavedManifest): boolean { + return ( + (meta.adoption_generation_version === 5 && + meta.projectionProof !== undefined) || + meta.adoption_generation_version === 3 || + meta.adoption_generation_version === 4 + ); +} +async function requireReceiptFamily( + ctx: Context, + meta: SavedManifest +): Promise { + const currentReceipt = await publicationState(ctx); + if ( + (meta.adoption_generation_version === 5) !== + (currentReceipt.adoption_receipt_version === 5) || + (meta.adoption_generation_version === 8) !== + (currentReceipt.adoption_receipt_version === 8) + ) { + refuse(); + } +} +async function checkRetainedFileMaterial(opts: { + readonly ctx: Context; + readonly candidate: unknown; + readonly intent: LegacyComposeStorageIntent; + readonly binding: LegacyComposeVerifiedBinding; + readonly fileFamily: boolean; + readonly proof?: LegacyComposeRetainedFileProof; + readonly material: "verify" | "saved"; +}): Promise { + if (!opts.fileFamily) { + return; + } + if (!opts.proof) { + refuse(); + } + if (opts.material === "verify") { + await verifyRetainedFileMaterial({ + ctx: opts.ctx, + candidate: opts.candidate, + intent: opts.intent, + binding: opts.binding, + proof: opts.proof, + }); + } +} async function readInputs( ctx: Context, selected: Anchor, - preparing = false + preparing = false, + material: "verify" | "saved" = "verify" ): Promise<{ readonly manifest: Manifest; readonly inputs: Readonly; @@ -418,8 +567,13 @@ async function readInputs( join(generationRoot, "candidate.json"), meta.files.candidate ); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); - const planned = planLegacyComposeAdoption({ configText, composeText }); + const fileFamily = meta.adoption_generation_version === 8; + const mapped = fileFamily + ? mapLegacyNativeRetainedFileStorage({ configText, composeText }) + : mapLegacyNativeStorageAdoption({ configText, composeText }); + const planned = fileFamily + ? planLegacyComposeRetainedFileAdoption({ configText, composeText }) + : planLegacyComposeAdoption({ configText, composeText }); const projectionOpts = { projectRoot: ctx.root, configText, @@ -428,13 +582,9 @@ async function readInputs( signal: ctx.signal, checkOwner: ctx.check, }; - const projection = - (meta.adoption_generation_version === 5 && - meta.projectionProof !== undefined) || - meta.adoption_generation_version === 3 || - meta.adoption_generation_version === 4 - ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) - : undefined; + const projection = savedProjectionRequired(meta) + ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) + : undefined; if ( !( mapped.candidate && @@ -449,25 +599,39 @@ async function readInputs( if (retainedPlan.requiresV5 !== (meta.adoption_generation_version === 5)) { refuse(); } + const fileProof = fileFamily + ? savedRetainedFileProof(meta, mapped.candidate) + : undefined; if (!preparing) { - const currentReceipt = await publicationState(ctx); - if ( - (meta.adoption_generation_version === 5) !== - (currentReceipt.adoption_receipt_version === 5) - ) { - refuse(); - } + await requireReceiptFamily(ctx, meta); } - const observed = await inspectLegacyComposeAdoptionResources({ - root: ctx.root, - intent: planned.intent, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - composeFiles: projection?.composeFiles, - }); + const observed = fileFamily + ? await inspectLegacyComposeRetainedFileResources({ + root: ctx.root, + intent: planned.intent, + candidate: mapped.candidate, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }) + : await inspectLegacyComposeAdoptionResources({ + root: ctx.root, + intent: planned.intent, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + composeFiles: projection?.composeFiles, + }); if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } + await checkRetainedFileMaterial({ + ctx, + candidate: mapped.candidate, + intent: planned.intent, + binding: observed, + fileFamily, + proof: fileProof, + material, + }); const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ binding: observed, composeFile: join(generationRoot, "legacy-compose.yml"), @@ -517,7 +681,8 @@ async function readInputs( async function save( ctx: Context, value: Receipt, - expected: Receipt + expected: Receipt, + finalPublication?: () => Promise ): Promise { await ctx.check(); const previous = await json(ctx.receiptPath); @@ -537,6 +702,22 @@ async function save( if (!sameFile(previous.info, latest.info) || previous.text !== latest.text) { refuse(); } + if (finalPublication) { + await finalPublication(); + await ctx.check(); + const fenced = await json(ctx.receiptPath); + if ( + !sameFile(previous.info, fenced.info) || + previous.text !== fenced.text + ) { + refuse(); + } + await readArtifact( + temporary, + { ...fileIdentity(staged.info), hash: hash(staged.text) }, + STATE_LIMIT + ); + } await rename(temporary, ctx.receiptPath); await ctx.directories.at(-2)?.file.sync(); const published = await json(ctx.receiptPath); @@ -556,7 +737,7 @@ function claim( known: WeakMap, binding: LegacyComposeVerifiedBinding, status: "prepared" | "active" = "prepared", - version: 1 | 3 | 4 | 5 = 1 + version: 1 | 3 | 4 | 5 | 8 = 1 ): LegacyComposeAdoptedGeneration { const result: LegacyComposeAdoptedGeneration = { report: { @@ -575,7 +756,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 +766,9 @@ async function prepare( projectRoot: ctx.root, signal: ctx.signal, }); - const mapped = mapLegacyNativeStorageAdoption(acquired); + const mapped = acquired.fileProof + ? mapLegacyNativeRetainedFileStorage(acquired) + : mapLegacyNativeStorageAdoption(acquired); if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } @@ -633,12 +816,16 @@ async function prepare( if (legacyComposeRetainedPlan(JSON.parse(candidateText)).requiresV5) { version = 5; } + if (acquired.fileProof) { + version = 8; + } const meta: Manifest = { adoption_generation_version: version, kind: KIND, projectRoot: ctx.root, id, binding: acquired.binding, + ...(acquired.fileProof ? { fileProof: acquired.fileProof } : {}), ...(acquired.projection ? { projectionProof: acquired.projection.projectionProof } : {}), @@ -1164,7 +1351,14 @@ async function completePublication( ...current, publication: { ...installed, phase: "active" }, }, - current + current, + loaded.manifest.adoption_generation_version === 8 + ? async () => { + await readInputs(transaction, publication.generation); + await requireStopped(transaction, loaded.inputs.binding); + await requireActiveCandidate(transaction, installed, loaded.inputs); + } + : undefined ); } finally { await held.file.close(); @@ -1175,7 +1369,7 @@ async function completeRollback(ctx: Context, state: Receipt) { if (!publication || publication.phase !== "rolling-back") { refuse(); } - const loaded = await readInputs(ctx, publication.generation); + const loaded = await readInputs(ctx, publication.generation, false, "saved"); await requireStopped(ctx, loaded.inputs.binding); const held = await holdDirectory( join(ctx.generationsRoot, publication.generation.id, "originals"), @@ -1224,7 +1418,7 @@ async function completeRollback(ctx: Context, state: Receipt) { if (!(await absent(join(transaction.root, ".hack/hack.project.json")))) { refuse(); } - await readInputs(transaction, publication.generation); + await readInputs(transaction, publication.generation, false, "saved"); await requireStopped(transaction, loaded.inputs.binding); await recheckDirectories([held]); await save( @@ -1302,7 +1496,10 @@ function preparedReceiptVersion( return version; } // A rolled-back v5 contract must not label a later plain generation as v5. - if (prior.adoption_receipt_version === 5) { + if ( + prior.adoption_receipt_version === 5 || + prior.adoption_receipt_version === 8 + ) { return "kind" in checkout.git ? 2 : 1; } return prior.adoption_receipt_version; @@ -1374,7 +1571,9 @@ async function mutateRetainedContainers( if (!known.has(captured.generation)) { refuse(); } - if (captured.generation.report.adoption_generation_version !== 5) { + if ( + ![5, 8].includes(captured.generation.report.adoption_generation_version) + ) { return await mutateRetainedContainersWithinBudget( original, known, @@ -1423,7 +1622,8 @@ async function mutateRetainedContainersWithinBudget( if (!owned) { refuse(); } - const loaded = await readInputs(ctx, owned); + const material = captured.operation === "stop" ? "saved" : "verify"; + const loaded = await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, loaded); await admitCandidate(ctx, loaded.inputs, captured.binary); const services = loaded.inputs.binding.containers.map( @@ -1475,7 +1675,7 @@ async function mutateRetainedContainersWithinBudget( state ); } - await readInputs(ctx, owned); + await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, loaded); await requireReceiptSnapshot(ctx, state); requireMutationDeadline(captured, retainedPlan); @@ -1493,7 +1693,7 @@ async function mutateRetainedContainersWithinBudget( if (!callbackOpen) { refuse(); } - const current = await readInputs(ctx, owned); + const current = await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, current); await requireReceiptSnapshot(ctx, state); requireMutationDeadline(captured, retainedPlan); @@ -1506,7 +1706,7 @@ async function mutateRetainedContainersWithinBudget( callbackOpen = false; } await ctx.check(); - await readInputs(ctx, owned); + await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, loaded); const completed = await inspectLegacyComposeContainerStates({ binding: loaded.inputs.binding, @@ -1521,17 +1721,11 @@ async function mutateRetainedContainersWithinBudget( if (code !== 0) { return code; } - if ( - completed.some( - (value) => - ids.has(value.id) && - (value.paused || - value.running !== (captured.operation !== "stop") || - !["created", "running", "exited"].includes(value.status)) - ) - ) { - refuse("E_LEGACY_ADOPTION_CHANGED"); - } + requireRetainedCompletion({ + observed: completed, + ids, + operation: captured.operation, + }); if (retainedPlan.requiresV5 && captured.operation !== "stop") { const readiness = await inspectLegacyComposeReadiness({ binding: loaded.inputs.binding, @@ -1552,14 +1746,52 @@ async function mutateRetainedContainersWithinBudget( ) { refuse("E_LEGACY_ADOPTION_CHANGED"); } - await readInputs(ctx, owned); + await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, loaded); } requireMutationDeadline(captured, retainedPlan); - await save(ctx, { ...state, pendingOperation: null }, state); + await save( + ctx, + { ...state, pendingOperation: null }, + state, + loaded.manifest.adoption_generation_version === 8 + ? async () => { + const final = await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, final); + requireRetainedCompletion({ + observed: await inspectLegacyComposeContainerStates({ + binding: final.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }), + ids, + operation: captured.operation, + }); + await requireReceiptSnapshot(ctx, state); + } + : undefined + ); return code; } +function requireRetainedCompletion(opts: { + readonly observed: readonly LegacyComposeContainerState[]; + readonly ids: ReadonlySet; + readonly operation: AdoptionOperation; +}): void { + if ( + opts.observed.some( + (value) => + opts.ids.has(value.id) && + (value.paused || + value.running !== (opts.operation !== "stop") || + !["created", "running", "exited"].includes(value.status)) + ) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } +} + /** * Durable preparation and explicit stopped format transition. Uses the same * bounded private file/lock authority as native generations, with a distinct @@ -1731,7 +1963,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { publication: null, pendingOperation: null, }, - prior + prior, + loaded.manifest.adoption_generation_version === 8 + ? async () => { + await readInputs(ctx, generated, true); + } + : undefined ); return claim( generated, @@ -1756,7 +1993,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { if (!current) { return null; } - const loaded = await readInputs(ctx, current); + const loaded = await readInputs(ctx, current, false, "saved"); return claim( current, known, @@ -1778,7 +2015,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { if (state.publication?.phase !== "active") { return null; } - const loaded = await readInputs(ctx, state.publication.generation); + const loaded = await readInputs( + ctx, + state.publication.generation, + false, + "saved" + ); await requireActiveCandidate(ctx, state.publication, loaded.inputs); return claim( state.publication.generation, @@ -1831,7 +2073,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { if (state.publication?.phase !== "active") { refuse(); } - const loaded = await readInputs(ctx, state.publication.generation); + const loaded = await readInputs( + ctx, + state.publication.generation, + false, + "saved" + ); await requireActiveCandidate(ctx, state.publication, loaded.inputs); await requireStopped(ctx, loaded.inputs.binding); const next: Receipt = { @@ -1881,6 +2128,13 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { try { const captured = { ...opts }; return await lock.withLock(async () => { + if ( + captured.material !== undefined && + captured.material !== "verify" && + captured.material !== "saved" + ) { + refuse(); + } const publication = await publicationState(ctx); requireStablePublication(publication); requireNoPendingOperation(publication); @@ -1892,7 +2146,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { ) { refuse(); } - const loaded = await readInputs(ctx, current); + const loaded = await readInputs( + ctx, + current, + false, + captured.material ?? "verify" + ); if (publication.publication?.phase === "active") { await requireActiveCandidate( ctx, @@ -1902,7 +2161,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { } await requireReceiptSnapshot(ctx, publication); const value = await captured.run(loaded.inputs); - await readInputs(ctx, current); + await readInputs( + ctx, + current, + false, + captured.material ?? "verify" + ); if (publication.publication?.phase === "active") { await requireActiveCandidate( ctx, diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 2f18dfa54..22ddc4db0 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -5,6 +5,8 @@ import { import { freezeImportValue, mapLegacyNativeAdoptionBaseline, + mapLegacyNativeRetainedFileAdoptionBaseline, + type NativeImportPlan, } from "./native-config-import-plan.ts"; import { type LegacyComposeStorageIntent, @@ -63,7 +65,30 @@ export function planLegacyComposeAdoption(opts: { readonly configText: string; readonly composeText: string; }): LegacyComposeAdoptionPlan { - const baseline = mapLegacyNativeAdoptionBaseline(opts); + return planLegacyComposeStorageInput( + opts, + mapLegacyNativeAdoptionBaseline(opts) + ); +} + +/** Source-only prerequisite consumed exclusively by the distinct proof-bearing retained-file owner. */ +export function planLegacyComposeRetainedFileAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return planLegacyComposeStorageInput( + opts, + mapLegacyNativeRetainedFileAdoptionBaseline(opts) + ); +} + +function planLegacyComposeStorageInput( + opts: { + readonly configText: string; + readonly composeText: string; + }, + baseline: NativeImportPlan +): 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..a3dfefdff 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, @@ -12,6 +12,7 @@ import { import type { ImportField } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedFileStorage, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -39,9 +40,11 @@ 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.fileProof + ? mapLegacyNativeRetainedFileStorage(acquired) + : 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..2f88918c7 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 | 8; 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 === 8 || + 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 === 8 || 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 dc83a79c7..4f1c7909d 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -144,7 +144,12 @@ 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-file-baseline" + | "retained-file-storage"; }): NativeImportPlan { const config = parseImportDocument({ text: opts.configText, @@ -189,12 +194,13 @@ function mapLegacyNativeInput(opts: { mark, refuse, buildPreview: opts.purpose === "preview", - jobPreview: opts.purpose !== "adoption-baseline", + jobPreview: + opts.purpose === "preview" || opts.purpose === "storage-adoption", }); - if (opts.purpose === "preview") { - mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); - } - if (opts.purpose === "storage-adoption") { + if ( + opts.purpose === "storage-adoption" || + opts.purpose === "retained-file-storage" + ) { mapStorageCandidate({ config: config.value, compose: compose.value, @@ -203,6 +209,13 @@ function mapLegacyNativeInput(opts: { refuse, }); } + if ( + opts.purpose === "preview" || + opts.purpose === "retained-file-baseline" || + opts.purpose === "retained-file-storage" + ) { + mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); + } return nativeImportResult({ fields, candidate }); } @@ -268,6 +281,30 @@ export function mapLegacyNativeStorageAdoption(opts: { }); } +/** Explicit file-owner source baseline. Mapping alone never binds material or authorizes retained effects. */ +export function mapLegacyNativeRetainedFileAdoptionBaseline(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-file-baseline", + }); +} + +/** Original local storage is mapped first; explicit file grants append rather than erase its mounts. */ +export function mapLegacyNativeRetainedFileStorage(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-file-storage", + }); +} + type FileMappingContext = Pick; function mapFileDeclarations( diff --git a/tests/native-compose-adoption-file-generation.test.ts b/tests/native-compose-adoption-file-generation.test.ts new file mode 100644 index 000000000..1a01ea8c3 --- /dev/null +++ b/tests/native-compose-adoption-file-generation.test.ts @@ -0,0 +1,530 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { acquireLegacyComposeAdoptionBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../src/lib/native-compose-adoption-generation.ts"; +import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const ID = "a".repeat(64); +const NETWORK = "b".repeat(64); +const BIRTH = "2026-01-01T01:02:03Z"; +const CANARY = "synthetic-private-retained-file-material"; +let root: string; +let projectRoot: string; +let binary: string; +let priorPath: string | undefined; +let fixture: { + running: boolean; + failed: boolean; + digest: string; + size: number; + uid: number; + guestIno: number; + mountSource: string; + rw: boolean; + guestDelayMs?: number; + stateReads?: number; + flipAfterState?: number; +}; +beforeEach(async () => { + priorPath = process.env.PATH; + root = await realpath( + await mkdtemp(join(tmpdir(), "retained-file-generation-")) + ); + projectRoot = join(root, "checkout"); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + await mkdir(join(projectRoot, ".git")); + await mkdir(join(projectRoot, "material")); + await writeFile(join(projectRoot, "material/config"), CANARY, { + mode: 0o444, + }); + await writeFile( + join(projectRoot, ".hack/hack.config.json"), + '{"name":"fixture"}\n' + ); + await writeFile( + join(projectRoot, ".hack/docker-compose.yml"), + "name: fixture\nservices:\n app:\n image: fixture:pinned\n configs: [settings]\n volumes: [data:/data]\nconfigs:\n settings:\n file: ../material/config\nvolumes:\n data:\n name: fixture_data\n" + ); + fixture = { + running: true, + failed: false, + digest: createHash("sha256").update(CANARY).digest("hex"), + size: Buffer.byteLength(CANARY), + uid: 123, + guestIno: 456, + mountSource: join(projectRoot, "material/config"), + rw: false, + }; + await save(); + await writeFile( + join(root, "docker"), + `#!${process.execPath} +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +const root=${JSON.stringify(root)}, projectRoot=${JSON.stringify(projectRoot)}, id=${JSON.stringify(ID)}, network=${JSON.stringify(NETWORK)}, birth=${JSON.stringify(BIRTH)}; +const args=process.argv.slice(2);appendFileSync(root+'/commands',JSON.stringify(args)+'\\n'); +const value=JSON.parse(readFileSync(root+'/fixture.json','utf8')); +const [kind,action]=args; +if(kind==='exec') { + if(args[1]!==id || args.at(-1)!=='/settings') process.exit(91); + if(value.guestDelayMs) {appendFileSync(root+'/guest-pids',String(process.pid)+'\\n');await Bun.sleep(value.guestDelayMs);} + if(args[2]==='stat' && args.length===7 && args[3]==='-c' && args[4]==='%d:%i:%u:%g:%s:%f:%a' && args[5]==='--') {console.log('7:'+value.guestIno+':'+value.uid+':321:'+value.size+':'+(0o100444).toString(16)+':444');process.exit(0);} + if(args[2]==='sha256sum' && args.length===5 && args[3]==='--') {console.log(value.digest+' /settings');process.exit(0);} + process.exit(92); +} +if(kind==='container' && ['start','stop','restart'].includes(action)) { + if(args.length!==3 || args[2]!==id) process.exit(93); + value.running=action!=='stop';writeFileSync(root+'/fixture.json',JSON.stringify(value));process.exit(value.failed?7:0); +} +if(kind==='compose' && args.includes('config') && args.includes('--hash')) {console.log('app '+'d'.repeat(64));process.exit(0);} +if(kind==='info' && action==='--format') {console.log(JSON.stringify({id:'fixed-engine',os:'linux'}));process.exit(0);} +if(!['container','volume','network'].includes(kind) || !['ls','inspect'].includes(action) || !args.includes('--format')) process.exit(94); +const common={project:'fixture',native:''}; +if(action==='ls') { + console.log(JSON.stringify(kind==='container'?{id,name:'fixture-app-1',project:'fixture'}:kind==='volume'?{id:'fixture_data',name:'fixture_data',project:'fixture'}:{id:network,name:'fixture_default',project:'fixture'}));process.exit(0); +} +if(kind==='container') { + if(args.at(-1)!==id) process.exit(95); + const format=args[args.indexOf('--format')+1]; + if(format.includes('config-hash')) {console.log(JSON.stringify({id,hash:'d'.repeat(64)}));process.exit(0);} + if(!format.includes('.Mounts')) {console.log(JSON.stringify({id,running:value.running,paused:false,status:value.running?'running':'exited'}));value.stateReads=(value.stateReads??0)+1;if(value.stateReads===value.flipAfterState) value.running=!value.running;writeFileSync(root+'/fixture.json',JSON.stringify(value));process.exit(0);} + console.log(JSON.stringify({...common,id,name:'/fixture-app-1',service:'app',number:'1',oneoff:'False',running:value.running,workingDir:projectRoot+'/.hack',configFiles:projectRoot+'/.hack/docker-compose.yml',mounts:[{type:'volume',name:'fixture_data',source:'/volumes/data',target:'/data',rw:true},{type:'bind',name:'',source:value.mountSource,target:'/settings',rw:value.rw}],networks:[{name:'fixture_default',id:network}]}));process.exit(0); +} +if(kind==='volume') {console.log(JSON.stringify({...common,id:'fixture_data',name:'fixture_data',storage:'data',createdAt:birth,driver:'local',scope:'local',mountpoint:'/volumes/data',options:null}));process.exit(0);} +console.log(JSON.stringify({...common,id:network,name:'fixture_default',logical:'default',createdAt:birth,driver:'bridge',scope:'local',internal:false,containers:value.running?[id]:[]})); +` + ); + await chmod(join(root, "docker"), 0o700); + binary = join(root, "compiler"); + await writeFile( + binary, + `#!${process.execPath} +if(process.argv[2]==='--protocol') console.log(JSON.stringify(Object.fromEntries(['transport_version','authored_version','plan_version','file_plan_version'].map(key=>[key,1])))); +else {const source=JSON.parse(await Bun.stdin.text());const {schema_version,...plan}=source;console.log(JSON.stringify({transport_version:1,ok:true,plan:{plan_version:1,...plan,jobs:{},selected_profiles:[]},semantic_hash:'a'.repeat(64),declared_workloads:Object.fromEntries(Object.keys(source.services).map(name=>[name,'service']))}));} +` + ); + await chmod(binary, 0o700); + process.env.PATH = root; +}); +afterEach(async () => { + restoreEnv("PATH", priorPath); + await rm(root, { recursive: true, force: true }); +}); +async function save() { + await writeFile(join(root, "fixture.json"), JSON.stringify(fixture)); +} +async function state() { + return JSON.parse( + await readFile( + join( + projectRoot, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ), + "utf8" + ) + ); +} +async function commands() { + return (await readFile(join(root, "commands"), "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); +} +async function mutations() { + return (await commands()).filter( + (args) => + args[0] === "container" && + ["start", "stop", "restart"].includes(args[1] ?? "") + ); +} +async function prepared() { + const store = await openLegacyComposeAdoptedGenerationStore({ projectRoot }); + try { + const generation = await store.prepare({ binary }); + return { store, generation }; + } catch (error: unknown) { + await store.close(); + throw error; + } +} +async function effect(operation: "start" | "stop" | "restart") { + const current = JSON.parse( + await readFile(join(root, "fixture.json"), "utf8") + ); + current.running = operation !== "stop"; + await writeFile(join(root, "fixture.json"), JSON.stringify(current)); + return current.failed ? 7 : 0; +} + +test("file8 earns explicit preparation, stopped publication, retained lifecycle and rollback without changing material", async () => { + const original = await readFile(join(projectRoot, "material/config")); + const { store, generation } = await prepared(); + try { + expect(generation.report.adoption_generation_version).toBe(8); + expect((await state()).adoption_receipt_version).toBe(8); + expect(JSON.stringify(generation)).not.toContain(CANARY); + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect("stop"); + }, + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active generation missing"); + } + expect( + await store.withMutation({ + generation: active, + operation: "start", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + expect(JSON.stringify(input)).toBe("{}"); + return await effect("start"); + }, + }) + ).toBe(0); + expect( + await store.withMutation({ + generation: active, + operation: "restart", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect("restart"); + }, + }) + ).toBe(0); + expect( + await store.withMutation({ + generation: active, + operation: "stop", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect("stop"); + }, + }) + ).toBe(0); + await store.rollback(); + expect((await state()).publication.phase).toBe("rolled-back"); + expect((await state()).pendingOperation).toBeNull(); + expect(await readFile(join(projectRoot, "material/config"))).toEqual( + original + ); + expect( + (await commands()).every( + (args) => + !["rm", "create", "up", "down", "build", "pull"].includes( + args[1] ?? "" + ) + ) + ).toBe(true); + } finally { + await store.close(); + } +}, 30_000); + +test("ordinary binding and unsupported secret grants refuse without acquiring material or engine", async () => { + await expect( + acquireLegacyComposeAdoptionBinding({ projectRoot }) + ).rejects.toThrow(); + const source = await readFile( + join(projectRoot, ".hack/docker-compose.yml"), + "utf8" + ); + await writeFile( + join(projectRoot, ".hack/docker-compose.yml"), + source.replaceAll("configs", "secrets") + ); + const store = await openLegacyComposeAdoptedGenerationStore({ projectRoot }); + try { + await expect(store.prepare({ binary })).rejects.toThrow(); + expect(await commands().catch(() => [])).toEqual([]); + } finally { + await store.close(); + } +}); +test.each([ + "foreign-source", + "writable", +])("exact original bind %s cannot earn file8", async (variation) => { + if (variation === "foreign-source") { + fixture.mountSource = join(root, "foreign"); + } else { + fixture.rw = true; + } + await save(); + await expect(prepared()).rejects.toThrow(); + expect(await mutations()).toEqual([]); +}); +test("fresh material drift refuses start before callback; saved observations/stop/rollback still settle originals", async () => { + const { store, generation } = await prepared(); + try { + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active generation missing"); + } + await rm(join(projectRoot, "material/config")); + let calls = 0; + await expect( + store.withMutation({ + generation: active, + operation: "start", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async () => { + calls++; + return 0; + }, + }) + ).rejects.toThrow(); + expect(calls).toBe(0); + expect((await state()).pendingOperation).toBeNull(); + expect( + await store.withLease({ + generation: active, + material: "saved", + run: async () => "saved", + }) + ).toBe("saved"); + await expect( + store.withLease({ generation: active, run: async () => "exec" }) + ).rejects.toThrow(); + await store.withMutation({ + generation: active, + operation: "stop", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect("stop"); + }, + }); + await store.rollback(); + expect((await state()).publication.phase).toBe("rolled-back"); + } finally { + await store.close(); + } +}, 30_000); +test("failed effect retains uncertainty and explicit stop recovery does not need material", async () => { + const { store, generation } = await prepared(); + try { + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active generation missing"); + } + fixture.failed = true; + fixture.running = false; + await save(); + expect( + await store.withMutation({ + generation: active, + operation: "start", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("start"), + }) + ).toBe(7); + expect((await state()).pendingOperation.operation).toBe("start"); + await expect(store.loadActive()).rejects.toThrow(); + await rm(join(projectRoot, "material/config")); + fixture.failed = false; + fixture.running = true; + await save(); + const recovering = await store.loadActive({ recoverOperation: true }); + if (!recovering) { + throw new Error("recovery missing"); + } + expect( + await store.withMutation({ + generation: recovering, + operation: "stop", + services: [], + recover: true, + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }) + ).toBe(0); + expect((await state()).pendingOperation).toBeNull(); + } finally { + await store.close(); + } +}, 30_000); +test("material change after an effect cannot clear its pending journal", async () => { + const { store, generation } = await prepared(); + try { + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active generation missing"); + } + await expect( + store.withMutation({ + generation: active, + operation: "start", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async () => { + await effect("start"); + await chmod(join(projectRoot, "material/config"), 0o600); + await writeFile(join(projectRoot, "material/config"), "drift"); + return 0; + }, + }) + ).rejects.toThrow(); + expect((await state()).pendingOperation.operation).toBe("start"); + expect((await state()).publication.phase).toBe("active"); + } finally { + await store.close(); + } +}, 30_000); +test("preview exposes field reports only, never private material identities or digests", async () => { + const report = await previewLegacyComposeAdoption({ + projectRoot, + binary, + stop: true, + }); + expect(report.complete).toBe(true); + const publicText = JSON.stringify(report); + expect(publicText).not.toContain(CANARY); + expect(publicText).not.toContain(fixture.digest); + expect(publicText).not.toContain(projectRoot); + expect(publicText).not.toContain(ID); + expect(await mutations()).toEqual([]); +}, 30_000); +test("saved exec material proof charges all guest queries to one remaining probe budget", async () => { + const preparedOwner = await prepared(); + await preparedOwner.store.close(); + fixture.guestDelayMs = 800; + await save(); + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot, + mode: "saved", + timeoutMs: 2000, + }); + try { + const generation = await store.loadPrepared(); + if (!generation) { + throw new Error("prepared generation missing"); + } + let callbacks = 0; + await expect( + store.withLease({ + generation, + run: async () => { + callbacks++; + return 0; + }, + }) + ).rejects.toThrow(); + expect(callbacks).toBe(0); + const pids = (await readFile(join(root, "guest-pids"), "utf8")) + .trim() + .split("\n") + .map(Number); + expect(pids.length).toBe(3); + for (const pid of pids) { + let code: unknown; + try { + process.kill(pid, 0); + } catch (error: unknown) { + code = (error as { code?: unknown }).code; + } + expect(code).toBe("ESRCH"); + } + expect((await state()).pendingOperation).toBeNull(); + expect(await mutations()).toEqual([]); + } finally { + await store.close(); + } +}, 30_000); +test.each([ + "start", + "stop", +] as const)("%s completion drift during final material fence keeps pending ownership", async (operation) => { + const { store, generation } = await prepared(); + try { + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active generation missing"); + } + await expect( + store.withMutation({ + generation: active, + operation, + services: [], + binary, + deadline: Date.now() + 15_000, + run: async () => { + const current = JSON.parse( + await readFile(join(root, "fixture.json"), "utf8") + ); + current.running = operation === "start"; + current.stateReads = 0; + current.flipAfterState = operation === "start" ? 2 : 1; + await writeFile(join(root, "fixture.json"), JSON.stringify(current)); + return 0; + }, + }) + ).rejects.toThrow(); + expect((await state()).pendingOperation.operation).toBe(operation); + } finally { + await store.close(); + } +}, 30_000); diff --git a/tests/native-compose-adoption-files.test.ts b/tests/native-compose-adoption-files.test.ts new file mode 100644 index 000000000..28ed15153 --- /dev/null +++ b/tests/native-compose-adoption-files.test.ts @@ -0,0 +1,466 @@ +import { afterEach, beforeEach, expect, 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 { + LegacyComposeRetainedFileError, + legacyComposeRetainedFileGrants, + observeLegacyComposeRetainedFileProof, + observeLegacyComposeRetainedFileSources, + readLegacyComposeRetainedFileProof, +} from "../src/lib/native-compose-adoption-files.ts"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedFileAdoption, +} from "../src/lib/native-compose-adoption-plan.ts"; +import { + mapLegacyNativeRetainedFileAdoptionBaseline, + mapLegacyNativeRetainedFileStorage, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; + +const ID = "a".repeat(64); +const CANARY = "synthetic-private-file-value"; +let root: string; +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "retained-file-proof-"))); + await mkdir(join(root, "material")); + await writeFile( + join(root, "material/config"), + Buffer.from(`${CANARY}\0binary`), + { mode: 0o444 } + ); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function candidate() { + return { + schema_version: 1, + name: "fixture", + services: { + app: { + image: "example:fixed", + mounts: [ + { storage: "data", target: "/data", access: "read-write" }, + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + storage: { data: { kind: "persistent", scope: "worktree" } }, + configs: { + settings: { file: "material/config" }, + unused: { file: "missing-file" }, + }, + }; +} +function original(running = true) { + return [{ id: ID, service: "app", running }]; +} +async function proof( + opts: { + readonly saved?: Awaited< + ReturnType + >; + readonly running?: boolean; + readonly mode?: string; + readonly guestIno?: number; + readonly guestUid?: number; + readonly digest?: string; + readonly beforeDigest?: () => Promise; + } = {} +) { + const calls: readonly string[][] = []; + const seen: string[][] = calls as string[][]; + const sources = await observeLegacyComposeRetainedFileSources({ + projectRoot: root, + candidate: candidate(), + }); + const source = sources[0]; + if (!source) { + throw new Error("test source missing"); + } + const result = await observeLegacyComposeRetainedFileProof({ + projectRoot: root, + candidate: candidate(), + containers: original(opts.running), + saved: opts.saved, + probe: async (args) => { + seen.push([...args]); + expect(args[0]).toBe("exec"); + expect(args[1]).toBe(ID); + expect(args.at(-1)).toBe("/settings"); + if (args[2] === "stat") { + return `7:${opts.guestIno ?? 11}:${opts.guestUid ?? 101}:202:${source.size}:${(0o10_0444).toString(16)}:${opts.mode ?? "444"}\n`; + } + if (args[2] !== "sha256sum") { + throw new Error("unexpected guest command"); + } + await opts.beforeDigest?.(); + return `${opts.digest ?? source.digest} /settings\n`; + }, + }); + return { result, calls }; +} +async function refused(operation: Promise) { + let caught: unknown; + try { + await operation; + } catch (error: unknown) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(String(caught)).not.toContain(CANARY); +} + +test("closed file family preserves explicit storage and grants without reading unused declarations", async () => { + expect(legacyComposeRetainedFileGrants(candidate())).toEqual([ + { + service: "app", + kind: "config", + name: "settings", + file: "material/config", + target: "/settings", + }, + ]); + const observed = await proof(); + expect(observed.calls).toEqual([ + ["exec", ID, "stat", "-c", "%d:%i:%u:%g:%s:%f:%a", "--", "/settings"], + ["exec", ID, "sha256sum", "--", "/settings"], + ["exec", ID, "stat", "-c", "%d:%i:%u:%g:%s:%f:%a", "--", "/settings"], + ]); + expect(observed.result.guests[0]?.uid).toBe(101); + expect(observed.result.guests[0]?.gid).toBe(202); + expect(JSON.stringify(observed.result)).not.toContain(CANARY); + expect(await readFile(join(root, "material/config"))).toEqual( + Buffer.from(`${CANARY}\0binary`) + ); +}); + +test("empty and binary material retain exact content and never get chmodded", async () => { + await chmod(join(root, "material/config"), 0o600); + await writeFile(join(root, "material/config"), Buffer.alloc(0)); + await chmod(join(root, "material/config"), 0o444); + const { result } = await proof(); + expect(result.sources[0]?.size).toBe(0); + expect(result.sources[0]?.mode).toBe(0o444); +}); + +test.each([ + "600", + "400", + "444\n", + "0444", + "777", +])("guest permission %s cannot stand in for the native 0444 target", async (mode) => { + await refused(proof({ mode })); +}); +test("wrong guest material and current host edits fail before a new proof is issued", async () => { + await refused(proof({ digest: "b".repeat(64) })); + await refused( + proof({ + beforeDigest: async () => { + await chmod(join(root, "material/config"), 0o600); + await writeFile(join(root, "material/config"), "changed"); + }, + }) + ); +}); +test("fresh reads refuse symlink, hardlink and equal-byte inode replacement", async () => { + const saved = (await proof()).result; + await rename(join(root, "material/config"), join(root, "material/original")); + await symlink("original", join(root, "material/config")); + await refused(proof({ saved })); + await rm(join(root, "material/config")); + await link(join(root, "material/original"), join(root, "material/config")); + await refused(proof({ saved })); + await rm(join(root, "material/config")); + await writeFile( + join(root, "material/config"), + Buffer.from(`${CANARY}\0binary`), + { mode: 0o444 } + ); + await refused(proof({ saved })); +}); +test("stopped originals need a prior guest proof; saved proof parsing does not acquire missing material", async () => { + await refused(proof({ running: false })); + const saved = (await proof()).result; + const observed = await proof({ saved, running: false }); + expect(observed.calls).toHaveLength(0); + await rm(join(root, "material/config")); + expect( + readLegacyComposeRetainedFileProof({ + proof: saved, + candidate: candidate(), + containers: original(false), + }) + ).toEqual(saved); + await refused(proof({ saved, running: false })); +}); +test("guest inode and effective UID drift cannot reuse a previous material proof", async () => { + const saved = (await proof()).result; + await refused(proof({ saved, guestIno: 12 })); + await refused(proof({ saved, guestUid: 0 })); +}); +test("saved proof cannot invent sources, guests, extra keys or a foreign original ID", async () => { + const saved = (await proof()).result; + for (const value of [ + { ...saved, leakedValue: CANARY }, + { ...saved, sources: [] }, + { ...saved, guests: [...saved.guests, saved.guests[0]] }, + { ...saved, sources: new Array(1) }, + { + ...saved, + guests: saved.guests.map((guest) => ({ ...guest, mode: "0600" })), + }, + { + ...saved, + guests: saved.guests.map((guest) => ({ + ...guest, + container: "b".repeat(64), + })), + }, + ]) { + expect(() => + readLegacyComposeRetainedFileProof({ + proof: value, + candidate: candidate(), + containers: original(), + }) + ).toThrow(LegacyComposeRetainedFileError); + } +}); +test("required target is own-only and a malformed mount cannot invoke inherited code", () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, "target"); + let reads = 0; + try { + Object.defineProperty(Object.prototype, "target", { + configurable: true, + get() { + reads++; + return "/forged"; + }, + }); + const value = candidate(); + value.services.app.mounts = [Object.create(null)]; + expect(() => legacyComposeRetainedFileGrants(value)).toThrow( + LegacyComposeRetainedFileError + ); + expect(reads).toBe(0); + } finally { + if (previous) { + Object.defineProperty(Object.prototype, "target", previous); + } else { + Reflect.deleteProperty(Object.prototype, "target"); + } + } +}); +test("saved proof and candidate arrays reject own index accessors and custom iteration without reads", async () => { + const saved = (await proof()).result; + let reads = 0; + const accessor = (value: unknown) => { + const array = [value]; + Object.defineProperty(array, "0", { + enumerable: true, + configurable: true, + get() { + reads++; + return value; + }, + }); + return array; + }; + for (const value of [ + { ...saved, sources: accessor(saved.sources[0]) }, + { ...saved, guests: accessor(saved.guests[0]) }, + ]) { + expect(() => + readLegacyComposeRetainedFileProof({ + proof: value, + candidate: candidate(), + containers: original(), + }) + ).toThrow(LegacyComposeRetainedFileError); + } + const input = candidate(); + input.services.app.mounts = accessor( + input.services.app.mounts[0] + ) as typeof input.services.app.mounts; + expect(() => legacyComposeRetainedFileGrants(input)).toThrow( + LegacyComposeRetainedFileError + ); + expect(() => + readLegacyComposeRetainedFileProof({ + proof: saved, + candidate: candidate(), + containers: accessor(original()[0]) as ReturnType, + }) + ).toThrow(LegacyComposeRetainedFileError); + const iterated = [saved.sources[0]]; + Object.defineProperty(iterated, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + expect(() => + readLegacyComposeRetainedFileProof({ + proof: { ...saved, sources: iterated }, + candidate: candidate(), + containers: original(), + }) + ).toThrow(LegacyComposeRetainedFileError); + expect(reads).toBe(0); +}); +test("first retained family refuses secret/build/job/profile/health/network intersections before material", () => { + for (const value of [ + { ...candidate(), jobs: {} }, + { ...candidate(), networks: {} }, + { + ...candidate(), + services: { + app: { ...candidate().services.app, profiles: ["inactive"] }, + }, + }, + { + ...candidate(), + services: { + app: { ...candidate().services.app, readiness: { kind: "exec" } }, + }, + }, + { + ...candidate(), + services: { + app: { ...candidate().services.app, build: { context: "." } }, + }, + }, + { + ...candidate(), + secrets: { settings: { file: "material/config" } }, + services: { + app: { + image: "example", + mounts: [ + { + secret: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }, + }, + ]) { + expect(() => legacyComposeRetainedFileGrants(value)).toThrow( + LegacyComposeRetainedFileError + ); + } +}); +test("distinct file storage mapper preserves named volume plus explicit grant and ordinary baseline refuses", () => { + const source = { + configText: '{"name":"fixture"}', + composeText: + "name: fixture\nservices:\n app:\n image: example:fixed\n configs: [settings]\n volumes: [data:/data]\nconfigs:\n settings:\n file: ../material/config\nvolumes:\n data:\n name: fixture_data\n", + }; + expect(planLegacyComposeAdoption(source).intent).toBeUndefined(); + expect(mapLegacyNativeStorageAdoption(source).candidate).toBeUndefined(); + expect(planLegacyComposeRetainedFileAdoption(source).intent?.volumes).toEqual( + [{ storage: "data", name: "fixture_data" }] + ); + const mapped = mapLegacyNativeRetainedFileStorage(source).candidate; + expect(mapped?.services).toEqual({ + app: { + image: "example:fixed", + mounts: [ + { storage: "data", target: "/data", access: "read-write" }, + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }); +}); +test("issued private non-enumerable source fields survive both retained-file mapper seams", () => { + const compose = + "name: fixture\nservices:\n app:\n image: example:fixed\n configs: [settings]\nconfigs:\n settings:\n file: ../material/config\n"; + const issued = Object.freeze( + Object.defineProperties( + {}, + { + configText: { value: '{"name":"fixture"}' }, + composeText: { value: compose }, + } + ) + ) as { readonly configText: string; readonly composeText: string }; + expect(Object.keys(issued)).toEqual([]); + const baseline = mapLegacyNativeRetainedFileAdoptionBaseline(issued); + expect(baseline.candidate?.configs).toEqual({ + settings: { file: "material/config" }, + }); + expect(baseline.candidate?.services).toEqual({ + app: { + image: "example:fixed", + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }); + const storageIssued = Object.freeze( + Object.defineProperties( + {}, + { + configText: { value: issued.configText }, + composeText: { + value: `${compose.replace( + " configs: [settings]", + " configs: [settings]\n volumes: [data:/data]" + )}volumes:\n data:\n name: fixture_data\n`, + }, + } + ) + ) as { readonly configText: string; readonly composeText: string }; + const storage = mapLegacyNativeRetainedFileStorage(storageIssued); + expect(storage.candidate?.storage).toEqual({ + data: { kind: "persistent", scope: "worktree" }, + }); + expect(storage.candidate?.services).toEqual({ + app: { + image: "example:fixed", + mounts: [ + { storage: "data", target: "/data", access: "read-write" }, + { + config: "settings", + target: "/settings", + access: "read-only", + mode: "0444", + }, + ], + }, + }); +}); From 562e8c0de20e5758c5cb99fa25838b877bdb6dc7 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:18:42 -0400 Subject: [PATCH 05/23] feat: preserve protected native file permissions Support exact 0444, 0400 and 0600 modes on exclusive private file snapshots. Keep version 1 limited to 0444 and require protected members for version 2. Original source permissions remain unchanged; guest and retained-bind acceptance are separate gates. --- docs/reference/native-config-files.md | 16 +- src/lib/native-compose-file-bytes.ts | 25 +- src/lib/native-compose-file-owner.ts | 39 ++- src/lib/native-compose-file-permissions.ts | 32 +++ src/lib/native-compose-file-state.ts | 36 ++- src/lib/native-compose-file-subset.ts | 6 +- src/lib/native-compose-renderer.ts | 7 +- tests/native-compose-file-owner.test.ts | 254 +++++++++++++++++- tests/native-compose-file-permissions.test.ts | 122 +++++++++ tests/native-compose-file-sources.test.ts | 2 +- 10 files changed, 506 insertions(+), 33 deletions(-) create mode 100644 src/lib/native-compose-file-permissions.ts create mode 100644 tests/native-compose-file-permissions.test.ts diff --git a/docs/reference/native-config-files.md b/docs/reference/native-config-files.md index 7fcce5e96..1ea83baa5 100644 --- a/docs/reference/native-config-files.md +++ b/docs/reference/native-config-files.md @@ -60,8 +60,8 @@ managed owner and exact material projection. The native graph adapter continues refuse raw file namespace presence, including empty definitions and inactive grants, before private value copies. Neither path falls back to another backend. -The first private owner subset is read-only mode `0444` with no UID/GID override; -custom permissions, writable access, one-off `run`, and projects combining builds +The private owner supports read-only modes `0444`, `0400` and `0600` with no UID/GID +override. Other permissions, writable access, one-off `run`, and projects combining builds with file inputs remain outside that subset. File-backed Compose config/secret mounts do not implement portable ownership remapping, so emitting ignored attributes would not satisfy this contract. See the [Compose long-syntax contract](https://docs.docker.com/reference/compose-file/services/#secrets). @@ -84,9 +84,17 @@ managed owner before reading file bytes. Hooks may create the selected source fi changing selection or unsupported permission intent refuses delivery. All authored build/file combinations, including inactive workloads, refuse before private reads. -Snapshots use owned 0700 directories outside the checkout, exclusive 0444 files, -0600 metadata and exact read-only binds with `create_host_path: false`. A private +Snapshots use owned 0700 directories outside the checkout, exclusive files with +the exact selected `0444`, `0400` or `0600` mode, and 0600 metadata and exact read-only binds with `create_host_path: false`. A private generated extension anchors the root receipt, snapshot, manifest and file identities. +Snapshot reference/manifest/journal version 1 remains the exact `0444` contract. +Version 2 records protected `0400`/`0600` members, with the requested mode bound by +the selected compiler grant and each immutable file anchor. Older clients refuse +version 2. Host source permissions are observed and never changed to satisfy a +grant; the selected mode applies only to Hack's exclusive private copy. Effective +guest ownership is not remapped or inferred from an image user. This source +contract does not establish application access or retained Compose bind parity; +those need separate live ownership and permission acceptance. The bind projection encodes literal dollar signs once for Compose interpolation; filesystem paths and the stored reference remain raw. Saved document checks require those exact encoded binds and reject interpolation in additional mounts, including diff --git a/src/lib/native-compose-file-bytes.ts b/src/lib/native-compose-file-bytes.ts index 04c9fb6e8..8e5ab1aaa 100644 --- a/src/lib/native-compose-file-bytes.ts +++ b/src/lib/native-compose-file-bytes.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; import { constants, type Stats } from "node:fs"; import { type FileHandle, lstat, open } from "node:fs/promises"; +import { + type NativeComposeFileMode, + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "./native-compose-file-permissions.ts"; import { NativeComposeGenerationError, sameFile, @@ -171,29 +176,37 @@ export async function holdNativeComposeFile(opts: { export async function writeNativeComposeFile(opts: { readonly path: string; readonly bytes: Uint8Array; + readonly mode?: NativeComposeFileMode; }): Promise { + const path = opts.path; + const mode = nativeComposeFileMode( + opts.mode === undefined ? "0444" : opts.mode + ); + if (!mode) { + return refuseNativeComposeFile(); + } + const bits = nativeComposeFileModeBits(mode); const bytes = Buffer.from(opts.bytes); if (bytes.length > NATIVE_COMPOSE_FILE_BYTES_LIMIT) { bytes.fill(0); return refuseNativeComposeFile(); } const file = await open( - opts.path, + path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o444 + bits ); try { await file.writeFile(bytes); - await file.chmod(0o444); + await file.chmod(bits); await file.sync(); const info = await file.stat(); if ( !( - allowed(info, [0o444], bytes.length) && - sameFile(info, await lstat(opts.path)) + allowed(info, [bits], bytes.length) && sameFile(info, await lstat(path)) ) ) { return refuseNativeComposeFile(); @@ -202,7 +215,7 @@ export async function writeNativeComposeFile(opts: { dev: info.dev, ino: info.ino, size: bytes.length, - mode: 0o444, + mode: bits, digest: nativeComposeFileDigest(bytes), }); } finally { diff --git a/src/lib/native-compose-file-owner.ts b/src/lib/native-compose-file-owner.ts index 591f8dc3a..4382cff07 100644 --- a/src/lib/native-compose-file-owner.ts +++ b/src/lib/native-compose-file-owner.ts @@ -8,6 +8,7 @@ import { refuseNativeComposeFile, writeNativeComposeFile, } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { assertNativeComposeFileSources, closeNativeComposeFileSources, @@ -137,9 +138,12 @@ function snapshotPath(reference: NativeComposeFileReference): string { ); } function headerFor( - reference: Pick + reference: Pick< + NativeComposeFileReference, + "generationId" | "snapshotToken" | "version" + > ): string { - return `${JSON.stringify({ version: 1, kind: "native-compose-file-journal", generationId: reference.generationId, snapshotToken: reference.snapshotToken })}\n`; + return `${JSON.stringify({ version: reference.version, kind: "native-compose-file-journal", generationId: reference.generationId, snapshotToken: reference.snapshotToken })}\n`; } function checkText( read: { @@ -400,6 +404,25 @@ function matchVolume(opts: { } } +function snapshotVersion(sources: NativeComposeFileSources): 1 | 2 { + const plan = sources.result.file_plan; + if (!plan?.complete) { + return refuseNativeComposeFile(); + } + let version: 1 | 2 = 1; + for (const bindings of Object.values(plan.workloads)) { + for (const binding of bindings) { + const mode = nativeComposeFileMode(binding.mode); + if (!mode) { + return refuseNativeComposeFile(); + } + if (mode !== "0444") { + version = 2; + } + } + } + return version; +} async function memberPresent( snapshot: Snapshot, member: NativeComposeFileMember @@ -415,7 +438,7 @@ async function memberPresent( } const held = await holdNativeComposeFile({ path, - modes: [0o444], + modes: [member.file.mode], limit: member.file.size, }); try { @@ -793,13 +816,14 @@ export function createNativeComposeFileOwner(opts: { reservation, sources, }); + const version = snapshotVersion(sources); const initialized = await initializeRoot(root); let directory: HeldDirectory | undefined; try { await checkAuthority(selection); const snapshotToken = token(); const base = { - version: 1 as const, + version, root, rootToken: initialized.rootToken, rootDirectory: { @@ -834,9 +858,14 @@ export function createNativeComposeFileOwner(opts: { ...(directory ? [directory] : []), ]); const id = token(); + const mode = nativeComposeFileMode(member.binding.mode); + if (!mode) { + return refuseNativeComposeFile(); + } const file = await writeNativeComposeFile({ path: join(path, id), bytes: member.bytes, + mode, }); members.push({ id, @@ -855,7 +884,7 @@ export function createNativeComposeFileOwner(opts: { header ); const manifest: NativeComposeFileManifest = { - version: 1, + version, kind: "native-compose-file-material", reference: referenceBase, creation: binding, diff --git a/src/lib/native-compose-file-permissions.ts b/src/lib/native-compose-file-permissions.ts new file mode 100644 index 000000000..baf6253d9 --- /dev/null +++ b/src/lib/native-compose-file-permissions.ts @@ -0,0 +1,32 @@ +/** Closed backend permission subset; the compiler preserves broader intent separately. */ +export type NativeComposeFileMode = "0444" | "0400" | "0600"; +export type NativeComposeFileModeBits = 0o444 | 0o400 | 0o600; +export function nativeComposeFileMode( + value: unknown +): NativeComposeFileMode | undefined { + return value === "0444" || value === "0400" || value === "0600" + ? value + : undefined; +} +export function nativeComposeFileModeBits( + value: NativeComposeFileMode +): NativeComposeFileModeBits; +export function nativeComposeFileModeBits( + value: unknown +): NativeComposeFileModeBits | undefined; +export function nativeComposeFileModeBits( + value: unknown +): NativeComposeFileModeBits | undefined { + if (value === "0444") { + return 0o444; + } + if (value === "0400") { + return 0o400; + } + return value === "0600" ? 0o600 : undefined; +} +export function nativeComposeFileModeBitsValid( + value: unknown +): value is NativeComposeFileModeBits { + return value === 0o444 || value === 0o400 || value === 0o600; +} diff --git a/src/lib/native-compose-file-state.ts b/src/lib/native-compose-file-state.ts index 0172fe65a..bad9c6413 100644 --- a/src/lib/native-compose-file-state.ts +++ b/src/lib/native-compose-file-state.ts @@ -3,6 +3,7 @@ import { type NativeComposeFileAnchor, refuseNativeComposeFile, } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileModeBitsValid } from "./native-compose-file-permissions.ts"; import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; import { keys, parsePrivateJson } from "./native-compose-private-state.ts"; export const NATIVE_COMPOSE_FILE_STATE_LIMIT = 1024 * 1024; @@ -14,7 +15,7 @@ const TARGET_FORBIDDEN = /[\\\0]/; export type FileIdentity = { readonly dev: number; readonly ino: number }; export type StateAnchor = FileIdentity & { readonly digest: string }; export type NativeComposeFileReference = { - readonly version: 1; + readonly version: 1 | 2; readonly root: string; readonly rootToken: string; readonly rootDirectory: FileIdentity; @@ -31,7 +32,7 @@ export type NativeComposeFileMember = { readonly file: NativeComposeFileAnchor; }; export type NativeComposeFileManifest = { - readonly version: 1; + readonly version: 1 | 2; readonly kind: "native-compose-file-material"; readonly reference: Omit; readonly creation: NativeComposeMaterialBinding; @@ -136,7 +137,7 @@ export function parseNativeComposeFileReference( value, "generationId,manifest,root,rootDirectory,rootReceipt,rootToken,snapshotDirectory,snapshotToken,version" ) && - value.version === 1 && + (value.version === 1 || value.version === 2) && typeof value.root === "string" && value.root.startsWith("/") && typeof value.rootToken === "string" && @@ -154,7 +155,7 @@ export function parseNativeComposeFileReference( return refuseNativeComposeFile(); } const result: NativeComposeFileReference = { - version: 1, + version: value.version, root: value.root, rootToken: value.rootToken, rootDirectory: value.rootDirectory, @@ -167,19 +168,27 @@ export function parseNativeComposeFileReference( freezeNativeComposeFileState(result); return result; } -function fileAnchor(value: unknown): value is NativeComposeFileAnchor { +function fileAnchor( + value: unknown, + version: 1 | 2 +): value is NativeComposeFileAnchor { return ( isRecord(value) && keys(value, "dev,digest,ino,mode,size") && anchor({ dev: value.dev, ino: value.ino, digest: value.digest }) && - value.mode === 0o444 && + (version === 1 + ? value.mode === 0o444 + : nativeComposeFileModeBitsValid(value.mode)) && typeof value.size === "number" && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= NATIVE_COMPOSE_FILE_STATE_LIMIT ); } -function member(value: unknown): value is NativeComposeFileMember { +function member( + value: unknown, + version: 1 | 2 +): value is NativeComposeFileMember { if ( !( isRecord(value) && @@ -192,7 +201,7 @@ function member(value: unknown): value is NativeComposeFileMember { value.target.startsWith("/") && value.target !== "/" && !TARGET_FORBIDDEN.test(value.target) && - fileAnchor(value.file) + fileAnchor(value.file, version) ) ) { return false; @@ -277,7 +286,8 @@ export function parseNativeComposeFileManifest(opts: { !( isRecord(value) && keys(value, "creation,journal,kind,members,reference,version") && - value.version === 1 && + (value.version === 1 || value.version === 2) && + value.version === opts.reference.version && value.kind === "native-compose-file-material" && sameNativeComposeFileState(value.reference, reference) && isRecord(value.creation) && @@ -290,7 +300,7 @@ export function parseNativeComposeFileManifest(opts: { value.creation.documentHash === null && anchor(value.journal) && Array.isArray(value.members) && - value.members.every(member) + value.members.every((entry) => member(entry, opts.reference.version)) ) ) { return refuseNativeComposeFile(); @@ -301,14 +311,16 @@ export function parseNativeComposeFileManifest(opts: { ); if ( ids.size !== value.members.length || - targets.size !== value.members.length + targets.size !== value.members.length || + (opts.reference.version === 2 && + !value.members.some((entry) => entry.file.mode !== 0o444)) ) { return refuseNativeComposeFile(); } // Creation bindings are produced by the same opaque authority. Saved identities // select immutable material; they never grant a live completion capability. const result: NativeComposeFileManifest = { - version: 1, + version: opts.reference.version, kind: "native-compose-file-material", reference, creation: parseBinding(value.creation, { diff --git a/src/lib/native-compose-file-subset.ts b/src/lib/native-compose-file-subset.ts index 1146769df..49f2d0f2d 100644 --- a/src/lib/native-compose-file-subset.ts +++ b/src/lib/native-compose-file-subset.ts @@ -1,12 +1,13 @@ import { isRecord } from "./guards.ts"; import { refuseNativeComposeFile } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { authoredFilePlanningRequired } from "./native-file-plan-protocol.ts"; function unsupported(): never { throw new NativeConfigCompilerError( "E_NATIVE_PROJECT_UNSUPPORTED", - "Native file delivery requires read-only mode 0444, no UID/GID override and no builds. Values omitted." + "Native file delivery requires read-only mode 0444, 0400 or 0600, no UID/GID override and no builds. Values omitted." ); } function assertWorkloadSubset(workload: unknown): void { @@ -28,9 +29,10 @@ function assertWorkloadSubset(workload: unknown): void { ) { continue; } + const mode = Object.hasOwn(mount, "mode") ? mount.mode : undefined; if ( mount.access !== "read-only" || - (mount.mode !== undefined && mount.mode !== "0444") || + (mode !== undefined && nativeComposeFileMode(mode) === undefined) || Object.hasOwn(mount, "uid") || Object.hasOwn(mount, "gid") ) { diff --git a/src/lib/native-compose-renderer.ts b/src/lib/native-compose-renderer.ts index 66369dd28..5326fb61b 100644 --- a/src/lib/native-compose-renderer.ts +++ b/src/lib/native-compose-renderer.ts @@ -10,6 +10,7 @@ import { type NativeComposeFileProjection, nativeComposeFileProjectionMatches, } from "./native-compose-file-owner.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { NATIVE_COMPOSE_FILES_EXTENSION } from "./native-compose-file-state.ts"; import { selectNativeComposeBeforeHooks } from "./native-compose-host-contract.ts"; import { @@ -836,7 +837,9 @@ function renderMounts(opts: { const kind = Object.hasOwn(mount, "config") ? "config" : "secret"; closed(mount, [kind, "target", "access", "mode"]); assert( - context.files && mount.access === "read-only" && mount.mode === "0444", + context.files && + mount.access === "read-only" && + nativeComposeFileMode(mount.mode) !== undefined, "E_COMPOSE_FILE_OWNER" ); assert( @@ -845,7 +848,7 @@ function renderMounts(opts: { file.kind === kind && file.name === mount[kind] && file.target === mount.target && - file.mode === "0444" && + file.mode === mount.mode && file.uid === undefined && file.gid === undefined ), diff --git a/tests/native-compose-file-owner.test.ts b/tests/native-compose-file-owner.test.ts index 013f417c1..24101f0aa 100644 --- a/tests/native-compose-file-owner.test.ts +++ b/tests/native-compose-file-owner.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { + chmod, lstat, mkdir, mkdtemp, @@ -25,9 +26,14 @@ import { closeNativeComposeFileSources, type NativeComposeFileSources, } from "../src/lib/native-compose-file-sources.ts"; -import { NATIVE_COMPOSE_FILES_EXTENSION } from "../src/lib/native-compose-file-state.ts"; +import { + NATIVE_COMPOSE_FILES_EXTENSION, + parseNativeComposeFileManifest, + parseNativeComposeFileReference, +} from "../src/lib/native-compose-file-state.ts"; import { type NativeComposeGenerationStore, + type NativeComposeMaterialBinding, type NativeComposeMutation, openNativeComposeGenerationStore, } from "../src/lib/native-compose-generation.ts"; @@ -309,6 +315,7 @@ test("actual acquisition stages binary and empty 0444 files outside checkout", a const selected = await staged(mutation); expect(JSON.stringify(selected.attempt)).toBe("{}"); const paths = memberPaths(selected.projection); + expect(selected.projection.reference.version).toBe(1); expect(paths.every((path) => !path.startsWith(root))).toBe(true); expect(await readFile(paths[0] ?? "")).toEqual(BYTES); expect(await readFile(paths[1] ?? "")).toEqual(Buffer.alloc(0)); @@ -1352,3 +1359,248 @@ test("known reaped readiness failure retains pending material until a separate v await expectAbsent(memberPaths(selected.projection)); }); }, 30_000); + +test.each([ + ["0400", 0o400], + ["0600", 0o600], +] as const)("protected %s delivery preserves original permissions and binds exact private snapshot2 modes", async (mode, bits) => { + const secretPath = join(root, "protected.bin"); + await writeFile(secretPath, BYTES, { mode: bits }); + const original = await lstat(secretPath); + await writeFile( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + ...SOURCE, + secrets: { empty: { file: "protected.bin" } }, + services: { + reader: { + ...SOURCE.services.reader, + mounts: [ + SOURCE.services.reader.mounts[0], + { + secret: "empty", + target: "/run/empty", + access: "read-only", + mode, + }, + ], + }, + }, + }) + ); + const selected = await store.withMutation(running); + expect(selected.projection.reference.version).toBe(2); + const granted = selected.projection.workloads.reader; + const secret = granted?.find((grant) => grant.target === "/run/empty"); + const config = granted?.find((grant) => grant.target === "/etc/settings"); + if (!(secret && config)) { + throw new Error("Missing selected file grants"); + } + expect(await readFile(secret.source)).toEqual(BYTES); + expect((await lstat(secret.source)).mode & 0o777).toBe(bits); + expect((await lstat(config.source)).mode & 0o777).toBe(0o444); + const after = await lstat(secretPath); + expect({ + dev: after.dev, + ino: after.ino, + mode: after.mode, + uid: after.uid, + gid: after.gid, + }).toEqual({ + dev: original.dev, + ino: original.ino, + mode: original.mode, + uid: original.uid, + gid: original.gid, + }); + const reference = selected.projection.reference; + const text = await readFile( + join( + reference.root, + `${reference.generationId}-${reference.snapshotToken}`, + "manifest.json" + ), + "utf8" + ); + const raw: unknown = JSON.parse(text); + if (!(isRecord(raw) && isRecord(raw.creation))) { + throw new Error("Missing owned material binding"); + } + // This binding comes from the genuine owner-produced private manifest; parsing + // below verifies it and no external effect authority is obtained from this fixture. + const binding = raw.creation as NativeComposeMaterialBinding; + const manifest = parseNativeComposeFileManifest({ text, reference, binding }); + expect(manifest.version).toBe(2); + expect( + manifest.members.find((member) => member.target === "/run/empty")?.file.mode + ).toBe(bits); + for (const changed of [0o000, 0o644, 0o777, "0400", null]) { + const forged = structuredClone(manifest); + const originalMembers = forged.members.map((member) => ({ + ...member, + file: { ...member.file }, + })); + const member = originalMembers.find( + (entry) => entry.target === "/run/empty" + ); + if (!member) { + throw new Error("Missing owned secret member"); + } + const altered = { + ...forged, + members: originalMembers.map((entry) => + entry === member + ? { ...entry, file: { ...entry.file, mode: changed } } + : entry + ), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(altered), + reference, + binding, + }) + ).toThrow(); + } + const legacy = { + ...manifest, + version: 1, + reference: { ...manifest.reference, version: 1 }, + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(legacy), + reference: { ...reference, version: 1 }, + binding, + }) + ).toThrow(); + const allPublic = { + ...manifest, + members: manifest.members.map((member) => ({ + ...member, + file: { ...member.file, mode: 0o444 }, + })), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(allPublic), + reference, + binding, + }) + ).toThrow(); + expect(() => + parseNativeComposeFileReference({ ...reference, version: 3 }) + ).toThrow(); + const unknownVersion = { + ...manifest, + version: 3, + reference: { ...manifest.reference, version: 3 }, + }; + expect(() => + Reflect.apply(parseNativeComposeFileManifest, undefined, [ + { + text: JSON.stringify(unknownVersion), + reference: { ...reference, version: 3 }, + binding, + }, + ]) + ).toThrow(); + await store.withMutation(async (mutation) => { + await ownerFor(mutation).assertSavedReady(selected.generation); + }); + expect(JSON.stringify(selected.attempt)).toBe("{}"); +}); + +test("protected snapshot mode drift refuses saved readiness while preserving owned stop recovery", async () => { + await writeFile(join(root, "protected.bin"), BYTES, { mode: 0o600 }); + await writeFile( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + ...SOURCE, + secrets: { empty: { file: "protected.bin" } }, + services: { + reader: { + ...SOURCE.services.reader, + mounts: [ + SOURCE.services.reader.mounts[0], + { + secret: "empty", + target: "/run/empty", + access: "read-only", + mode: "0400", + }, + ], + }, + }, + }) + ); + const selected = await store.withMutation(running); + const secret = selected.projection.workloads.reader?.find( + (grant) => grant.target === "/run/empty" + ); + if (!secret) { + throw new Error("Missing selected secret grant"); + } + await chmod(secret.source, 0o600); + let children = 0; + let readinessChecks = 0; + await expect( + store.withMutation(async (mutation) => { + const owner = ownerFor(mutation); + await mutation.runEffect({ + generation: selected.generation, + operation: "up", + // This control isolates the saved-material guard, not source admission; + // input freshness is synthetic and no expired first-mutation authority is reused. + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + readinessChecks++; + await owner.assertSavedReady(selected.generation); + children++; + return { outcome: "complete", value: 0 }; + }, + }); + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(readinessChecks).toBe(1); + expect(children).toBe(0); + expect((await store.loadPending())?.generationId).toBe( + selected.generation.generationId + ); + await expect( + store.withMutation(async (mutation) => { + const owner = ownerFor(mutation); + await mutation.runEffect({ + generation: selected.generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => { + const attempt = await owner.armStop(selected.generation); + if (!attempt) { + throw new Error("Missing known owned stop attempt"); + } + children++; + await owner.recordStopReaped({ + attempt, + assertReaped: async () => {}, + }); + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + await owner.retire({ + generation: selected.generation, + assertAbsent: async () => {}, + }); + }, + }); + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(children).toBe(1); + expect((await store.loadPending())?.generationId).toBe( + selected.generation.generationId + ); + expect((await lstat(secret.source)).mode & 0o777).toBe(0o600); + expect((await lstat(join(root, "protected.bin"))).mode & 0o777).toBe(0o600); +}); diff --git a/tests/native-compose-file-permissions.test.ts b/tests/native-compose-file-permissions.test.ts new file mode 100644 index 000000000..31e3b3c93 --- /dev/null +++ b/tests/native-compose-file-permissions.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test"; +import { lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeNativeComposeFile } from "../src/lib/native-compose-file-bytes.ts"; +import { + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "../src/lib/native-compose-file-permissions.ts"; +import { assertNativeComposeFileSubset } from "../src/lib/native-compose-file-subset.ts"; + +test.each([ + ["0444", 0o444], + ["0400", 0o400], + ["0600", 0o600], +] as const)("exclusive private writer delivers exact %s permission and binary bytes", async (mode, bits) => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-file-permission-")) + ); + try { + const path = join(root, "copy"); + const bytes = new Uint8Array([0, 255, 10]); + const anchor = await writeNativeComposeFile({ path, bytes, mode }); + expect(anchor.mode).toBe(bits); + expect((await lstat(path)).mode & 0o777).toBe(bits); + expect(await readFile(path)).toEqual(Buffer.from(bytes)); + await expect( + writeNativeComposeFile({ path, bytes, mode }) + ).rejects.toMatchObject({ code: "EEXIST" }); + expect((await lstat(path)).ino).toBe(anchor.ino); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test.each( + ["0000", "0644", "0777", "400", "600", 0o400, 0o600, null, false, {}, []].map( + (mode) => ({ mode }) + ) +)("closed native permission subset refuses unsupported %j even in inactive workloads", ({ + mode, +}) => { + expect(nativeComposeFileMode(mode)).toBeUndefined(); + expect(nativeComposeFileModeBits(mode)).toBeUndefined(); + const input = new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + name: "fixture", + configs: { settings: { file: "missing" } }, + services: { + inactive: { + image: "fixture", + profiles: ["later"], + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode, + }, + ], + }, + }, + }) + ); + expect(() => assertNativeComposeFileSubset(input)).toThrow(); +}); + +test("untrusted writer permission refuses before publishing a private member", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-file-invalid-permission-")) + ); + try { + const path = join(root, "copy"); + await expect( + Reflect.apply(writeNativeComposeFile, undefined, [ + { + path, + bytes: new Uint8Array([0, 255]), + mode: "0644", + }, + ]) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test.each([ + "0444", + "0400", + "0600", +])("supported mode %s preserves whole-input no-build/no-UID policy", (mode) => { + const workload = { + image: "fixture", + profiles: ["later"], + mounts: [{ secret: "token", target: "/token", access: "read-only", mode }], + }; + const input = (selected: unknown) => + new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + name: "fixture", + secrets: { token: { file: "missing" } }, + services: { inactive: selected }, + }) + ); + expect(() => assertNativeComposeFileSubset(input(workload))).not.toThrow(); + expect(() => + assertNativeComposeFileSubset( + input({ ...workload, build: { context: "." } }) + ) + ).toThrow(); + for (const extra of [{ uid: 0 }, { gid: 0 }, { access: "read-write" }]) { + expect(() => + assertNativeComposeFileSubset( + input({ ...workload, mounts: [{ ...workload.mounts[0], ...extra }] }) + ) + ).toThrow(); + } +}); diff --git a/tests/native-compose-file-sources.test.ts b/tests/native-compose-file-sources.test.ts index 5709e3cdd..35df5c812 100644 --- a/tests/native-compose-file-sources.test.ts +++ b/tests/native-compose-file-sources.test.ts @@ -367,7 +367,7 @@ test.each([ raw.services.off = { build: { context: "." }, profiles: ["inactive"] }; } else { raw.services.reader.mounts[0][kind] = - kind === "uid" ? 0 : kind === "mode" ? "0600" : "read-write"; + kind === "uid" ? 0 : kind === "mode" ? "0644" : "read-write"; } await writeFile(join(root, ".hack/hack.project.json"), JSON.stringify(raw)); await writeFile(join(root, ".hack/hack.env.json"), CANARY); From 6d6ccb22022e83a5fb8124408247b5ab76ac8b25 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:50:14 -0400 Subject: [PATCH 06/23] feat: preserve protected secrets during retained adoption Bind omitted and explicit secret permission intent to the observed original source and guest in closed private file proof2. Normalize the candidate only after that proof and reproduce it from saved intent for read-only recovery. Keep config proof1 and original material ownership unchanged. Actual guest access, readonly writes, linked isolation and lifecycle acceptance remain separate gates; this checkpoint carries source and focused controls. --- docs/reference/native-compose-adoption.md | 36 +- src/lib/native-compose-adoption-binding.ts | 14 + src/lib/native-compose-adoption-files.ts | 279 ++++++++++++--- src/lib/native-compose-adoption-generation.ts | 47 ++- src/lib/native-compose-adoption-preview.ts | 9 +- src/lib/native-config-import-files.ts | 130 ++++--- src/lib/native-config-import-plan.ts | 57 ++- ...e-compose-adoption-file-generation.test.ts | 115 +++++- ...ompose-adoption-secret-permissions.test.ts | 334 ++++++++++++++++++ 9 files changed, 906 insertions(+), 115 deletions(-) create mode 100644 tests/native-compose-adoption-secret-permissions.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 869a74468..93958bbf6 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -241,10 +241,11 @@ it does not independently terminate a caller-owned engine callback. ### Original file grants: private version 8 -The distinct retained-file preparation owner supports a first closed subset: +The distinct retained-file preparation owner supports a closed subset: static image services, the original default bridge, already-bound local named -volumes and explicit file-backed **config** grants with a verified read-only -0444 target. The ordinary adoption baseline remains closed; pure import preview +volumes and explicit file-backed config grants with a verified read-only `0444` +target, plus protected original secret grants with verified `0400` or `0600` +source and guest permissions. The ordinary adoption baseline remains closed; pure import preview alone never authorizes retained files. Builds, jobs, profiles, routes, custom networks, readiness/dependency intersections, managed/generated inputs and typed locals remain refused by this file family before material or engine acquisition. @@ -256,8 +257,8 @@ require canonical owned directories and regular, single-link source files; no symlink, hardlink, path escape, source replacement or unsafe writable material is adopted. It never rewrites or chmods the original. Private source facts retain device/inode, owner, mode, size, timestamps and a content digest. Fixed original-ID -guest `stat`/`sha256sum` queries must agree with the host content and target 0444 -policy; effective guest UID/GID and file identity are observed, not inferred as +guest `stat`/`sha256sum` queries must agree with the host content and selected +permission policy; effective guest UID/GID and file identity are observed, not inferred as root. Repeated observations fence drift, but do not atomically freeze the guest, host filesystem or Docker. @@ -274,13 +275,24 @@ settle stopped originals even after a material source disappears; missing or changed material still refuses a new start or exec. Rollback restores only the exact held authored pair and never edits a material file or deletes retained data. -This initial permission intersection deliberately refuses secret grants. Native -secret sources currently require 0400/0600 while generated targets require 0444; -an unchanged original bind cannot truthfully satisfy both. Ordinary 0400/0600 -retained secrets require a separately qualified canonical permission contract and -context-aware mapping. Initial config support is not complete file/secret or NC04 -parity. Original mount/mode/material ownership, linked-checkout isolation, real -retained lifecycle and recovery acceptance remain separate live gates. +Private file proof version 1 preserves the config-only `0444` contract. Version 2 +adds protected original secret binds with exact `0400` or `0600` source and guest +permissions. The strict retained-purpose mapper preserves whether a permission +was omitted or explicitly declared. An omitted secret permission can normalize +only to the verified original effective permission; an explicit permission must +agree with that same source and guest. Explicit `0444` over a protected source +refuses. Config grants remain `0444`, and pure import preview keeps its existing +declarative defaults. The private candidate is normalized only after the original +proof, then compiled. Saved reads derive the same candidate from the immutable +proof and raw authored intent without material reads. Unknown proof versions, +policy-presence swaps, changed source mode and changed guest UID/GID refuse. + +This source contract is not complete file/secret or NC04 parity. No original +permission is changed, no guest owner is inferred, and UID/GID overrides remain +refused. Original mount and material ownership, granted and ungranted reads, +read-only write refusal, linked-checkout isolation, retained lifecycle, recovery +and rollback remain required live gates. Mixed build, job, custom-network, +routing, generated, managed and typed-local families stay outside this owner. After the original containers are stopped, `hack config adopt --rollback` journals `rolling-back`, holds the installed candidate and restores both exact diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 6b3bb7264..e667f7d00 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -5,6 +5,7 @@ import { type LegacyComposeRetainedFileProof, legacyComposeRetainedFileGrants, observeLegacyComposeRetainedFileProof, + observeLegacyComposeRetainedFileSources, } from "./native-compose-adoption-files.ts"; import { retainLegacyAdoptionLocalRefusal } from "./native-compose-adoption-local.ts"; import { @@ -892,6 +893,13 @@ async function acquireBinding( } }; await layoutSupported(signal); + const initialFileSources = files + ? await observeLegacyComposeRetainedFileSources({ + projectRoot: root, + candidate, + signal, + }) + : undefined; const baseline = await inspectLegacyResources({ root, intent, @@ -920,6 +928,12 @@ async function acquireBinding( probe: createNativeComposeProbe({ signal, timeoutMs }), }) : undefined; + if ( + fileProof && + JSON.stringify(fileProof.sources) !== JSON.stringify(initialFileSources) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } freezeImportValue(baseline); const assertFresh = async (current: { readonly projectRoot: string; diff --git a/src/lib/native-compose-adoption-files.ts b/src/lib/native-compose-adoption-files.ts index 975ab9a20..1fed36085 100644 --- a/src/lib/native-compose-adoption-files.ts +++ b/src/lib/native-compose-adoption-files.ts @@ -4,12 +4,21 @@ import { holdNativeComposeFile, NATIVE_COMPOSE_FILE_BYTES_LIMIT, } from "./native-compose-file-bytes.ts"; +import { + type NativeComposeFileMode, + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "./native-compose-file-permissions.ts"; import { type HeldDirectory, holdDirectory, recheckDirectories, } from "./native-compose-private-state.ts"; -import { freezeImportValue } from "./native-config-import-plan.ts"; +import type { RetainedFilePermissionPolicy } from "./native-config-import-files.ts"; +import { + freezeImportValue, + legacyNativeRetainedFilePolicies, +} from "./native-config-import-plan.ts"; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const ID = /^[a-f0-9]{64}$/; @@ -48,16 +57,21 @@ type GuestFact = { readonly ino: number; readonly uid: number; readonly gid: number; - readonly mode: "0444"; + readonly mode: NativeComposeFileMode; readonly size: number; readonly digest: string; }; /** Private material digests and identities. Never serialize this proof into a public report. */ export type LegacyComposeRetainedFileProof = { - readonly file_proof_version: 1; readonly sources: readonly SourceFact[]; readonly guests: readonly GuestFact[]; -}; +} & ( + | { readonly file_proof_version: 1 } + | { + readonly file_proof_version: 2; + readonly policies: readonly RetainedFilePermissionPolicy[]; + } +); export class LegacyComposeRetainedFileError extends Error { constructor() { super( @@ -220,6 +234,7 @@ type GrantContext = { readonly storage: Record; readonly definitions: Readonly>>; readonly grants: LegacyComposeRetainedFileGrant[]; + readonly policies?: readonly RetainedFilePermissionPolicy[]; }; function validateRetainedStorage(storage: Record): void { for (const [logical, raw] of Object.entries(storage)) { @@ -259,20 +274,29 @@ function appendRetainedMount( } const kind = Object.hasOwn(mount, "config") ? "config" : "secret"; keys(mount, [kind, "target", "access", "mode"]); - // A protected 0400/0600 original secret cannot also be an unchanged 0444 target. - // The broader canonical permission contract must be earned separately. - if (kind === "secret") { - refuse(); - } const logical = mount[kind]; + const mode = nativeComposeFileMode(mount.mode); if ( typeof logical !== "string" || !Object.hasOwn(opts.definitions[kind], logical) || mount.access !== "read-only" || - mount.mode !== "0444" + !mode || + (kind === "config" && mode !== "0444") ) { refuse(); } + if (kind === "secret") { + const policy = opts.policies?.find( + (entry) => + entry.service === opts.service && + entry.kind === kind && + entry.name === logical && + entry.target === mount.target + ); + if (!policy || mode !== (policy.declaredMode ?? "0444")) { + refuse(); + } + } const declaration = record(opts.definitions[kind][logical]); if (!relative(declaration.file)) { refuse(); @@ -343,7 +367,14 @@ export function legacyComposeRetainedFileGrants( validateRetainedStorage(storage); const grants: LegacyComposeRetainedFileGrant[] = []; for (const [service, raw] of Object.entries(services)) { - appendRetainedWorkload({ service, raw, storage, definitions, grants }); + appendRetainedWorkload({ + service, + raw, + storage, + definitions, + grants, + policies: legacyNativeRetainedFilePolicies(candidate), + }); } if (grants.length === 0) { refuse(); @@ -414,7 +445,7 @@ function parseSource(value: unknown): SourceFact { digest: item.digest, }; } -function parseGuest(value: unknown): GuestFact { +function parseGuest(value: unknown, version: 1 | 2): GuestFact { const item = record(value); keys(item, [ "service", @@ -437,7 +468,8 @@ function parseGuest(value: unknown): GuestFact { !identityNumber(item.ino, true) || !identityNumber(item.uid) || !identityNumber(item.gid) || - item.mode !== "0444" || + !nativeComposeFileMode(item.mode) || + (version === 1 && item.mode !== "0444") || !identityNumber(item.size) || item.size > NATIVE_COMPOSE_FILE_BYTES_LIMIT || typeof item.digest !== "string" || @@ -454,11 +486,81 @@ function parseGuest(value: unknown): GuestFact { ino: item.ino, uid: item.uid, gid: item.gid, - mode: item.mode, + mode: nativeComposeFileMode(item.mode) ?? refuse(), size: item.size, digest: item.digest, }; } +function parsePolicy(value: unknown): RetainedFilePermissionPolicy { + const item = record(value); + keys(item, ["service", "kind", "name", "target", "declaredMode"]); + if ( + typeof item.service !== "string" || + typeof item.name !== "string" || + (item.kind !== "config" && item.kind !== "secret") || + !target(item.target) || + (item.declaredMode !== null && !nativeComposeFileMode(item.declaredMode)) + ) { + refuse(); + } + name(item.service); + name(item.name); + return { + service: item.service, + kind: item.kind, + name: item.name, + target: item.target, + declaredMode: + item.declaredMode === null + ? null + : (nativeComposeFileMode(item.declaredMode) ?? refuse()), + }; +} +function effectiveMode(opts: { + readonly candidate: unknown; + readonly grant: LegacyComposeRetainedFileGrant; + readonly source: SourceFact; +}): NativeComposeFileMode { + if (opts.grant.kind === "config") { + return "0444"; + } + const policy = legacyNativeRetainedFilePolicies(opts.candidate)?.find( + (entry) => + entry.service === opts.grant.service && + entry.target === opts.grant.target && + entry.kind === "secret" && + entry.name === opts.grant.name + ); + const mode = new Map([ + [0o400, "0400"], + [0o600, "0600"], + ]).get(opts.source.mode); + if ( + !(policy && mode) || + (policy.declaredMode !== null && policy.declaredMode !== mode) + ) { + refuse(); + } + return mode; +} + +function assertProofSources(opts: { + readonly sources: readonly SourceFact[]; + readonly grants: ReadonlyMap; +}) { + for (const source of opts.sources) { + const grant = opts.grants.get(sourceKey(source)); + if ( + grant?.file !== source.file || + source.uid !== process.getuid?.() || + (source.kind === "secret" + ? ![0o400, 0o600].includes(source.mode) + : (source.mode & 0o022) !== 0) + ) { + refuse(); + } + } +} /** Saved proof parsing binds every private claim to the actual converted source and exact original container. */ export function readLegacyComposeRetainedFileProof(opts: { readonly proof: unknown; @@ -471,12 +573,31 @@ export function readLegacyComposeRetainedFileProof(opts: { const grants = legacyComposeRetainedFileGrants(opts.candidate); const originals = containers(opts.containers); const value = record(opts.proof); - keys(value, ["file_proof_version", "sources", "guests"]); - if (value.file_proof_version !== 1) { + if (value.file_proof_version !== 1 && value.file_proof_version !== 2) { + refuse(); + } + const version = value.file_proof_version; + keys( + value, + version === 1 + ? ["file_proof_version", "sources", "guests"] + : ["file_proof_version", "sources", "guests", "policies"] + ); + const protectedGrants = grants.some((grant) => grant.kind === "secret"); + if ((version === 2) !== protectedGrants) { + refuse(); + } + const policies = + version === 2 ? array(value.policies).map(parsePolicy) : undefined; + if ( + policies && + JSON.stringify(policies) !== + JSON.stringify(legacyNativeRetainedFilePolicies(opts.candidate)) + ) { refuse(); } const sources = array(value.sources).map(parseSource); - const guests = array(value.guests).map(parseGuest); + const guests = array(value.guests).map((entry) => parseGuest(entry, version)); const unique = new Map(grants.map((grant) => [sourceKey(grant), grant])); if ( sources.length !== unique.size || @@ -486,18 +607,7 @@ export function readLegacyComposeRetainedFileProof(opts: { ) { refuse(); } - for (const source of sources) { - const grant = unique.get(sourceKey(source)); - if ( - grant?.file !== source.file || - source.uid !== process.getuid?.() || - (source.kind === "secret" - ? ![0o400, 0o600].includes(source.mode) - : (source.mode & 0o022) !== 0) - ) { - refuse(); - } - } + assertProofSources({ sources, grants: unique }); for (const grant of grants) { const source = sources.find( (entry) => sourceKey(entry) === sourceKey(grant) @@ -511,14 +621,66 @@ export function readLegacyComposeRetainedFileProof(opts: { matches.length !== 1 || guest.container !== matches[0]?.id || guest.size !== source.size || - guest.digest !== source.digest + guest.digest !== source.digest || + guest.mode !== effectiveMode({ candidate: opts.candidate, grant, source }) ) { refuse(); } } sources.sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))); guests.sort((a, b) => guestKey(a).localeCompare(guestKey(b))); - const result = { file_proof_version: 1 as const, sources, guests }; + const result: LegacyComposeRetainedFileProof = + version === 1 + ? { file_proof_version: 1, sources, guests } + : { + file_proof_version: 2, + sources, + guests, + policies: policies ?? refuse(), + }; + freezeImportValue(result); + return result; +} +/** Normalize only an issued raw candidate through its closed original permission proof. No material lookup or owner remapping. */ +export function normalizeLegacyComposeRetainedFileCandidate(opts: { + readonly candidate: unknown; + readonly proof: unknown; + readonly containers: readonly { + readonly id: string; + readonly service: string; + }[]; +}): Readonly> { + const proof = readLegacyComposeRetainedFileProof(opts); + const candidate = record(opts.candidate); + if (proof.file_proof_version === 1) { + return candidate; + } + if (!legacyNativeRetainedFilePolicies(candidate)) { + refuse(); + } + const services = Object.fromEntries( + Object.entries(record(candidate.services)).map(([service, raw]) => { + const workload = record(raw); + if (!Object.hasOwn(workload, "mounts")) { + return [service, workload]; + } + const mounts = array(workload.mounts).map((rawMount) => { + const mount = record(rawMount); + if (!Object.hasOwn(mount, "secret")) { + return mount; + } + const guest = proof.guests.find( + (entry) => entry.service === service && entry.target === mount.target + ); + if (!guest) { + refuse(); + } + return { ...mount, mode: guest.mode }; + }); + return [service, { ...workload, mounts }]; + }) + ); + const result = { ...candidate, services }; freezeImportValue(result); return result; } @@ -561,7 +723,7 @@ export async function observeLegacyComposeRetainedFileSources(opts: { try { await held.assertFresh(); const info = held.info; - facts.push({ + const fact: SourceFact = { kind: grant.kind, name: grant.name, file: grant.file, @@ -574,7 +736,17 @@ export async function observeLegacyComposeRetainedFileSources(opts: { mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, digest: held.anchor.digest, - }); + }; + for (const request of grants.filter( + (entry) => sourceKey(entry) === sourceKey(grant) + )) { + effectiveMode({ + candidate: opts.candidate, + grant: request, + source: fact, + }); + } + facts.push(fact); remaining -= info.size; await recheckDirectories(directories); await held.assertFresh(); @@ -593,13 +765,17 @@ export async function observeLegacyComposeRetainedFileSources(opts: { } } -function parseGuestStat(raw: string, sourceSize: number) { +function parseGuestStat( + raw: string, + sourceSize: number, + requiredMode: NativeComposeFileMode +) { const parts = raw.replace(TRAILING_NEWLINE, "").split(":"); if ( parts.length !== 7 || parts.slice(0, 5).some((part) => !DECIMAL.test(part)) || !HEX.test(parts[5] ?? "") || - parts[6] !== "444" + parts[6] !== requiredMode.slice(1) ) { refuse(); } @@ -613,7 +789,9 @@ function parseGuestStat(raw: string, sourceSize: number) { identityNumber(size) ) || size !== sourceSize || - (Number.parseInt(parts[5] ?? "", 16) & 0xf0_00) !== 0x80_00 + (Number.parseInt(parts[5] ?? "", 16) & 0xf0_00) !== 0x80_00 || + (Number.parseInt(parts[5] ?? "", 16) & 0o777) !== + nativeComposeFileModeBits(requiredMode) ) { refuse(); } @@ -624,6 +802,7 @@ async function observeRunningGuest(opts: { readonly container: { readonly id: string }; readonly source: SourceFact; readonly probe: (args: readonly string[]) => Promise; + readonly mode: NativeComposeFileMode; }): Promise { const { grant, container, source, probe } = opts; const raw = await probe([ @@ -635,7 +814,11 @@ async function observeRunningGuest(opts: { "--", grant.target, ]); - const { dev, ino, uid, gid, size } = parseGuestStat(raw, source.size); + const { dev, ino, uid, gid, size } = parseGuestStat( + raw, + source.size, + opts.mode + ); const digest = await probe([ "exec", container.id, @@ -666,7 +849,7 @@ async function observeRunningGuest(opts: { ino, uid, gid, - mode: "0444", + mode: opts.mode, size, digest: source.digest, }; @@ -726,7 +909,13 @@ export async function observeLegacyComposeRetainedFileGuests(opts: { continue; } guests.push( - await observeRunningGuest({ grant, container, source, probe: opts.probe }) + await observeRunningGuest({ + grant, + container, + source, + probe: opts.probe, + mode: effectiveMode({ candidate: opts.candidate, grant, source }), + }) ); } guests.sort((a, b) => guestKey(a).localeCompare(guestKey(b))); @@ -776,7 +965,17 @@ export async function observeLegacyComposeRetainedFileProof(opts: { refuse(); } return readLegacyComposeRetainedFileProof({ - proof: { file_proof_version: 1, sources, guests }, + proof: legacyComposeRetainedFileGrants(opts.candidate).some( + (grant) => grant.kind === "secret" + ) + ? { + file_proof_version: 2, + sources, + guests, + policies: + legacyNativeRetainedFilePolicies(opts.candidate) ?? refuse(), + } + : { file_proof_version: 1, sources, guests }, candidate: opts.candidate, containers: opts.containers, }); diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index cc8504c11..482d2af9c 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -17,6 +17,7 @@ import { } from "./native-compose-adoption-contract.ts"; import { type LegacyComposeRetainedFileProof, + normalizeLegacyComposeRetainedFileCandidate, observeLegacyComposeRetainedFileProof, readLegacyComposeRetainedFileProof, } from "./native-compose-adoption-files.ts"; @@ -413,14 +414,13 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; -function savedRetainedFileProof( - meta: SavedManifest, - candidate: unknown -): LegacyComposeRetainedFileProof { +function savedRetainedFileContainers( + meta: SavedManifest +): readonly { readonly id: string; readonly service: string }[] { if (!(isRecord(meta.binding) && Array.isArray(meta.binding.containers))) { refuse(); } - const containers = meta.binding.containers.map((value: unknown) => { + return meta.binding.containers.map((value: unknown) => { if ( !( isRecord(value) && @@ -434,10 +434,15 @@ function savedRetainedFileProof( } return { id: value.id, service: value.service }; }); +} +function savedRetainedFileProof( + meta: SavedManifest, + candidate: unknown +): LegacyComposeRetainedFileProof { return readLegacyComposeRetainedFileProof({ proof: meta.fileProof, candidate, - containers, + containers: savedRetainedFileContainers(meta), }); } async function verifyRetainedFileMaterial(opts: { @@ -625,19 +630,22 @@ async function readInputs( const projection = savedProjectionRequired(meta) ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) : undefined; - if ( - !( - mapped.candidate && - planned.intent && - candidateText === - JSON.stringify(projection?.candidate ?? mapped.candidate) - ) - ) { + if (!(mapped.candidate && planned.intent)) { refuse(); } const fileProof = fileFamily ? savedRetainedFileProof(meta, mapped.candidate) : undefined; + const preparedCandidate = fileProof + ? normalizeLegacyComposeRetainedFileCandidate({ + candidate: mapped.candidate, + proof: fileProof, + containers: savedRetainedFileContainers(meta), + }) + : (projection?.candidate ?? mapped.candidate); + if (candidateText !== JSON.stringify(preparedCandidate)) { + refuse(); + } const observed = fileFamily ? await inspectLegacyComposeRetainedFileResources({ root: ctx.root, @@ -844,9 +852,14 @@ async function prepare( if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } - const candidateText = JSON.stringify( - acquired.projection?.candidate ?? mapped.candidate - ); + const preparedCandidate = acquired.fileProof + ? normalizeLegacyComposeRetainedFileCandidate({ + candidate: mapped.candidate, + proof: acquired.fileProof, + containers: acquired.binding.containers, + }) + : (acquired.projection?.candidate ?? mapped.candidate); + const candidateText = JSON.stringify(preparedCandidate); const retainedPlan = legacyComposeRetainedPlan(JSON.parse(candidateText)); if (retainedPlan.requiresV5 && acquired.binding.binding_version >= 3) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); diff --git a/src/lib/native-compose-adoption-preview.ts b/src/lib/native-compose-adoption-preview.ts index a3dfefdff..f070a1883 100644 --- a/src/lib/native-compose-adoption-preview.ts +++ b/src/lib/native-compose-adoption-preview.ts @@ -4,6 +4,7 @@ import { legacyComposeAdoptionCandidateSupported, legacyComposeAdoptionLayoutSupported, } from "./native-compose-adoption-contract.ts"; +import { normalizeLegacyComposeRetainedFileCandidate } from "./native-compose-adoption-files.ts"; import { legacyAdoptionLocalRefusalFields } from "./native-compose-adoption-local.ts"; import { inspectLegacyComposeContainerStates, @@ -49,7 +50,13 @@ export async function previewLegacyComposeAdoption(input: { ...mapped.report.fields, ...(acquired.projection?.localFields ?? []), ]; - const candidate = acquired.projection?.candidate ?? mapped.candidate; + const candidate = acquired.fileProof + ? normalizeLegacyComposeRetainedFileCandidate({ + candidate: mapped.candidate, + proof: acquired.fileProof, + containers: acquired.binding.containers, + }) + : (acquired.projection?.candidate ?? mapped.candidate); if (!candidate) { return report({ complete: false, diff --git a/src/lib/native-config-import-files.ts b/src/lib/native-config-import-files.ts index 012050d6d..f77c9815f 100644 --- a/src/lib/native-config-import-files.ts +++ b/src/lib/native-config-import-files.ts @@ -1,5 +1,9 @@ import { posix } from "node:path"; import { isRecord } from "./guards.ts"; +import { + type NativeComposeFileMode, + nativeComposeFileMode, +} from "./native-compose-file-permissions.ts"; import { literalComposeArg } from "./native-config-import-argv.ts"; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -11,22 +15,31 @@ export type ImportedFileGrant = readonly config: string; readonly target: string; readonly access: "read-only"; - readonly mode: "0444"; + readonly mode: NativeComposeFileMode; } | { readonly secret: string; readonly target: string; readonly access: "read-only"; - readonly mode: "0444"; + readonly mode: NativeComposeFileMode; }; export type ImportedFileGrantMapping = { readonly grant: ImportedFileGrant; + /** Only retained-purpose mappings preserve omission as distinct private intent. */ + readonly declaredMode?: NativeComposeFileMode | null; readonly fields: readonly { readonly source: string; readonly target: string; readonly code: string; }[]; }; +export type RetainedFilePermissionPolicy = { + readonly service: string; + readonly kind: ImportedFileKind; + readonly name: string; + readonly target: string; + readonly declaredMode: NativeComposeFileMode | null; +}; /** Read only own enumerable data fields; accessor/prototype authority is never acquired. */ function dataRecord( @@ -124,33 +137,80 @@ function target(kind: ImportedFileKind, raw: unknown): string | undefined { : undefined; } -function supportedMode(source: Record): boolean { - return ( - !Object.hasOwn(source, "mode") || - source.mode === "0444" || - source.mode === 0o444 - ); +function declaredMode( + source: Record +): NativeComposeFileMode | null | undefined { + if (!Object.hasOwn(source, "mode")) { + return null; + } + const raw = source.mode; + return typeof raw === "number" + ? new Map([ + [0o444, "0444"], + [0o400, "0400"], + [0o600, "0600"], + ]).get(raw) + : nativeComposeFileMode(raw); +} + +function grantFields(opts: { + readonly source: Record; + readonly kind: ImportedFileKind; + readonly destination: string; + readonly mode: NativeComposeFileMode; + readonly shorthand: boolean; +}) { + const fields = [ + { source: "", target: "", code: "compose_linux_file_grant_policy" }, + ]; + if (opts.shorthand) { + return fields; + } + fields.push({ source: "source", target: opts.kind, code: "exact" }); + if (Object.hasOwn(opts.source, "target")) { + fields.push({ + source: "target", + target: "target", + code: + opts.source.target === opts.destination + ? "exact" + : "compose_file_target_normalized", + }); + } + if (Object.hasOwn(opts.source, "mode")) { + fields.push({ + source: "mode", + target: "mode", + code: + opts.source.mode === opts.mode ? "exact" : "compose_file_mode_octal", + }); + } + return fields; } /** Explicit Linux grants only; defaults normalize declaration intent, not source-file mode or bind fidelity. */ export function mapLegacyComposeFileGrant(opts: { readonly kind: ImportedFileKind; readonly value: unknown; + readonly retainedPermissions?: boolean; }): ImportedFileGrantMapping | undefined { const shorthand = typeof opts.value === "string"; const source: Record | undefined = shorthand ? { source: opts.value } : dataRecord(opts.value, ["source", "target", "mode"]); + if (!(source && Object.hasOwn(source, "source") && name(source.source))) { + return undefined; + } + const authoredMode = declaredMode(source); if ( - !( - source && - Object.hasOwn(source, "source") && - name(source.source) && - supportedMode(source) - ) + authoredMode === undefined || + (authoredMode !== null && + authoredMode !== "0444" && + !(opts.retainedPermissions === true && opts.kind === "secret")) ) { return undefined; } + const mode = authoredMode ?? "0444"; const defaultTarget = opts.kind === "config" ? `/${source.source}` @@ -167,36 +227,26 @@ export function mapLegacyComposeFileGrant(opts: { config: source.source, target: destination, access: "read-only", - mode: "0444", + mode, } : { secret: source.source, target: destination, access: "read-only", - mode: "0444", + mode, }; - const fields = [ - { source: "", target: "", code: "compose_linux_file_grant_policy" }, - ]; - if (!shorthand) { - fields.push({ source: "source", target: opts.kind, code: "exact" }); - if (Object.hasOwn(source, "target")) { - fields.push({ - source: "target", - target: "target", - code: - source.target === destination - ? "exact" - : "compose_file_target_normalized", - }); - } - if (Object.hasOwn(source, "mode")) { - fields.push({ - source: "mode", - target: "mode", - code: source.mode === "0444" ? "exact" : "compose_file_mode_octal", - }); - } - } - return { grant, fields }; + const fields = grantFields({ + source, + kind: opts.kind, + destination, + mode, + shorthand, + }); + return { + grant, + fields, + ...(opts.retainedPermissions === true + ? { declaredMode: authoredMode } + : {}), + }; } diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 77dbbaeb5..aa469f4f6 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -4,6 +4,7 @@ import { mapLegacyComposeBuild } from "./native-config-import-build.ts"; import { mapLegacyComposeFileDeclaration, mapLegacyComposeFileGrant, + type RetainedFilePermissionPolicy, } from "./native-config-import-files.ts"; import { legacyComposeJobNames, @@ -40,6 +41,17 @@ export type NativeImportPlan = { /** Private authored static candidate, never a CLI report or a partially converted input. */ readonly candidate?: Readonly>; }; +const retainedFilePolicies = new WeakMap< + Readonly>, + readonly RetainedFilePermissionPolicy[] +>(); + +/** Private raw grant intent issued only by the strict retained-purpose source mapper. No runtime authority. */ +export function legacyNativeRetainedFilePolicies( + candidate: unknown +): readonly RetainedFilePermissionPolicy[] | undefined { + return isRecord(candidate) ? retainedFilePolicies.get(candidate) : undefined; +} export function freezeImportValue(value: unknown): void { if (isRecord(value) || Array.isArray(value)) { @@ -223,7 +235,24 @@ function mapLegacyNativeInput(opts: { opts.purpose === "retained-file-baseline" || opts.purpose === "retained-file-storage" ) { - mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); + const retainedPermissions = opts.purpose !== "preview"; + const policies: RetainedFilePermissionPolicy[] = []; + mapFileCandidate({ + compose: compose.value, + candidate, + mark, + refuse, + retainedPermissions, + policies, + }); + if (retainedPermissions) { + policies.sort( + (a, b) => + a.service.localeCompare(b.service) || a.target.localeCompare(b.target) + ); + freezeImportValue(policies); + retainedFilePolicies.set(candidate, policies); + } } return nativeImportResult({ fields, candidate }); } @@ -374,7 +403,13 @@ export function mapLegacyNativeRetainedFileStorage(opts: { }); } -type FileMappingContext = Pick; +type FileMappingContext = Pick< + MappingContext, + "candidate" | "mark" | "refuse" +> & { + readonly retainedPermissions: boolean; + readonly policies: RetainedFilePermissionPolicy[]; +}; function mapFileDeclarations( opts: FileMappingContext & { @@ -424,6 +459,7 @@ function mapFileGrantEntries( readonly pointer: string; readonly targetPointer: string; readonly mounts: unknown[]; + readonly serviceName: string; } ): void { if (!Array.isArray(opts.grants)) { @@ -438,13 +474,27 @@ function mapFileGrantEntries( ); for (const [index, raw] of opts.grants.entries()) { const entryPointer = importPointer(opts.pointer, index); - const mapped = mapLegacyComposeFileGrant({ kind: opts.kind, value: raw }); + const mapped = mapLegacyComposeFileGrant({ + kind: opts.kind, + value: raw, + retainedPermissions: opts.retainedPermissions, + }); if (!mapped) { opts.refuse("compose", entryPointer, "invalid_or_unsupported_file_grant"); continue; } const target = `${opts.targetPointer}/mounts/${opts.mounts.length}`; opts.mounts.push(mapped.grant); + if (opts.retainedPermissions && mapped.declaredMode !== undefined) { + opts.policies.push({ + service: opts.serviceName, + kind: opts.kind, + name: + "config" in mapped.grant ? mapped.grant.config : mapped.grant.secret, + target: mapped.grant.target, + declaredMode: mapped.declaredMode, + }); + } for (const field of mapped.fields) { opts.mark( "compose", @@ -486,6 +536,7 @@ function mapServiceFileGrants( pointer: importPointer(servicePointer, namespace), targetPointer, mounts, + serviceName: opts.name, }); } } diff --git a/tests/native-compose-adoption-file-generation.test.ts b/tests/native-compose-adoption-file-generation.test.ts index 1a01ea8c3..d077461bb 100644 --- a/tests/native-compose-adoption-file-generation.test.ts +++ b/tests/native-compose-adoption-file-generation.test.ts @@ -7,6 +7,7 @@ import { readFile, realpath, rm, + stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -36,6 +37,7 @@ let fixture: { guestDelayMs?: number; stateReads?: number; flipAfterState?: number; + guestMode?: "400" | "600"; }; beforeEach(async () => { priorPath = process.env.PATH; @@ -79,7 +81,7 @@ const [kind,action]=args; if(kind==='exec') { if(args[1]!==id || args.at(-1)!=='/settings') process.exit(91); if(value.guestDelayMs) {appendFileSync(root+'/guest-pids',String(process.pid)+'\\n');await Bun.sleep(value.guestDelayMs);} - if(args[2]==='stat' && args.length===7 && args[3]==='-c' && args[4]==='%d:%i:%u:%g:%s:%f:%a' && args[5]==='--') {console.log('7:'+value.guestIno+':'+value.uid+':321:'+value.size+':'+(0o100444).toString(16)+':444');process.exit(0);} + if(args[2]==='stat' && args.length===7 && args[3]==='-c' && args[4]==='%d:%i:%u:%g:%s:%f:%a' && args[5]==='--') {const mode=value.guestMode??'444';console.log('7:'+value.guestIno+':'+value.uid+':321:'+value.size+':'+(0o100000|Number.parseInt(mode,8)).toString(16)+':'+mode);process.exit(0);} if(args[2]==='sha256sum' && args.length===5 && args[3]==='--') {console.log(value.digest+' /settings');process.exit(0);} process.exit(92); } @@ -248,10 +250,16 @@ test("file8 earns explicit preparation, stopped publication, retained lifecycle } }, 30_000); -test("ordinary binding and unsupported secret grants refuse without acquiring material or engine", async () => { +test("ordinary binding and unprotected original secret refuse without engine acquisition", async () => { await expect( acquireLegacyComposeAdoptionBinding({ projectRoot }) ).rejects.toThrow(); + expect(await commands().catch(() => [])).toEqual([]); + // File creation respects the caller's umask; establish the intended unsafe + // secret permission on this disposable test source before admission. + const material = join(projectRoot, "material/config"); + await chmod(material, 0o444); + expect((await stat(material)).mode & 0o777).toBe(0o444); const source = await readFile( join(projectRoot, ".hack/docker-compose.yml"), "utf8" @@ -268,6 +276,109 @@ test("ordinary binding and unsupported secret grants refuse without acquiring ma await store.close(); } }); +test.each([ + "400", + "600", +] as const)("prepared file8 normalizes original protected secret %s with saved stop/recovery and rollback", async (mode) => { + const material = join(projectRoot, "material/config"); + await chmod(material, Number.parseInt(mode, 8)); + const before = await stat(material); + const authored = await readFile( + join(projectRoot, ".hack/docker-compose.yml"), + "utf8" + ); + await writeFile( + join(projectRoot, ".hack/docker-compose.yml"), + authored + .replaceAll("configs", "secrets") + .replace( + "secrets: [settings]", + "secrets:\n - source: settings\n target: /settings" + ) + ); + fixture.guestMode = mode; + await save(); + const { store, generation } = await prepared(); + try { + expect(generation.report.adoption_generation_version).toBe(8); + await store.withLease({ + generation, + run: async (input) => { + expect( + JSON.parse(input.candidateText).services.app.mounts + ).toContainEqual({ + secret: "settings", + target: "/settings", + access: "read-only", + mode: `0${mode}`, + }); + expect(JSON.stringify(input)).toBe("{}"); + }, + }); + await store.withPreparationStop({ + generation, + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect("stop"); + }, + }); + await store.publish({ generation, binary }); + const active = await store.loadActive(); + if (!active) { + throw new Error("active protected generation missing"); + } + for (const operation of ["start", "restart", "stop"] as const) { + expect( + await store.withMutation({ + generation: active, + operation, + services: [], + binary, + deadline: Date.now() + 15_000, + run: async (input) => { + await input.assertFresh(); + return await effect(operation); + }, + }) + ).toBe(0); + } + const after = await stat(material); + expect([after.dev, after.ino, after.uid, after.gid, after.mode]).toEqual([ + before.dev, + before.ino, + before.uid, + before.gid, + before.mode, + ]); + expect(await readFile(material, "utf8")).toBe(CANARY); + await rm(material); + expect( + await store.withLease({ + generation: active, + material: "saved", + run: async () => "saved", + }) + ).toBe("saved"); + await expect( + store.withLease({ generation: active, run: async () => "exec" }) + ).rejects.toThrow(); + await store.withMutation({ + generation: active, + operation: "stop", + services: [], + binary, + deadline: Date.now() + 15_000, + run: async () => await effect("stop"), + }); + await store.rollback(); + expect((await state()).publication.phase).toBe("rolled-back"); + expect((await state()).pendingOperation).toBeNull(); + } finally { + await store.close(); + } +}, 30_000); test.each([ "foreign-source", "writable", diff --git a/tests/native-compose-adoption-secret-permissions.test.ts b/tests/native-compose-adoption-secret-permissions.test.ts new file mode 100644 index 000000000..fa5362bcd --- /dev/null +++ b/tests/native-compose-adoption-secret-permissions.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LegacyComposeRetainedFileError, + type LegacyComposeRetainedFileProof, + legacyComposeRetainedFileGrants, + normalizeLegacyComposeRetainedFileCandidate, + observeLegacyComposeRetainedFileProof, + observeLegacyComposeRetainedFileSources, + readLegacyComposeRetainedFileProof, +} from "../src/lib/native-compose-adoption-files.ts"; +import { + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeImport, + mapLegacyNativeRetainedFileStorage, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; + +const ID = "a".repeat(64); +const CANARY = "synthetic-retained-protected-material\0binary"; +let root: string; +beforeEach(async () => { + root = await realpath( + await mkdtemp(join(tmpdir(), "retained-permission-proof-")) + ); + await mkdir(join(root, "material")); + await writeFile(join(root, "material/config"), "public-fixture", { + mode: 0o444, + }); + await writeFile(join(root, "material/secret"), CANARY, { mode: 0o600 }); +}); +afterEach(async () => await rm(root, { recursive: true, force: true })); + +function source(mode?: unknown) { + return { + configText: '{"name":"fixture"}', + composeText: JSON.stringify({ + name: "fixture", + services: { + app: { + image: "example:pinned", + configs: ["settings"], + secrets: [ + { + source: "token", + target: "/run/token", + ...(mode === undefined ? {} : { mode }), + }, + ], + volumes: ["data:/data"], + }, + }, + configs: { settings: { file: "../material/config" } }, + secrets: { token: { file: "../material/secret" } }, + volumes: { data: { name: "fixture_data" } }, + }), + }; +} +function candidate(mode?: unknown) { + const mapped = mapLegacyNativeRetainedFileStorage(source(mode)); + if (!mapped.candidate) { + throw new Error("test candidate not issued"); + } + return mapped.candidate; +} +function containers(running = true) { + return [{ id: ID, service: "app", running }]; +} +async function observed(opts: { + readonly candidate: unknown; + readonly mode: "0400" | "0600"; + readonly uid?: number; + readonly gid?: number; + readonly saved?: LegacyComposeRetainedFileProof; + readonly running?: boolean; + readonly wrongMode?: string; +}) { + const sources = await observeLegacyComposeRetainedFileSources({ + projectRoot: root, + candidate: opts.candidate, + }); + const calls: string[][] = []; + const proof = await observeLegacyComposeRetainedFileProof({ + projectRoot: root, + candidate: opts.candidate, + containers: containers(opts.running), + ...(opts.saved === undefined ? {} : { saved: opts.saved }), + probe: async (args) => { + calls.push([...args]); + expect(args[0]).toBe("exec"); + expect(args[1]).toBe(ID); + const target = args.at(-1); + const material = sources.find( + (entry) => + entry.kind === (target === "/run/token" ? "secret" : "config") + ); + if (!material) { + throw new Error("unselected target"); + } + const mode = + target === "/run/token" + ? (opts.wrongMode ?? opts.mode.slice(1)) + : "444"; + if (args[2] === "stat") { + return `7:31:${opts.uid ?? 123}:${opts.gid ?? 321}:${material.size}:${(0o10_0000 | Number.parseInt(mode, 8)).toString(16)}:${mode}\n`; + } + expect(args[2]).toBe("sha256sum"); + return `${material.digest} ${target}\n`; + }, + }); + return { proof, calls }; +} + +test.each([ + "0400", + "0600", +] as const)("omitted original secret mode earns exact %s only through private proof2", async (mode) => { + const path = join(root, "material/secret"); + await chmod(path, Number.parseInt(mode, 8)); + const before = await stat(path); + const input = candidate(); + const { proof, calls } = await observed({ candidate: input, mode }); + expect(proof.file_proof_version).toBe(2); + if (proof.file_proof_version !== 2) { + throw new Error("proof version missing"); + } + expect( + proof.policies.find((entry) => entry.kind === "secret")?.declaredMode + ).toBeNull(); + expect( + proof.guests.find((entry) => entry.target === "/run/token") + ).toMatchObject({ mode, uid: 123, gid: 321 }); + const normalized = normalizeLegacyComposeRetainedFileCandidate({ + candidate: input, + proof, + containers: containers(), + }); + expect(normalized.services).toMatchObject({ + app: { + mounts: expect.arrayContaining([ + { secret: "token", target: "/run/token", access: "read-only", mode }, + ]), + }, + }); + expect(input.services).toMatchObject({ + app: { + mounts: expect.arrayContaining([ + { + secret: "token", + target: "/run/token", + access: "read-only", + mode: "0444", + }, + ]), + }, + }); + const after = await stat(path); + expect([after.dev, after.ino, after.uid, after.gid, after.mode]).toEqual([ + before.dev, + before.ino, + before.uid, + before.gid, + before.mode, + ]); + expect(await readFile(path, "utf8")).toBe(CANARY); + expect(calls).toHaveLength(6); + expect( + JSON.stringify(mapLegacyNativeRetainedFileStorage(source()).report) + ).not.toContain(CANARY); + expect(JSON.stringify(proof)).not.toContain(CANARY); +}); + +test("explicit permission must agree with both original source and guest without overriding source mode", async () => { + const input = candidate("0600"); + const { proof } = await observed({ candidate: input, mode: "0600" }); + expect(proof.file_proof_version).toBe(2); + await expect( + observed({ candidate: input, mode: "0600", wrongMode: "400" }) + ).rejects.toThrow(LegacyComposeRetainedFileError); + await expect( + observed({ candidate: candidate("0400"), mode: "0400" }) + ).rejects.toThrow(LegacyComposeRetainedFileError); + await expect( + observeLegacyComposeRetainedFileSources({ + projectRoot: root, + candidate: candidate("0444"), + }) + ).rejects.toThrow(LegacyComposeRetainedFileError); + expect((await stat(join(root, "material/secret"))).mode & 0o777).toBe(0o600); +}); + +test("proof2 cannot swap omission with explicit mode, invent guest owner, downgrade or normalize an unissued candidate", async () => { + const input = candidate(); + const { proof } = await observed({ candidate: input, mode: "0600" }); + expect(() => + readLegacyComposeRetainedFileProof({ + proof, + candidate: candidate("0600"), + containers: containers(), + }) + ).toThrow(LegacyComposeRetainedFileError); + for (const forged of [ + { ...proof, file_proof_version: 1 }, + { ...proof, file_proof_version: 3 }, + { + ...proof, + guests: proof.guests.map((entry) => ({ ...entry, mode: "0444" })), + }, + { ...proof, policies: [] }, + { ...proof, extra: true }, + ]) { + expect(() => + readLegacyComposeRetainedFileProof({ + proof: forged, + candidate: input, + containers: containers(), + }) + ).toThrow(LegacyComposeRetainedFileError); + } + expect(() => + normalizeLegacyComposeRetainedFileCandidate({ + candidate: JSON.parse(JSON.stringify(input)), + proof, + containers: containers(), + }) + ).toThrow(LegacyComposeRetainedFileError); + await expect( + observed({ candidate: input, mode: "0600", uid: 0, saved: proof }) + ).rejects.toThrow(LegacyComposeRetainedFileError); + await expect( + observed({ candidate: input, mode: "0600", gid: 0, saved: proof }) + ).rejects.toThrow(LegacyComposeRetainedFileError); +}); + +test("stopped originals require an exact prior proof; saved mode and owner remain checked on next running observation", async () => { + const input = candidate(); + const { proof } = await observed({ candidate: input, mode: "0600" }); + const stopped = await observed({ + candidate: input, + mode: "0600", + saved: proof, + running: false, + }); + expect(stopped.calls).toEqual([]); + expect(stopped.proof).toEqual(proof); + await expect( + observed({ candidate: input, mode: "0600", running: false }) + ).rejects.toThrow(LegacyComposeRetainedFileError); + await chmod(join(root, "material/secret"), 0o400); + await expect( + observed({ candidate: input, mode: "0400", saved: proof }) + ).rejects.toThrow(LegacyComposeRetainedFileError); +}); +test("proof2 rejects accessor policy fields and array elements without reading them", async () => { + const input = candidate(); + const { proof } = await observed({ candidate: input, mode: "0600" }); + if (proof.file_proof_version !== 2) { + throw new Error("protected proof missing"); + } + let reads = 0; + const getterPolicy = { ...proof.policies[0] }; + Object.defineProperty(getterPolicy, "declaredMode", { + enumerable: true, + get() { + reads++; + return null; + }, + }); + const getterArray = [...proof.policies]; + Object.defineProperty(getterArray, "0", { + enumerable: true, + get() { + reads++; + return proof.policies[0]; + }, + }); + for (const policies of [ + [getterPolicy, ...proof.policies.slice(1)], + getterArray, + ]) { + expect(() => + readLegacyComposeRetainedFileProof({ + proof: { ...proof, policies }, + candidate: input, + containers: containers(), + }) + ).toThrow(LegacyComposeRetainedFileError); + } + expect(reads).toBe(0); +}); + +test("preview defaults and ordinary adoption purposes acquire no retained secret permission authority", () => { + expect(mapLegacyNativeImport(source("0600")).candidate).toBeUndefined(); + for (const mapper of [ + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeStorageAdoption, + ]) { + expect(mapper(source()).candidate).toBeUndefined(); + expect(mapper(source("0600")).candidate).toBeUndefined(); + } + const pure = mapLegacyNativeImport({ + ...source(), + composeText: source() + .composeText.replace(',"volumes":["data:/data"]', "") + .replace(',"volumes":{"data":{"name":"fixture_data"}}', ""), + }); + expect(pure.report.complete).toBe(true); + expect(pure.candidate?.services).toMatchObject({ + app: { + mounts: expect.arrayContaining([ + { + secret: "token", + target: "/run/token", + access: "read-only", + mode: "0444", + }, + ]), + }, + }); + expect(() => + legacyComposeRetainedFileGrants({ ...candidate(), jobs: {} }) + ).toThrow(LegacyComposeRetainedFileError); +}); From dd4ec3b60da47c5c5146aef935894d0f4e16eb77 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 18:53:28 -0400 Subject: [PATCH 07/23] test(native): add protected file lifecycle acceptance --- tests/e2e/README.md | 35 + tests/e2e/native-file-permission-command.ts | 225 ++ tests/e2e/native-file-permission-control.ts | 96 + tests/e2e/native-file-permission-guest.ts | 160 ++ tests/e2e/run.ts | 2 + .../native-config-protected-files.ts | 1997 +++++++++++++++++ ...ive-config-protected-files-fixture.test.ts | 448 ++++ 7 files changed, 2963 insertions(+) create mode 100644 tests/e2e/native-file-permission-command.ts create mode 100644 tests/e2e/native-file-permission-control.ts create mode 100644 tests/e2e/native-file-permission-guest.ts create mode 100644 tests/e2e/scenarios/native-config-protected-files.ts create mode 100644 tests/native-config-protected-files-fixture.test.ts diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 7d6944079..92b303ffc 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -128,6 +128,41 @@ HACK_E2E_KEEP=1 HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_ These maintained scenarios are prepared source, not evidence of a successful live run. Source/whole/CI and compiled synthetic engine acceptance remain separate. +`native-config-protected-files` is a separate explicit selector for synthetic +0444 configs and protected 0400/0600 file secrets. It requires kept fixture roots, +the exact current compiled CLI/compiler and source hashes, an explicitly pinned +Compose plugin, cached Bun/PostgreSQL images and an inherited exact-source +`HACK_E2E_PROTECTED_FILES_SLOT_RELEASED` grant. A private launcher must supply those +pins after source and artifact qualification; the selector does not manufacture +the grant or use caller Docker credentials. It publishes no ports and performs no +pulls, builds, DNS changes or trust changes. + +The ordinary native fixture checks snapshot version 2. Two real linked retained +checkouts then bind their original readonly sources, observed guest UID/GID and +0400/0600 mode, original container/bridge/volume identities and distinct SQL +markers. Guest checks compare exact synthetic bytes through private stdin, prove +access by the observed owner, EACCES for a different non-root UID, ENOENT in an +ungranted workload and EROFS for an explicitly privileged write. No original file +is chmodded; only newly created synthetic files receive their initial modes. + +A closed Docker forwarder refuses the exact whole-ID start only after the real +pending receipt is durable. Source material is then withheld while shipping saved +down recovery and rollback settle the original resources. This finite interrupted +operation control does not claim guest cancellation after arbitrary parent death. +An earlier active-source control withholds only the synthetic material directory: +restart and exec must refuse with the exact state diagnostic before effects or +journal changes, then restored source incarnation must permit normal exec. +Each SQL marker is seeded once, reads survive restart, and stopping/rolling back +one checkout preserves its sibling. Saved stop/rollback precedes exact non-force +removal: every original ID, creation birth, label, mount, policy and endpoint is +rechecked; volumes also require zero consumers. Final inventory compares the +original daemon, stopped containers, networks, volume births, image IDs and tags. +Unknown effects or cleanup failures retain private captures and fail the selector. + +This selector is maintained acceptance source, with live guest/adoption evidence +still required. It does not replace the existing version-1/0444 managed-file +fixtures or admit file/build/job/health/custom-network intersections. + ## Native config process-policy qualification `native-config-process-policy` is registered in required Docker CI. It needs the diff --git a/tests/e2e/native-file-permission-command.ts b/tests/e2e/native-file-permission-command.ts new file mode 100644 index 000000000..443d53bb4 --- /dev/null +++ b/tests/e2e/native-file-permission-command.ts @@ -0,0 +1,225 @@ +import { lstat, realpath, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; + +const LIMIT = 256 * 1024; +function refuse(): never { + throw new Error( + "Owned file fixture command did not settle within its contract; values omitted." + ); +} +function capture(stream: ReadableStream, stop: () => void) { + const reader = stream.getReader(); + const value = (async () => { + const parts: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + size += next.value.length; + if (size > LIMIT) { + refuse(); + } + parts.push(next.value); + } + return Buffer.concat(parts); + } catch { + stop(); + refuse(); + } finally { + reader.releaseLock(); + } + })(); + return { + value, + cancel: async () => { + try { + await reader.cancel(); + } catch { + /* Settled readers have released their lock. */ + } + }, + }; +} +export type NativeFileFixtureCommandResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; +type NativeFileFixtureCommandOptions = { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + readonly stdin?: Uint8Array; + readonly capturePrefix?: string; +}; +function captureCommandInputs(opts: NativeFileFixtureCommandOptions) { + // Freeze selection before the first await. In particular, the validated + // capture parent must never authorize a later caller-supplied output path. + const privateInput = opts.stdin; + const captured = { + argv: [...opts.argv], + cwd: opts.cwd, + env: { ...opts.env }, + timeoutMs: opts.timeoutMs, + signal: opts.signal, + stdin: privateInput === undefined ? undefined : Buffer.from(privateInput), + capturePrefix: opts.capturePrefix, + }; + const binary = captured.argv[0]; + if ( + !( + binary && + isAbsolute(binary) && + isAbsolute(captured.cwd) && + Number.isSafeInteger(captured.timeoutMs) && + captured.timeoutMs > 0 && + captured.timeoutMs <= 90_000 && + !captured.signal?.aborted && + (captured.stdin === undefined || captured.stdin.byteLength <= 8192) + ) + ) { + refuse(); + } + return captured; +} +/** + * Uses the compiler/config-only owner pattern: leader AND both pipes must settle + * before group authority disarms. Private stdin is bounded and never captured. + * Failure/cancellation kills once, cancels inherited pipes and reaps the leader; + * publication errors after settlement never signal a former process group. + */ +export async function runNativeFileFixtureCommand( + opts: NativeFileFixtureCommandOptions +): Promise { + const captured = captureCommandInputs(opts); + const deadline = Date.now() + captured.timeoutMs; + const captureDirectory = + captured.capturePrefix === undefined + ? undefined + : dirname(captured.capturePrefix); + let captureIdentity: Awaited> | undefined; + if (captureDirectory !== undefined) { + if ( + !(captured.capturePrefix && isAbsolute(captured.capturePrefix)) || + resolve(captured.capturePrefix) !== captured.capturePrefix || + !/^[a-z0-9-]+$/.test(basename(captured.capturePrefix)) || + (await realpath(captureDirectory)) !== captureDirectory + ) { + refuse(); + } + captureIdentity = await lstat(captureDirectory); + if ( + !captureIdentity.isDirectory() || + captureIdentity.isSymbolicLink() || + captureIdentity.uid !== process.getuid?.() || + (captureIdentity.mode & 0o777) !== 0o700 + ) { + refuse(); + } + } + const checkCapture = async () => { + if (captureDirectory === undefined || captureIdentity === undefined) { + return; + } + const current = await lstat(captureDirectory); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== captureIdentity.dev || + current.ino !== captureIdentity.ino || + current.uid !== captureIdentity.uid || + current.mode !== captureIdentity.mode || + (await realpath(captureDirectory)) !== captureDirectory + ) { + refuse(); + } + }; + if (captured.signal?.aborted || Date.now() >= deadline) { + refuse(); + } + const child = Bun.spawn(captured.argv, { + cwd: captured.cwd, + env: captured.env, + stdin: captured.stdin === undefined ? "ignore" : new Blob([captured.stdin]), + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + let complete = false, + stopped = false; + let stdout: ReturnType | undefined, + stderr: 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 { + /* Failure cannot pass; leader is still awaited. */ + } + } + } + } + void stdout?.cancel(); + void stderr?.cancel(); + }; + const timer = setTimeout(stop, Math.max(1, deadline - Date.now())); + captured.signal?.addEventListener("abort", stop, { once: true }); + stdout = capture(child.stdout, stop); + stderr = capture(child.stderr, stop); + const pending = [child.exited, stdout.value, stderr.value] as const; + try { + const [exitCode, output, diagnostics] = await Promise.all(pending); + complete = true; + if (captured.capturePrefix !== undefined) { + await checkCapture(); + await writeFile(`${captured.capturePrefix}.stdout`, output, { + mode: 0o600, + flag: "wx", + }); + await writeFile(`${captured.capturePrefix}.stderr`, diagnostics, { + mode: 0o600, + flag: "wx", + }); + await writeFile( + `${captured.capturePrefix}.json`, + JSON.stringify({ + exitCode, + outputBytes: output.length, + diagnosticBytes: diagnostics.length, + settled: true, + stopRequested: stopped, + aborted: captured.signal?.aborted === true, + }), + { mode: 0o600, flag: "wx" } + ); + } + await checkCapture(); + if (stopped || captured.signal?.aborted || Date.now() >= deadline) { + refuse(); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + return { + exitCode, + stdout: decoder.decode(output), + stderr: decoder.decode(diagnostics), + }; + } catch { + stop(); + await Promise.allSettled(pending); + return refuse(); + } finally { + clearTimeout(timer); + captured.signal?.removeEventListener("abort", stop); + } +} diff --git a/tests/e2e/native-file-permission-control.ts b/tests/e2e/native-file-permission-control.ts new file mode 100644 index 000000000..23f5272de --- /dev/null +++ b/tests/e2e/native-file-permission-control.ts @@ -0,0 +1,96 @@ +import { isRecord } from "../../src/lib/guards.ts"; +import { adoptionDependencyReadAllowed } from "./scenarios/native-compose-adoption-dependency-inputs.ts"; + +/** Current material admission has a fixed redacted generation-state diagnostic. */ +export function nativeProtectedFileStateRefused(value: unknown): boolean { + if ( + !(isRecord(value) && Object.hasOwn(value, "ok")) || + value.ok !== false || + !Object.hasOwn(value, "error") || + !isRecord(value.error) + ) { + return false; + } + const error = value.error; + return ( + Object.hasOwn(error, "code") && + error.code === "E_CONFIG_INVALID" && + Object.hasOwn(error, "message") && + error.message === + "Legacy adoption generation state is invalid, unsafe or changed; values omitted." + ); +} + +/** Extend the existing closed metadata/config-hash owner only for fixed file proof queries. */ +export function nativeProtectedFileReadAllowed(opts: { + readonly args: readonly string[]; + readonly projectRoot: string; + readonly project: string; + readonly containerIds: readonly string[]; + readonly networkId: string; + readonly volumeName: string; + readonly generationId: string; + readonly reader: string; + readonly targets: readonly string[]; +}): boolean { + if (adoptionDependencyReadAllowed(opts)) { + return true; + } + const target = opts.args.at(-1); + if ( + typeof target !== "string" || + !opts.targets.includes(target) || + opts.args[0] !== "exec" || + opts.args[1] !== opts.reader || + !opts.containerIds.includes(opts.reader) + ) { + return false; + } + return ( + JSON.stringify(opts.args) === + JSON.stringify([ + "exec", + opts.reader, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + target, + ]) || + JSON.stringify(opts.args) === + JSON.stringify(["exec", opts.reader, "sha256sum", "--", target]) + ); +} +/** A refusal marker proves the original whole-ID start reached its real pending journal; it does no engine effect. */ +export function nativeProtectedFileStartAllowed(opts: { + readonly args: readonly string[]; + readonly ids: readonly string[]; + readonly prepared: unknown; + readonly receipt: unknown; +}): boolean { + const row = opts.receipt, + pending = isRecord(row) ? row.pendingOperation : undefined, + publication = isRecord(row) ? row.publication : undefined; + return ( + opts.ids.length === 3 && + new Set(opts.ids).size === 3 && + opts.ids.every((id) => /^[a-f0-9]{64}$/.test(id)) && + opts.args[0] === "container" && + opts.args[1] === "start" && + opts.args.length === 5 && + new Set(opts.args.slice(2)).size === 3 && + opts.args.slice(2).every((id) => opts.ids.includes(id)) && + isRecord(row) && + row.adoption_receipt_version === 8 && + JSON.stringify(row.prepared) === JSON.stringify(opts.prepared) && + isRecord(publication) && + publication.phase === "active" && + JSON.stringify(publication.generation) === JSON.stringify(opts.prepared) && + isRecord(pending) && + pending.operation === "start" && + JSON.stringify(pending.generation) === JSON.stringify(opts.prepared) && + Array.isArray(pending.services) && + pending.services.length === 3 && + [...pending.services].sort().join() === "db,reader,ungranted" + ); +} diff --git a/tests/e2e/native-file-permission-guest.ts b/tests/e2e/native-file-permission-guest.ts new file mode 100644 index 000000000..003ef6292 --- /dev/null +++ b/tests/e2e/native-file-permission-guest.ts @@ -0,0 +1,160 @@ +import { isRecord } from "../../src/lib/guards.ts"; +import type { NativeComposeFileMode } from "../../src/lib/native-compose-file-permissions.ts"; + +export type NativeFileFixtureGrant = { + readonly target: string; + readonly mode: NativeComposeFileMode; + readonly bytes: readonly number[]; +}; +export type NativeFileFixtureGuest = { + readonly uid: number; + readonly gid: number; + readonly members: readonly { + readonly target: string; + readonly mode: NativeComposeFileMode; + readonly uid: number; + readonly gid: number; + }[]; +}; + +/** Private stdin carries only fixture-authored bytes; stdout exposes permission facts, never content. */ +export const nativeFileFixtureReadProgram = ` +import {lstat,readFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(31); + const rows=JSON.parse(new TextDecoder().decode(raw)); + if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(32); + const members=[]; + for(const row of rows){ + if(typeof row.target!=="string"||!row.target.startsWith("/")||!["0444","0400","0600"].includes(row.mode)||!Array.isArray(row.bytes))process.exit(33); + const before=await lstat(row.target); + const actual=new Uint8Array(await readFile(row.target)); + const after=await lstat(row.target); + if(!before.isFile()||before.isSymbolicLink()||(before.mode&4095)!==Number.parseInt(row.mode,8)||JSON.stringify(Array.from(actual))!==JSON.stringify(row.bytes)||before.dev!==after.dev||before.ino!==after.ino||before.uid!==after.uid||before.gid!==after.gid||before.mode!==after.mode||before.size!==after.size)process.exit(34); + members.push({target:row.target,mode:row.mode,uid:before.uid,gid:before.gid}); + } + process.stdout.write(JSON.stringify({version:1,marker:"granted-files-exact",uid:process.getuid(),gid:process.getgid(),members})); +} catch {process.exit(35)} +`; + +/** An explicitly privileged observer tests the readonly mount, independently of the reader's DAC rights. */ +export const nativeFileFixtureWriteProgram = ` +import {writeFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + if(process.getuid()!==0)process.exit(41); + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(42); + for(const row of rows){try{await writeFile(row.target,"unexpected-fixture-write");process.exit(43)}catch(error){if(error?.code!=="EROFS")process.exit(44)}} + process.stdout.write("exact-readonly-files"); +} catch {process.exit(45)} +`; + +/** The chosen non-root guest UID must differ from every observed protected file owner. */ +export const nativeFileFixtureDeniedProgram = ` +import {lstat,readFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8||process.getuid()===0)process.exit(51); + for(const row of rows){ + const info=await lstat(row.target); + if(!["0400","0600"].includes(row.mode)||(info.mode&4095)!==Number.parseInt(row.mode,8)||info.uid!==row.uid||info.gid!==row.gid||info.uid===process.getuid())process.exit(52); + try{await readFile(row.target);process.exit(53)}catch(error){if(error?.code!=="EACCES")process.exit(54)} + } + process.stdout.write("exact-nonowner-read-refused"); +} catch {process.exit(55)} +`; + +/** Missing targets must be ENOENT, rather than an unreadable or hidden mount. */ +export const nativeFileFixtureAbsentProgram = ` +import {lstat} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(61); + for(const row of rows){try{await lstat(row.target);process.exit(62)}catch(error){if(error?.code!=="ENOENT")process.exit(63)}} + process.stdout.write("exact-ungranted-files-absent"); +} catch {process.exit(64)} +`; + +function keys(value: Record, expected: readonly string[]) { + if (Object.keys(value).sort().join() !== [...expected].sort().join()) { + throw new Error("Fixture guest observation shape refused; values omitted"); + } +} +function identity(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +/** Decode only bounded JSON and bind every mode/target in order. This grants no engine authority. */ +export function readNativeFileFixtureGuest(opts: { + readonly text: string; + readonly expected: readonly NativeFileFixtureGrant[]; +}): NativeFileFixtureGuest { + if (opts.text.length > 4096) { + throw new Error("Fixture guest observation budget refused; values omitted"); + } + const value: unknown = JSON.parse(opts.text); + if (!isRecord(value)) { + throw new Error("Fixture guest observation refused; values omitted"); + } + keys(value, ["version", "marker", "uid", "gid", "members"]); + if ( + !( + value.version === 1 && + value.marker === "granted-files-exact" && + identity(value.uid) && + identity(value.gid) && + Array.isArray(value.members) && + value.members.length === opts.expected.length + ) + ) { + throw new Error("Fixture guest permissions refused; values omitted"); + } + const members = value.members.map((entry: unknown, index: number) => { + if (!isRecord(entry)) { + throw new Error("Fixture guest member refused; values omitted"); + } + keys(entry, ["target", "mode", "uid", "gid"]); + const expected = opts.expected[index]; + if ( + !( + expected && + entry.target === expected.target && + entry.mode === expected.mode && + identity(entry.uid) && + identity(entry.gid) + ) + ) { + throw new Error("Fixture guest member changed; values omitted"); + } + return Object.freeze({ + target: expected.target, + mode: expected.mode, + uid: entry.uid, + gid: entry.gid, + }); + }); + return Object.freeze({ + uid: value.uid, + gid: value.gid, + members: Object.freeze(members), + }); +} + +/** Select a fixed non-root observer UID only after seeing actual file ownership. Never infer an image default. */ +export function nativeFileFixtureNonowner( + guest: NativeFileFixtureGuest +): number { + const protectedMembers = guest.members.filter( + (member) => member.mode !== "0444" + ); + if (protectedMembers.length === 0) { + throw new Error("Fixture has no protected target; values omitted"); + } + for (const uid of [65_534, 65_533]) { + if (protectedMembers.every((member) => member.uid !== uid)) { + return uid; + } + } + throw new Error("Fixture nonowner selection refused; values omitted"); +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 94665bfab..796a4c887 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -31,6 +31,7 @@ import { nativeConfigFileUnknownStopScenario, } from "./scenarios/native-config-files.ts"; import { nativeConfigProcessPolicyScenario } from "./scenarios/native-config-process-policy.ts"; +import { nativeConfigProtectedFilesScenario } from "./scenarios/native-config-protected-files.ts"; import { nativeConfigRoutingScenario } from "./scenarios/native-config-routing.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; import { upDownScenario } from "./scenarios/up-down.ts"; @@ -81,6 +82,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeConfigDownHooksScenario, nativeConfigFilesScenario, nativeConfigFileUnknownStopScenario, + nativeConfigProtectedFilesScenario, nativeConfigProcessPolicyScenario, nativeConfigRoutingScenario, nativeConfigNetworksScenario, diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts new file mode 100644 index 000000000..adb793578 --- /dev/null +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -0,0 +1,1997 @@ +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { + lstat, + mkdir, + open, + readFile, + realpath, + rename, + writeFile, +} from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { parseNativeComposeFileReference } from "../../../src/lib/native-compose-file-state.ts"; +import { openNativeComposeGenerationStore } from "../../../src/lib/native-compose-generation.ts"; +import { + buildCliEnv, + REPO_ROOT, + type Scenario, + type ScenarioContext, +} from "../harness.ts"; +import { + observeNativeComposeFixture, + runWithOwnedCleanup, +} from "../native-compose-owned-fixture.ts"; +import { provisionNativeNetworkFixtureComposePlugin } from "../native-config-networks-acceptance.ts"; +import { + type NativeFileFixtureCommandResult, + runNativeFileFixtureCommand, +} from "../native-file-permission-command.ts"; +import { nativeProtectedFileStateRefused } from "../native-file-permission-control.ts"; +import { + type NativeFileFixtureGrant, + nativeFileFixtureAbsentProgram, + nativeFileFixtureDeniedProgram, + nativeFileFixtureNonowner, + nativeFileFixtureReadProgram, + nativeFileFixtureWriteProgram, + readNativeFileFixtureGuest, +} from "../native-file-permission-guest.ts"; + +const ID = /^[a-f0-9]{64}$/; +const HASH = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const PROJECT = "com.docker.compose.project"; +const BUN_TAG = "oven/bun:1.4.2-slim"; +const DB_TAG = "postgres:17.6-alpine"; +const LOOP = ["bun", "-e", "setInterval(()=>{},1000)"]; +const FORMATS = { + container: + '{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"image":{{json .Image}},"labels":{{json .Config.Labels}},"command":{{json .Config.Cmd}},"entrypoint":{{json .Config.Entrypoint}},"running":{{json .State.Running}},"status":{{json .State.Status}},"paused":{{json .State.Paused}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"source":{{json $m.Source}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}],"networks":[{{$first := true}}{{range $name,$n := .NetworkSettings.Networks}}{{if not $first}},{{end}}{{$first = false}}{"name":{{json $name}},"id":{{json $n.NetworkID}},"aliases":{{json $n.Aliases}}}{{end}}]}', + network: + '{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}},"labels":{{json .Labels}},"members":[{{$first := true}}{{range $id,$c := .Containers}}{{if not $first}},{{end}}{{$first = false}}{{json $id}}{{end}}]}', + volume: + '{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver":{{json .Driver}},"scope":{{json .Scope}},"labels":{{json .Labels}},"options":{{json .Options}},"mountpoint":{{json .Mountpoint}}}', +} as const; +type Kind = keyof typeof FORMATS; +type Row = Record; +type Inventory = { + readonly engine: string; + readonly container: readonly Row[]; + readonly network: readonly Row[]; + readonly volume: readonly Row[]; + readonly images: readonly string[]; + readonly tags: Readonly>; +}; +type SourcePin = { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly uid: number; + readonly gid: number; + readonly size: number; + readonly hash: string; +}; +type Checkout = { + readonly root: string; + readonly name: string; + readonly grants: readonly NativeFileFixtureGrant[]; + readonly material: readonly SourcePin[]; + readonly marker: string; + privateMembers?: readonly string[]; + original?: { + readonly resources: Record; + readonly sources: readonly SourcePin[]; + }; + rolledBack?: boolean; + bootstrapped?: boolean; +}; +function requireValue(value: unknown): asserts value { + if (!value) { + throw new Error("Protected file acceptance refused; values omitted."); + } +} +function object(text: string): Row { + const value: unknown = JSON.parse(text); + requireValue(isRecord(value)); + return value; +} +function canonical(value: unknown): string { + if (Array.isArray(value)) { + return JSON.stringify(value.map((entry) => JSON.parse(canonical(entry)))); + } + if (isRecord(value)) { + return JSON.stringify( + Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, JSON.parse(canonical(value[key]))]) + ) + ); + } + return JSON.stringify(value); +} +function rows(value: readonly unknown[]): unknown[] { + return [...value].sort((a, b) => canonical(a).localeCompare(canonical(b))); +} +function hash(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} +async function sourcePin(path: string): Promise { + const before = await lstat(path); + requireValue( + before.isFile() && + !before.isSymbolicLink() && + before.nlink === 1 && + before.size <= 8192 && + before.uid === process.getuid?.() + ); + const descriptor = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const held = await descriptor.stat(); + requireValue( + held.dev === before.dev && + held.ino === before.ino && + held.mode === before.mode && + held.isFile() && + held.nlink === 1 && + held.uid === before.uid && + held.gid === before.gid && + held.size === before.size + ); + const buffer = Buffer.alloc(8193), + { bytesRead } = await descriptor.read(buffer, 0, buffer.length, 0); + const after = await lstat(path), + final = await descriptor.stat(); + for (const current of [after, final]) { + requireValue( + current.isFile() && + !current.isSymbolicLink() && + current.nlink === 1 && + before.dev === current.dev && + before.ino === current.ino && + before.mode === current.mode && + before.size === bytesRead && + current.size === bytesRead && + before.uid === current.uid && + before.gid === current.gid + ); + } + requireValue(bytesRead <= 8192); + return { + path, + dev: before.dev, + ino: before.ino, + mode: before.mode & 0o7777, + uid: before.uid, + gid: before.gid, + size: bytesRead, + hash: hash(buffer.subarray(0, bytesRead)), + }; + } finally { + await descriptor.close(); + } +} +async function checkSources(pins: readonly SourcePin[]) { + for (const pin of pins) { + requireValue(canonical(await sourcePin(pin.path)) === canonical(pin)); + } +} +async function createMaterial(path: string, bytes: Uint8Array, mode: number) { + // Initialize only a newly created exclusive synthetic file. No chmod is allowed + // after bootstrap or against any original/caller source. + const file = await open(path, "wx", 0o600); + try { + await file.writeFile(bytes); + await file.chmod(mode); + await file.sync(); + } finally { + await file.close(); + } + const pin = await sourcePin(path); + requireValue(pin.mode === mode); + return pin; +} +function noPorts(row: Row) { + return ( + row.publishAll === false && + (row.ports === null || + (isRecord(row.ports) && Object.keys(row.ports).length === 0)) && + (row.runtimePorts === null || + (isRecord(row.runtimePorts) && + Object.values(row.runtimePorts).every( + (value) => + value === null || (Array.isArray(value) && value.length === 0) + ))) + ); +} +/** Compare immutable container facts, preserving every mount and label. State is separately inspected. */ +export function nativeProtectedFileContainerIdentity(value: unknown): string { + requireValue( + isRecord(value) && + Array.isArray(value.mounts) && + Array.isArray(value.networks) + ); + const { + running: _running, + status: _status, + paused: _paused, + networks: _networks, + ...identity + } = value; + return canonical({ ...identity, mounts: rows(value.mounts) }); +} +/** Fixture cleanup cannot target an added resource, changed creation birth, or a live container. */ +export function nativeProtectedFileRemovalMatches(opts: { + readonly pin: Row; + readonly current: Row; + readonly kind: Kind; +}): boolean { + if (opts.kind === "container") { + return ( + opts.current.running === false && + opts.current.paused === false && + opts.current.status === "exited" && + Array.isArray(opts.pin.networks) && + Array.isArray(opts.current.networks) && + canonical(rows(opts.pin.networks)) === + canonical(rows(opts.current.networks)) && + nativeProtectedFileContainerIdentity(opts.pin) === + nativeProtectedFileContainerIdentity(opts.current) + ); + } + if (opts.kind === "network") { + const { members: _before, ...pin } = opts.pin, + { members, ...current } = opts.current; + return ( + Array.isArray(members) && + members.length === 0 && + canonical(pin) === canonical(current) + ); + } + return canonical(opts.pin) === canonical(opts.current); +} +function legacy(instance: Checkout, bun: string, db: string) { + return { + name: instance.name, + services: { + db: { + image: db, + pull_policy: "never", + environment: { + POSTGRES_HOST_AUTH_METHOD: "trust", + POSTGRES_DB: "fixture", + }, + volumes: ["data:/var/lib/postgresql/data"], + restart: "no", + stop_grace_period: "1s", + }, + reader: { + image: bun, + pull_policy: "never", + command: LOOP, + restart: "no", + stop_grace_period: "1s", + configs: [ + { + source: "settings", + target: instance.grants[0]?.target, + mode: "0444", + }, + ], + secrets: [ + { source: "owner", target: instance.grants[1]?.target }, + { + source: "private", + target: instance.grants[2]?.target, + mode: "0600", + }, + ], + }, + ungranted: { + image: bun, + pull_policy: "never", + command: LOOP, + restart: "no", + stop_grace_period: "1s", + }, + }, + configs: { settings: { file: "../material/settings" } }, + secrets: { + owner: { file: "../material/owner" }, + private: { file: "../material/private" }, + }, + volumes: { data: { name: `${instance.name}_data` } }, + }; +} +function native(instance: Checkout, bun: string) { + return { + schema_version: 1, + name: instance.name, + configs: { settings: { file: "material/settings" } }, + secrets: { + owner: { file: "material/owner" }, + private: { file: "material/private" }, + }, + services: { + reader: { + image: bun, + pull_policy: "never", + command: { exec: LOOP }, + restart: { kind: "no" }, + shutdown: { grace: "1s" }, + mounts: [ + { + config: "settings", + target: instance.grants[0]?.target, + access: "read-only", + mode: "0444", + }, + { + secret: "owner", + target: instance.grants[1]?.target, + access: "read-only", + mode: "0400", + }, + { + secret: "private", + target: instance.grants[2]?.target, + access: "read-only", + mode: "0600", + }, + ], + }, + ungranted: { + image: bun, + pull_policy: "never", + command: { exec: LOOP }, + restart: { kind: "no" }, + shutdown: { grace: "1s" }, + }, + }, + }; +} + +async function setup(ctx: ScenarioContext) { + requireValue(process.env.HACK_E2E_KEEP === "1"); + const deadline = Date.now() + 600_000; + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + const remaining = (max = 90_000) => { + const time = deadline - Date.now(); + requireValue(time > 0 && !controller.signal.aborted); + return Math.min(max, time); + }; + const cli = process.env.HACK_E2E_CLI_BIN, + compiler = process.env.HACK_CONFIG_COMPILER_BINARY; + const expectedCli = process.env.HACK_E2E_CLI_SHA256, + expectedCompiler = process.env.HACK_E2E_COMPILER_SHA256, + expectedSource = process.env.HACK_E2E_SOURCE_REVISION; + const pluginPath = process.env.HACK_E2E_COMPOSE_PLUGIN_PATH, + pluginHash = process.env.HACK_E2E_COMPOSE_PLUGIN_SHA256; + requireValue( + process.env.HACK_E2E_PROTECTED_FILES_SLOT_RELEASED === expectedSource + ); + requireValue( + cli && + compiler && + expectedCli && + expectedCompiler && + expectedSource && + pluginPath && + pluginHash && + isAbsolute(cli) && + isAbsolute(compiler) && + HASH.test(expectedCli) && + HASH.test(expectedCompiler) && + /^[a-f0-9]{40}$/.test(expectedSource) + ); + const docker = Bun.which("docker"), + git = Bun.which("git"); + requireValue(docker && git && isAbsolute(docker) && isAbsolute(git)); + const requestedSocket = process.env.DOCKER_HOST; + requireValue( + typeof requestedSocket === "string" && requestedSocket.startsWith("unix://") + ); + const socket = await realpath(requestedSocket.slice(7)), + socketInfo = await lstat(socket); + requireValue(socketInfo.isSocket()); + const artifacts = await Promise.all( + [cli, compiler, docker, git, process.execPath].map(async (path) => ({ + path, + physical: await realpath(path), + info: await lstat(await realpath(path)), + hash: hash(await readFile(await realpath(path))), + })) + ); + requireValue( + artifacts[0]?.hash === expectedCli && + artifacts[1]?.hash === expectedCompiler && + artifacts.every( + (pin) => + pin.info.isFile() && + !pin.info.isSymbolicLink() && + pin.info.nlink === 1 && + (pin.info.mode & 0o111) !== 0 + ) + ); + const home = join(ctx.tempRoot, "protected-home"), + captures = join(ctx.tempRoot, "protected-captures"); + await mkdir(home, { mode: 0o700 }); + await mkdir(join(home, ".docker"), { mode: 0o700 }); + await mkdir(captures, { mode: 0o700 }); + await writeFile(join(home, ".docker/config.json"), "{}\n", { + mode: 0o600, + flag: "wx", + }); + await provisionNativeNetworkFixtureComposePlugin({ + dockerConfig: join(home, ".docker"), + path: pluginPath, + expectedHash: pluginHash, + }); + const env = buildCliEnv({ + hackHome: join(home, "hack"), + extra: { + HOME: home, + DOCKER_HOST: `unix://${socket}`, + DOCKER_CONFIG: join(home, ".docker"), + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + HACK_CONFIG_COMPILER_BINARY: compiler, + HACK_RUNTIME_BACKEND: "compose", + HACK_DAEMON_AUTO_START: "0", + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "45000", + }, + }); + let serial = 0, + remainingOutput = 16 * 1024 * 1024; + const command = async ( + argv: readonly string[], + cwd = ctx.tempRoot, + stdin?: unknown, + extra?: Readonly> + ) => { + requireValue(serial < 1000 && remainingOutput > 0); + const result = await runNativeFileFixtureCommand({ + argv, + cwd, + env: { ...env, ...extra }, + timeoutMs: remaining(), + signal: controller.signal, + stdin: + stdin === undefined ? undefined : Buffer.from(JSON.stringify(stdin)), + capturePrefix: join(captures, String(++serial).padStart(5, "0")), + }); + remainingOutput -= + Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr); + requireValue(remainingOutput >= 0); + return result; + }; + const successful = (result: NativeFileFixtureCommandResult) => { + requireValue(result.exitCode === 0); + return result.stdout.trim(); + }; + const probe = async (args: readonly string[]) => + successful(await command([docker, ...args])); + const invoke = async ( + instance: Checkout, + args: readonly string[], + stdin?: unknown, + extra?: Readonly> + ) => { + await fence(); + return command( + [cli, "--path", instance.root, ...args], + instance.root, + stdin, + extra + ); + }; + const fence = async () => { + remaining(); + const currentSocket = await lstat(socket); + requireValue( + currentSocket.isSocket() && + currentSocket.dev === socketInfo.dev && + currentSocket.ino === socketInfo.ino && + currentSocket.mode === socketInfo.mode && + (await realpath(socket)) === socket + ); + for (const pin of artifacts) { + requireValue((await realpath(pin.path)) === pin.physical); + const latest = await lstat(pin.physical); + requireValue( + latest.dev === pin.info.dev && + latest.ino === pin.info.ino && + latest.size === pin.info.size && + latest.mode === pin.info.mode && + hash(await readFile(pin.physical)) === pin.hash + ); + } + requireValue( + successful(await command([git, "-C", REPO_ROOT, "rev-parse", "HEAD"])) === + expectedSource + ); + requireValue( + successful( + await command([ + git, + "-C", + REPO_ROOT, + "status", + "--porcelain", + "--untracked-files=no", + ]) + ) === "" + ); + remaining(); + }; + await fence(); + const inspect = async (kind: Kind, id: string) => { + const row = object( + await probe([kind, "inspect", "--format", FORMATS[kind], id]) + ); + if (kind === "container") { + requireValue(Array.isArray(row.networks) && Array.isArray(row.mounts)); + row.mounts = rows(row.mounts); + row.networks = rows( + row.networks.map((entry: unknown) => { + requireValue( + isRecord(entry) && + (entry.aliases === null || Array.isArray(entry.aliases)) + ); + if (Array.isArray(entry.aliases)) { + requireValue( + entry.aliases.every( + (alias: unknown) => typeof alias === "string" + ) && new Set(entry.aliases).size === entry.aliases.length + ); + return { ...entry, aliases: [...entry.aliases].sort() }; + } + return entry; + }) + ); + } + return row; + }; + const list = async (kind: Kind, project?: string) => { + const text = await probe([ + kind, + "ls", + ...(kind === "container" ? ["--all"] : []), + ...(kind === "volume" ? [] : ["--no-trunc"]), + "--format", + kind === "volume" ? "{{.Name}}" : "{{.ID}}", + ...(project ? ["--filter", `label=${PROJECT}=${project}`] : []), + ]); + const ids = text.split(/\s+/).filter(Boolean).sort(); + requireValue( + new Set(ids).size === ids.length && + (kind === "volume" || ids.every((id) => ID.test(id))) + ); + return ids; + }; + const engine = JSON.parse(await probe(["info", "--format", "{{json .ID}}"])); + requireValue(typeof engine === "string" && engine.length > 0); + const assertEngine = async () => + requireValue( + JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) === engine + ); + const tags = { + [BUN_TAG]: await probe([ + "image", + "inspect", + BUN_TAG, + "--format", + "{{.Id}}", + ]), + [DB_TAG]: await probe(["image", "inspect", DB_TAG, "--format", "{{.Id}}"]), + }; + requireValue(Object.values(tags).every((id) => IMAGE.test(id))); + const inventory = async (): Promise => { + await assertEngine(); + const resources: Record = { + container: [], + network: [], + volume: [], + }; + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind)) { + const row = await inspect(kind, id); + if (kind === "container") { + requireValue( + Array.isArray(row.mounts) && Array.isArray(row.networks) + ); + row.mounts = rows(row.mounts); + row.networks = rows(row.networks); + } else if (kind === "network") { + requireValue(Array.isArray(row.members)); + row.members = rows(row.members); + } + resources[kind].push(row); + } + } + const images = [ + ...new Set( + ( + await probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--format", + "{{.ID}}", + ]) + ) + .trim() + .split(/\s+/) + .filter(Boolean) + ), + ].sort(); + requireValue(images.every((id) => IMAGE.test(id))); + const currentTags = Object.fromEntries( + await Promise.all( + Object.keys(tags).map(async (tag) => [ + tag, + await probe(["image", "inspect", tag, "--format", "{{.Id}}"]), + ]) + ) + ); + await assertEngine(); + return { engine, ...resources, images, tags: currentTags }; + }; + const baseline = await inventory(); + requireValue(baseline.container.every((row) => row.running === false)); + const make = async ( + root: string, + name: string, + marker: string + ): Promise => { + await mkdir(join(root, ".hack"), { mode: 0o700, recursive: true }); + await mkdir(join(root, "material"), { mode: 0o700 }); + const grants: NativeFileFixtureGrant[] = [ + { + target: "/etc/fixture-settings", + mode: "0444", + bytes: Array.from(Buffer.from(`fixture-config-${marker}`)), + }, + { + target: "/run/secrets/owner", + mode: "0400", + bytes: Array.from(Buffer.from(`fixture-owner-${marker}`)), + }, + { + target: "/run/secrets/private", + mode: "0600", + bytes: Array.from(Buffer.from(`fixture-private-${marker}`)), + }, + ]; + const material: SourcePin[] = []; + for (const [index, filename] of [ + "settings", + "owner", + "private", + ].entries()) { + const grant = grants[index]; + requireValue(grant); + material.push( + await createMaterial( + join(root, "material", filename), + Buffer.from(grant.bytes), + Number.parseInt(grant.mode, 8) + ) + ); + } + return { root, name, marker, grants, material }; + }; + const suffix = randomBytes(4).toString("hex"), + ordinary = await make( + join(ctx.tempRoot, "ordinary"), + `file-native-${suffix}`, + "native" + ); + await writeFile( + join(ordinary.root, ".hack/hack.project.json"), + JSON.stringify(native(ordinary, tags[BUN_TAG])), + { mode: 0o600, flag: "wx" } + ); + const primaryRoot = join(ctx.tempRoot, "retained-primary"); + await mkdir(primaryRoot, { mode: 0o700 }); + successful(await command([git, "init", "--quiet", primaryRoot])); + successful( + await command([git, "-C", primaryRoot, "config", "user.name", "Fixture"]) + ); + successful( + await command([ + git, + "-C", + primaryRoot, + "config", + "user.email", + "fixture@example.invalid", + ]) + ); + await writeFile( + join(primaryRoot, ".gitignore"), + "material/\n.hack/.internal/\n.hack/hack.project.json\n", + { mode: 0o600 } + ); + successful(await command([git, "-C", primaryRoot, "add", ".gitignore"])); + successful( + await command([ + git, + "-C", + primaryRoot, + "commit", + "--quiet", + "-m", + "fixture: initial retained checkout", + ]) + ); + const secondRoot = join(ctx.tempRoot, "retained-second"); + successful( + await command([ + git, + "-C", + primaryRoot, + "worktree", + "add", + "--quiet", + "-b", + "file-second", + secondRoot, + ]) + ); + const first = await make(primaryRoot, `file-first-${suffix}`, "first"), + second = await make(secondRoot, `file-second-${suffix}`, "second"); + for (const instance of [first, second]) { + await writeFile( + join(instance.root, ".hack/hack.config.json"), + JSON.stringify({ name: instance.name, worktree: { inherit: false } }), + { mode: 0o600, flag: "wx" } + ); + await writeFile( + join(instance.root, ".hack/docker-compose.yml"), + JSON.stringify(legacy(instance, tags[BUN_TAG], tags[DB_TAG])), + { mode: 0o600, flag: "wx" } + ); + successful( + await command([ + git, + "-C", + instance.root, + "add", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ]) + ); + successful( + await command([ + git, + "-C", + instance.root, + "commit", + "--quiet", + "-m", + "fixture: isolated retained file inputs", + ]) + ); + } + const stop = () => { + process.off("SIGINT", abort); + process.off("SIGTERM", abort); + }; + return { + ctx, + deadline, + remaining, + env, + cli, + git, + docker, + home, + command, + successful, + probe, + invoke, + fence, + inspect, + list, + assertEngine, + inventory, + baseline, + ordinary, + first, + second, + stop, + }; +} +type Fixture = Awaited>; +async function fixtureEnvironment( + h: Fixture, + run: () => Promise +): Promise { + const prior = new Map(); + for (const [key, value] of Object.entries(h.env)) { + prior.set(key, process.env[key]); + process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of prior) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} +function containerTopology(entries: readonly unknown[]) { + return entries.map((entry) => { + requireValue(isRecord(entry)); + return { name: entry.name, aliases: entry.aliases }; + }); +} +function requireOriginalResourceIdentity(kind: Kind, pin: Row, current: Row) { + if (kind === "container") { + requireValue( + nativeProtectedFileContainerIdentity(current) === + nativeProtectedFileContainerIdentity(pin) && + Array.isArray(pin.networks) && + Array.isArray(current.networks) + ); + requireValue( + canonical(rows(containerTopology(pin.networks))) === + canonical(rows(containerTopology(current.networks))) + ); + } else if (kind === "network") { + const { members: _a, ...a } = pin, + { members: _b, ...b } = current; + requireValue(canonical(a) === canonical(b)); + } else { + requireValue(canonical(pin) === canonical(current)); + } +} +function requireOriginalResources( + current: Record, + original: Record +) { + for (const kind of ["container", "network", "volume"] as const) { + const before = original[kind]; + requireValue(before.length === current[kind].length); + for (const pin of before) { + const identity = kind === "volume" ? "name" : "id"; + const row = current[kind].find( + (value) => value[identity] === pin[identity] + ); + requireValue(row); + requireOriginalResourceIdentity(kind, pin, row); + } + } +} +async function resources( + h: Fixture, + instance: Checkout +): Promise> { + await h.assertEngine(); + const result: Record = { + container: [], + network: [], + volume: [], + }; + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await h.list(kind, instance.name)) { + result[kind].push(await h.inspect(kind, id)); + } + } + requireValue( + result.container.length === 3 && + result.network.length === 1 && + result.volume.length === 1 + ); + const roles = new Set(); + for (const row of result.container) { + requireValue( + typeof row.id === "string" && + ID.test(row.id) && + isRecord(row.labels) && + row.labels[PROJECT] === instance.name && + typeof row.labels["com.docker.compose.service"] === "string" && + ["db", "reader", "ungranted"].includes( + row.labels["com.docker.compose.service"] + ) && + row.labels["com.docker.compose.container-number"] === "1" && + row.labels["com.docker.compose.oneoff"] === "False" && + row.labels["com.docker.compose.project.working_dir"] === + join(instance.root, ".hack") && + row.labels["com.docker.compose.project.config_files"] === + join(instance.root, ".hack/docker-compose.yml") && + !Object.keys(row.labels).some((key) => + key.startsWith("io.hack.native-config.") + ) && + noPorts(row) && + Array.isArray(row.mounts) && + Array.isArray(row.networks) && + row.networks.length === 1 + ); + const service = row.labels["com.docker.compose.service"]; + requireValue(!roles.has(service)); + roles.add(service); + const mounts = + service === "db" + ? [ + { + type: "volume", + name: `${instance.name}_data`, + target: "/var/lib/postgresql/data", + rw: true, + }, + ] + : service === "reader" + ? instance.grants.map((grant, index) => ({ + type: "bind", + name: "", + source: instance.material[index]?.path, + target: grant.target, + rw: false, + })) + : []; + const projected = row.mounts.map((mount: unknown) => { + requireValue(isRecord(mount)); + return service === "db" + ? { + type: mount.type, + name: mount.name, + target: mount.target, + rw: mount.rw, + } + : mount; + }); + requireValue(canonical(rows(projected)) === canonical(rows(mounts))); + const endpoint = row.networks[0]; + requireValue( + isRecord(endpoint) && + endpoint.name === `${instance.name}_default` && + (endpoint.id === result.network[0]?.id || + (!row.running && endpoint.id === "")) + ); + } + const network = result.network[0], + volume = result.volume[0]; + requireValue( + network && + typeof network.id === "string" && + ID.test(network.id) && + network.name === `${instance.name}_default` && + network.driver === "bridge" && + network.scope === "local" && + network.internal === false && + isRecord(network.labels) && + network.labels[PROJECT] === instance.name && + network.labels["com.docker.compose.network"] === "default" && + Array.isArray(network.members) && + canonical(rows(network.members)) === + canonical( + rows( + result.container.filter((row) => row.running).map((row) => row.id) + ) + ) + ); + requireValue( + volume && + volume.name === `${instance.name}_data` && + volume.driver === "local" && + volume.scope === "local" && + (volume.options === null || + (isRecord(volume.options) && + Object.keys(volume.options).length === 0)) && + isRecord(volume.labels) && + volume.labels[PROJECT] === instance.name && + volume.labels["com.docker.compose.volume"] === "data" && + typeof volume.createdAt === "string" && + volume.createdAt.length > 0 + ); + if (instance.original) { + requireOriginalResources(result, instance.original.resources); + } + await h.assertEngine(); + return result; +} +function container( + rows: Record, + service: string +): string { + const row = rows.container.find( + (item) => + isRecord(item.labels) && + item.labels["com.docker.compose.service"] === service + ); + requireValue(row && typeof row.id === "string" && ID.test(row.id)); + return row.id; +} +async function sourcePair(instance: Checkout) { + return [ + await sourcePin(join(instance.root, ".hack/hack.config.json")), + await sourcePin(join(instance.root, ".hack/docker-compose.yml")), + ]; +} +async function sql(h: Fixture, instance: Checkout, query: string) { + const rows = await resources(h, instance); + return h.successful( + await h.command([ + h.docker, + "container", + "exec", + container(rows, "db"), + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + query, + ]) + ); +} +async function waitSql(h: Fixture, instance: Checkout) { + const limit = Date.now() + Math.min(30_000, h.remaining()); + while (true) { + h.remaining(); + const rows = await resources(h, instance), + result = await h.command([ + h.docker, + "container", + "exec", + container(rows, "db"), + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + "SELECT 1", + ]); + if (result.exitCode === 0 && result.stdout.trim() === "1") { + return; + } + requireValue(Date.now() < limit); + await Bun.sleep(100); + } +} +async function retainedReceipt(instance: Checkout) { + const path = join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const before = await lstat(path); + requireValue( + before.isFile() && + !before.isSymbolicLink() && + before.nlink === 1 && + before.size <= 64 * 1024 && + (before.mode & 0o777) === 0o600 + ); + const raw = await readFile(path, "utf8"), + after = await lstat(path); + requireValue( + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size + ); + const row = object(raw); + requireValue( + row.adoption_receipt_version === 8 && + isRecord(row.prepared) && + isRecord(row.publication) && + row.publication.phase === "active" && + canonical(row.publication.generation) === canonical(row.prepared) + ); + return row; +} +async function savedRetained(h: Fixture, instance: Checkout) { + const receipt = await retainedReceipt(instance); + requireValue(receipt.pendingOperation === null); + await fixtureEnvironment(h, async () => { + const { openLegacyComposeAdoptedGenerationStore } = await import( + "../../../src/lib/native-compose-adoption-generation.ts" + ); + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot: instance.root, + mode: "saved", + }); + try { + const generation = await store.loadActive(); + requireValue(generation?.report.adoption_generation_version === 8); + await store.withLease({ + generation, + material: "saved", + run: async (input) => { + const sourceHashes = [ + hash(Buffer.from(input.configText)), + hash(Buffer.from(input.composeText)), + ]; + requireValue( + canonical(sourceHashes) === + canonical(instance.original?.sources.map((pin) => pin.hash)) + ); + const rows = await resources(h, instance); + requireValue( + canonical( + [...input.binding.containers.map((row) => row.id)].sort() + ) === canonical([...rows.container.map((row) => row.id)].sort()) + ); + }, + }); + } finally { + await store.close(); + } + }); + return receipt; +} +async function permissionRead( + h: Fixture, + instance: Checkout, + mode: "native" | "retained" +) { + await checkSources(instance.material); + await h.fence(); + let reader: string, ungranted: string; + if (mode === "native") { + const observed = await fixtureEnvironment(h, () => + observeNativeComposeFixture(instance.root) + ); + requireValue( + observed.pending === null && + !observed.stopped && + observed.observed.containers.length === 2 + ); + const selected = observed.observed.containers.find( + (row) => row.service === "reader" + ), + absent = observed.observed.containers.find( + (row) => row.service === "ungranted" + ); + requireValue(selected && absent); + reader = selected.id; + ungranted = absent.id; + } else { + const rows = await resources(h, instance); + requireValue(rows.container.every((row) => row.running === true)); + reader = container(rows, "reader"); + ungranted = container(rows, "ungranted"); + await savedRetained(h, instance); + } + const read = h.successful( + await h.invoke( + instance, + ["exec", "reader", "--", "bun", "-e", nativeFileFixtureReadProgram], + instance.grants + ) + ); + const guest = readNativeFileFixtureGuest({ + text: read, + expected: instance.grants, + }); + requireValue(guest.members.length === 3); + const protectedMembers = guest.members.filter( + (member) => member.mode !== "0444" + ), + nonowner = nativeFileFixtureNonowner(guest); + const protectedOwners = new Set(protectedMembers.map((member) => member.uid)); + requireValue(protectedOwners.size === 1); + const ownerUid = protectedMembers[0]?.uid; + requireValue(ownerUid !== undefined); + await h.fence(); + const ownerRead = h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + String(ownerUid), + reader, + "bun", + "-e", + nativeFileFixtureReadProgram, + ], + instance.root, + instance.grants + ) + ); + const authorized = readNativeFileFixtureGuest({ + text: ownerRead, + expected: instance.grants, + }); + requireValue( + authorized.uid === ownerUid && + canonical(authorized.members) === canonical(guest.members) + ); + await h.fence(); + requireValue( + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + "0", + reader, + "bun", + "-e", + nativeFileFixtureWriteProgram, + ], + instance.root, + guest.members + ) + ) === "exact-readonly-files" + ); + await h.fence(); + requireValue( + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + String(nonowner), + reader, + "bun", + "-e", + nativeFileFixtureDeniedProgram, + ], + instance.root, + protectedMembers + ) + ) === "exact-nonowner-read-refused" + ); + await h.fence(); + requireValue( + h.successful( + await h.invoke( + instance, + [ + "exec", + "ungranted", + "--", + "bun", + "-e", + nativeFileFixtureAbsentProgram, + ], + guest.members + ) + ) === "exact-ungranted-files-absent" + ); + // Independently bind the ungranted ID; no config/secret target is delivered there. + const absent = await h.inspect("container", ungranted); + requireValue( + Array.isArray(absent.mounts) && + absent.mounts.every( + (value: unknown) => + isRecord(value) && + !instance.grants.some((grant) => grant.target === value.target) + ) + ); + await checkSources(instance.material); + await h.fence(); + return guest; +} +async function savedNative(h: Fixture, instance: Checkout) { + return fixtureEnvironment(h, async () => { + const store = await openNativeComposeGenerationStore({ + projectRoot: instance.root, + instance: null, + mode: "saved", + }); + try { + const current = await store.loadCurrent(); + requireValue(current.generation && !current.pending); + const doc = await store.withLease({ + generation: current.generation, + run: () => store.readGenerationDocument(current.generation!), + }); + const reference = parseNativeComposeFileReference( + doc["x-hack-native-files"] + ); + requireValue(reference.version === 2); + requireValue( + isRecord(doc.services) && + isRecord(doc.services.reader) && + Array.isArray(doc.services.reader.volumes) + ); + const members = doc.services.reader.volumes.map((value: unknown) => { + requireValue( + isRecord(value) && + value.type === "bind" && + value.read_only === true && + typeof value.source === "string" + ); + return value.source.replaceAll("$$", "$"); + }); + instance.privateMembers = [ + ...(instance.privateMembers ?? []), + ...members, + ]; + return { generation: current.generation.generationId, reference }; + } finally { + await store.close(); + } + }); +} +async function ordinaryFlow(h: Fixture) { + const instance = h.ordinary; + await h.fence(); + h.successful(await h.invoke(instance, ["up", "--detach", "--json"])); + instance.bootstrapped = true; + const before = await savedNative(h, instance), + guest = await permissionRead(h, instance, "native"); + h.successful(await h.invoke(instance, ["ps", "--json"])); + h.successful(await h.invoke(instance, ["restart", "--json"])); + const after = await savedNative(h, instance); + requireValue( + before.reference.snapshotToken !== after.reference.snapshotToken && + canonical(await permissionRead(h, instance, "native")) === + canonical(guest) + ); + h.ctx.log( + "Ordinary native snapshot2 guest modes, owner facts, granted/nonowner/ungranted reads and readonly writes verified" + ); +} +async function bootstrap(h: Fixture, instance: Checkout) { + for (const kind of ["container", "network", "volume"] as const) { + requireValue((await h.list(kind, instance.name)).length === 0); + } + await h.fence(); + await checkSources(instance.material); + const sources = await sourcePair(instance); + const result = await h.command( + [ + h.docker, + "compose", + "--project-name", + instance.name, + "--project-directory", + join(instance.root, ".hack"), + "--env-file", + "/dev/null", + "--file", + join(instance.root, ".hack/docker-compose.yml"), + "up", + "--detach", + "--no-build", + "--pull", + "never", + ], + instance.root + ); + // Latch the complete exact inventory before any later readiness or permission assertion. + const observed = await resources(h, instance); + instance.original = { resources: observed, sources }; + instance.bootstrapped = true; + h.successful(result); + await waitSql(h, instance); + requireValue( + (await sql( + h, + instance, + "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename='marker'" + )) === "0" + ); + // Exactly one seed call. No upsert, polling write or automatic retry. + await h.fence(); + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + container(observed, "db"), + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + "fixture", + "-c", + `CREATE TABLE marker(id integer primary key,value text not null); INSERT INTO marker VALUES (1,'${instance.marker}');`, + ], + instance.root + ) + ); + requireValue( + (await sql(h, instance, "SELECT value FROM marker WHERE id=1")) === + instance.marker + ); + await checkSources(instance.material); + await checkSources(sources); +} +async function retainedRunning(h: Fixture, instance: Checkout) { + requireValue( + (await sql(h, instance, "SELECT value FROM marker WHERE id=1")) === + instance.marker + ); + await savedRetained(h, instance); + return permissionRead(h, instance, "retained"); +} +async function activeSourceRefusal(h: Fixture) { + const instance = h.first, + before = await savedRetained(h, instance), + originals = await resources(h, instance), + sibling = await resources(h, h.second); + requireValue(originals.container.every((row) => row.running === true)); + const directory = join(h.ctx.tempRoot, "source-readonly-guard"), + marker = join(directory, "unexpected-effect.json"); + await mkdir(directory, { mode: 0o700 }); + const prepared = before.prepared; + requireValue(isRecord(prepared) && typeof prepared.id === "string"); + const scope = { + projectRoot: instance.root, + project: instance.name, + containerIds: originals.container.map((row) => row.id), + networkId: originals.network[0]?.id, + volumeName: originals.volume[0]?.name, + generationId: prepared.id, + reader: container(originals, "reader"), + targets: instance.grants.map((grant) => grant.target), + }; + const control = fileURLToPath( + new URL("../native-file-permission-control.ts", import.meta.url) + ); + await writeFile( + join(directory, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';\nimport {nativeProtectedFileReadAllowed} from ${JSON.stringify(control)};\nconst args=process.argv.slice(2),engine=${JSON.stringify(h.docker)},scope=${JSON.stringify(scope)};\nconst proof=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'});const text=await new Response(proof.stdout).text();if(text.length>4096||await proof.exited!==0||JSON.parse(text)!==${JSON.stringify(h.baseline.engine)})process.exit(95);\nif(!nativeProtectedFileReadAllowed({...scope,args})){const file=await open(${JSON.stringify(marker)},'wx',0o600);try{await file.writeFile(JSON.stringify({version:1,stage:'effect-refused-before-forwarding'}));await file.sync()}finally{await file.close()}process.exit(98)}\nconst child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);\n`, + { mode: 0o700, flag: "wx" } + ); + const path = h.env.PATH; + requireValue(path); + const withheld = join(h.ctx.tempRoot, "active-material-withheld"); + await h.fence(); + await rename(join(instance.root, "material"), withheld); + try { + for (const args of [ + ["restart", "--json"], + ["--json", "exec", "reader", "--", "true"], + ]) { + const refused = await h.invoke(instance, args, undefined, { + PATH: `${directory}:${path}`, + }); + // Always inspect the journal and physical originals independently of the + // diagnostic, so an arbitrary earlier refusal cannot hide a mutation. + const receipt = await retainedReceipt(instance), + observed = await resources(h, instance), + siblingAfter = await resources(h, h.second); + requireValue( + refused.exitCode !== 0 && + nativeProtectedFileStateRefused(object(refused.stdout)) + ); + requireValue( + canonical(receipt) === canonical(before) && + receipt.pendingOperation === null && + canonical(observed) === canonical(originals) && + canonical(siblingAfter) === canonical(sibling) + ); + try { + await lstat(marker); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + } + } finally { + await rename(withheld, join(instance.root, "material")); + } + await checkSources(instance.material); + await h.fence(); + h.successful(await h.invoke(instance, ["exec", "reader", "--", "true"])); + requireValue( + canonical(await resources(h, instance)) === canonical(originals) && + canonical(await resources(h, h.second)) === canonical(sibling) + ); + h.ctx.log( + "Missing current source refused restart and exec before effects or journal changes; original source incarnation restored" + ); +} +async function interruptedStart(h: Fixture) { + const instance = h.first, + sibling = h.second; + h.successful(await h.invoke(instance, ["down", "--json"])); + const stopped = await resources(h, instance); + requireValue(stopped.container.every((row) => row.running === false)); + const before = await savedRetained(h, instance), + siblingBefore = await resources(h, sibling); + const directory = join(h.ctx.tempRoot, "interrupted-start"), + marker = join(directory, "armed.json"); + await mkdir(directory, { mode: 0o700 }); + const ids = stopped.container.map((row) => row.id); + const wrapper = join(directory, "docker"); + const control = fileURLToPath( + new URL("../native-file-permission-control.ts", import.meta.url) + ); + const prepared = before.prepared; + requireValue(isRecord(prepared) && typeof prepared.id === "string"); + const scope = { + projectRoot: instance.root, + project: instance.name, + containerIds: ids, + networkId: stopped.network[0]?.id, + volumeName: stopped.volume[0]?.name, + generationId: prepared.id, + reader: container(stopped, "reader"), + targets: instance.grants.map((grant) => grant.target), + }; + await writeFile( + wrapper, + `#!${process.execPath}\nimport {open,lstat,readFile} from 'node:fs/promises';\nimport {nativeProtectedFileReadAllowed,nativeProtectedFileStartAllowed} from ${JSON.stringify(control)};\nconst args=process.argv.slice(2),engine=${JSON.stringify(h.docker)},scope=${JSON.stringify(scope)};\nconst proof=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'});const engineText=await new Response(proof.stdout).text();if(engineText.length>4096||await proof.exited!==0||JSON.parse(engineText)!==${JSON.stringify(h.baseline.engine)})process.exit(95);\nconst receiptPath=${JSON.stringify(join(instance.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"))};const info=await lstat(receiptPath);if(!info.isFile()||info.isSymbolicLink()||info.nlink!==1||info.size>65536||(info.mode&511)!==384)process.exit(98);const receipt=JSON.parse(await readFile(receiptPath,'utf8'));\nif(args[0]==='container'&&args[1]==='start'){\nif(!nativeProtectedFileStartAllowed({args,ids:${JSON.stringify(ids)},prepared:${JSON.stringify(prepared)},receipt}))process.exit(98);\nconst file=await open(${JSON.stringify(marker)},'wx',0o600);try{await file.writeFile(JSON.stringify({version:1,stage:'journaled-start-before-effect',ids:${JSON.stringify(ids)}}));await file.sync();}finally{await file.close()}process.exit(71);}\nif(!nativeProtectedFileReadAllowed({...scope,args}))process.exit(98);\nconst child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);\n`, + { mode: 0o700, flag: "wx" } + ); + const originalPath = h.env.PATH; + requireValue(originalPath); + const failed = await h.invoke( + instance, + ["up", "--detach", "--json"], + undefined, + { PATH: `${directory}:${originalPath}` } + ); + requireValue(failed.exitCode !== 0); + const admitted = object(await readFile(marker, "utf8")); + requireValue( + admitted.stage === "journaled-start-before-effect" && + canonical(admitted.ids) === canonical(ids) + ); + const pending = await retainedReceipt(instance); + requireValue( + isRecord(pending.pendingOperation) && + pending.pendingOperation.operation === "start" && + canonical(pending.pendingOperation.generation) === + canonical( + before.publication && isRecord(before.publication) + ? before.publication.generation + : null + ) && + canonical(pending.prepared) === canonical(before.prepared) + ); + requireValue(canonical(await resources(h, instance)) === canonical(stopped)); + requireValue( + canonical(await resources(h, sibling)) === canonical(siblingBefore) + ); + // Only this synthetic source directory is withheld. Saved stop/recovery and rollback must not read it. + const withheld = join(h.ctx.tempRoot, "first-material-withheld"); + await rename(join(instance.root, "material"), withheld); + try { + const exec = await h.invoke(instance, ["exec", "reader", "--", "true"]); + requireValue(exec.exitCode !== 0); + h.successful(await h.invoke(instance, ["down", "--recover", "--json"])); + requireValue((await retainedReceipt(instance)).pendingOperation === null); + requireValue( + (await resources(h, instance)).container.every( + (row) => row.running === false + ) + ); + h.successful( + await h.invoke(instance, ["config", "adopt", "--rollback", "--json"]) + ); + instance.rolledBack = true; + await checkSources(instance.original?.sources ?? []); + requireValue( + canonical(await resources(h, sibling)) === canonical(siblingBefore) + ); + } finally { + await rename(withheld, join(instance.root, "material")); + } + await checkSources(instance.material); + h.ctx.log( + "Journaled retained start interruption kept exact originals; source-free saved stop recovery and rollback preserved sibling" + ); +} +async function retainedFlow(h: Fixture) { + await bootstrap(h, h.first); + await bootstrap(h, h.second); + for (const instance of [h.first, h.second]) { + const before = await resources(h, instance); + const preview = object( + h.successful( + await h.invoke(instance, [ + "config", + "adopt", + "--dry-run", + "--stop", + "--json", + ]) + ) + ); + requireValue(preview.complete === true); + requireValue(canonical(await resources(h, instance)) === canonical(before)); + h.successful( + await h.invoke(instance, ["config", "adopt", "--stop", "--json"]) + ); + requireValue( + (await resources(h, instance)).container.every( + (row) => row.running === false + ) + ); + h.successful(await h.invoke(instance, ["up", "--detach", "--json"])); + await waitSql(h, instance); + const guest = await retainedRunning(h, instance); + h.successful(await h.invoke(instance, ["restart", "--json"])); + await waitSql(h, instance); + requireValue( + canonical(await retainedRunning(h, instance)) === canonical(guest) + ); + await h.fence(); + } + requireValue( + h.first.original && + h.second.original && + h.first.original.resources.volume[0]?.name !== + h.second.original.resources.volume[0]?.name && + h.first.material.every((pin) => + h.second.material.every( + (other) => pin.dev !== other.dev || pin.ino !== other.ino + ) + ) + ); + await activeSourceRefusal(h); + await interruptedStart(h); + h.successful(await h.invoke(h.second, ["down", "--json"])); + requireValue( + (await resources(h, h.second)).container.every( + (row) => row.running === false + ) + ); + h.successful( + await h.invoke(h.second, ["config", "adopt", "--rollback", "--json"]) + ); + h.second.rolledBack = true; + await checkSources(h.second.original.sources); + await checkSources(h.second.material); +} +async function removeRetained(h: Fixture, instance: Checkout) { + if (!instance.bootstrapped) { + return; + } + requireValue(instance.original); + // Never dispatch a cleanup down through the legacy path if adoption did not + // reach this exact active file8 receipt. Unknown bootstrap/publication is + // retained for explicit inspection instead of inferred or replayed. + if (!instance.rolledBack) { + await retainedReceipt(instance); + h.successful(await h.invoke(instance, ["down", "--recover", "--json"])); + h.successful( + await h.invoke(instance, ["config", "adopt", "--rollback", "--json"]) + ); + instance.rolledBack = true; + } + const stopped = await resources(h, instance); + requireValue( + stopped.container.every( + (row) => + row.running === false && row.paused === false && row.status === "exited" + ) + ); + await checkSources(instance.original.sources); + await checkSources(instance.material); + await h.fence(); + const remainingIds = stopped.container.map((row) => row.id); + requireValue( + remainingIds.every((id) => typeof id === "string" && ID.test(id)) + ); + for (const pin of stopped.container) { + await h.fence(); + await h.assertEngine(); + requireValue( + canonical(await h.list("container", instance.name)) === + canonical([...remainingIds].sort()) + ); + requireValue( + typeof pin.id === "string" && + nativeProtectedFileRemovalMatches({ + pin, + current: await h.inspect("container", pin.id), + kind: "container", + }) + ); + h.successful(await h.command([h.docker, "container", "rm", pin.id])); + remainingIds.splice(remainingIds.indexOf(pin.id), 1); + } + requireValue((await h.list("container", instance.name)).length === 0); + const network = stopped.network[0], + volume = stopped.volume[0]; + requireValue( + network && + volume && + typeof network.id === "string" && + typeof volume.name === "string" + ); + await h.fence(); + await h.assertEngine(); + requireValue( + canonical(await h.list("network", instance.name)) === + canonical([network.id]) + ); + requireValue( + nativeProtectedFileRemovalMatches({ + pin: network, + current: await h.inspect("network", network.id), + kind: "network", + }) + ); + h.successful(await h.command([h.docker, "network", "rm", network.id])); + await h.fence(); + await h.assertEngine(); + requireValue( + (await h.list("network", instance.name)).length === 0 && + canonical(await h.list("volume", instance.name)) === + canonical([volume.name]) + ); + requireValue( + ( + await h.probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--filter", + `volume=${volume.name}`, + "--format", + "{{.ID}}", + ]) + ).trim() === "" + ); + requireValue( + nativeProtectedFileRemovalMatches({ + pin: volume, + current: await h.inspect("volume", volume.name), + kind: "volume", + }) + ); + h.successful(await h.command([h.docker, "volume", "rm", volume.name])); + for (const kind of ["container", "network", "volume"] as const) { + requireValue((await h.list(kind, instance.name)).length === 0); + } + await checkSources(instance.original.sources); + await checkSources(instance.material); +} +async function removeOrdinary(h: Fixture) { + if (!h.ordinary.bootstrapped) { + return; + } + await h.fence(); + const source = join(h.ordinary.root, "material"), + withheld = join(h.ctx.tempRoot, "native-material-withheld"); + await rename(source, withheld); + try { + h.successful(await h.invoke(h.ordinary, ["down", "--recover", "--json"])); + } finally { + await rename(withheld, source); + } + await checkSources(h.ordinary.material); + const observed = await fixtureEnvironment(h, () => + observeNativeComposeFixture(h.ordinary.root) + ); + requireValue( + observed.pending === null && + observed.stopped && + observed.observed.containers.length === 0 && + observed.observed.networks.length === 0 && + observed.observed.volumes.length === 0 + ); + for (const path of h.ordinary.privateMembers ?? []) { + try { + await lstat(path); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + } +} +async function refusalBeforeEngine(h: Fixture) { + const root = join(h.ctx.tempRoot, "invalid-mode"), + name = `file-invalid-${randomBytes(4).toString("hex")}`; + await mkdir(join(root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(root, "material"), { mode: 0o700 }); + h.successful(await h.command([h.git, "init", "--quiet", root])); + const source = await createMaterial( + join(root, "material/private"), + Buffer.from("synthetic-private-refusal"), + 0o400 + ); + await writeFile( + join(root, ".hack/hack.config.json"), + JSON.stringify({ name, worktree: { inherit: false } }), + { flag: "wx", mode: 0o600 } + ); + const compose = { + name, + services: { + db: { + image: h.baseline.tags[DB_TAG], + pull_policy: "never", + environment: { + POSTGRES_HOST_AUTH_METHOD: "trust", + POSTGRES_DB: "fixture", + }, + volumes: ["data:/var/lib/postgresql/data"], + restart: "no", + stop_grace_period: "1s", + }, + reader: { + image: h.baseline.tags[BUN_TAG], + pull_policy: "never", + command: LOOP, + restart: "no", + stop_grace_period: "1s", + secrets: [{ source: "private", mode: "0444" }], + }, + }, + secrets: { private: { file: "../material/private" } }, + volumes: { data: { name: `${name}_data` } }, + }; + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify(compose), + { flag: "wx", mode: 0o600 } + ); + h.successful( + await h.command([h.git, "-C", root, "config", "user.name", "Fixture"]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "config", + "user.email", + "fixture@example.invalid", + ]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "add", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "commit", + "--quiet", + "-m", + "fixture: explicit-mode source refusal", + ]) + ); + const shimRoot = join(h.ctx.tempRoot, "no-engine-mode-refusal"), + marker = join(shimRoot, "invoked"); + await mkdir(shimRoot, { mode: 0o700 }); + await writeFile( + join(shimRoot, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';const file=await open(${JSON.stringify(marker)},'wx',0o600);await file.writeFile('unexpected-engine-query');await file.close();process.exit(98);\n`, + { flag: "wx", mode: 0o700 } + ); + const result = await h.invoke( + { root, name, marker: "invalid", material: [source], grants: [] }, + ["config", "adopt", "--dry-run", "--stop", "--json"], + undefined, + { PATH: `${shimRoot}:${h.env.PATH}` } + ); + requireValue(result.exitCode !== 0); + const report = object(result.stdout); + requireValue(report.complete === false); + try { + await lstat(marker); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + for (const path of [ + join(root, ".hack/.internal/legacy-compose-adoption-v1"), + join(root, ".hack/hack.project.json"), + ]) { + try { + await lstat(path); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + } + // Positive control changes only the explicit declaration. Same source mode/owner + // now passes the material gate and reaches the rejecting (never-forwarding) shim. + compose.services.reader.secrets[0]!.mode = "0400"; + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify(compose) + ); + const permitted = await h.invoke( + { root, name, marker: "valid-source", material: [source], grants: [] }, + ["config", "adopt", "--dry-run", "--stop", "--json"], + undefined, + { PATH: `${shimRoot}:${h.env.PATH}` } + ); + requireValue(permitted.exitCode !== 0); + requireValue((await readFile(marker, "utf8")) === "unexpected-engine-query"); + await checkSources([source]); + requireValue(canonical(await h.inventory()) === canonical(h.baseline)); +} +/** + * Explicit, synthetic-only ordinary + retained permission qualification. No ports, + * pulls, builds, global DNS/trust, caller material or broad cleanup. Every original + * remains its exact engine/ID/birth; stop/rollback precede non-forced removals. + * A failure preserves bounded private captures and cannot be converted into pass. + */ +export const nativeConfigProtectedFilesScenario: Scenario = { + name: "native-config-protected-files", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "ordinary snapshots and two original retained worktrees preserve protected guest access, readonly binds, SQL and saved recovery", + run: async (ctx) => { + const h = await setup(ctx); + try { + await runWithOwnedCleanup({ + run: async () => { + await writeFile( + join(ctx.tempRoot, "protected-intent.json"), + JSON.stringify({ + version: 1, + source: process.env.HACK_E2E_SOURCE_REVISION, + scope: + "one ordinary native and two original retained synthetic file checkouts", + replay: "refused", + }), + { mode: 0o600, flag: "wx" } + ); + await refusalBeforeEngine(h); + await ordinaryFlow(h); + await retainedFlow(h); + }, + cleanup: async () => { + await removeRetained(h, h.first); + await removeRetained(h, h.second); + await removeOrdinary(h); + await h.fence(); + requireValue( + canonical(await h.inventory()) === canonical(h.baseline) + ); + h.remaining(); + ctx.log( + "Exact stopped original cleanup and full daemon/container/network/volume-birth/image/tag baseline verified" + ); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Protected file exact cleanup is incomplete; original failure, private captures and saved anchors retained" + ), + }); + await h.fence(); + h.remaining(); + ctx.log( + "Protected-file ordinary and retained access/lifecycle qualification passed" + ); + } finally { + h.stop(); + } + }, +}; diff --git a/tests/native-config-protected-files-fixture.test.ts b/tests/native-config-protected-files-fixture.test.ts new file mode 100644 index 000000000..029e1a7fa --- /dev/null +++ b/tests/native-config-protected-files-fixture.test.ts @@ -0,0 +1,448 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runNativeFileFixtureCommand } from "./e2e/native-file-permission-command.ts"; +import { + nativeProtectedFileReadAllowed, + nativeProtectedFileStartAllowed, + nativeProtectedFileStateRefused, +} from "./e2e/native-file-permission-control.ts"; +import { + nativeFileFixtureNonowner, + readNativeFileFixtureGuest, +} from "./e2e/native-file-permission-guest.ts"; +import { + nativeProtectedFileContainerIdentity, + nativeProtectedFileRemovalMatches, +} from "./e2e/scenarios/native-config-protected-files.ts"; + +const grants = [ + { target: "/settings", mode: "0444" as const, bytes: [1] }, + { target: "/run/secrets/owner", mode: "0400" as const, bytes: [2] }, + { target: "/run/secrets/private", mode: "0600" as const, bytes: [3] }, +]; +const observed = { + version: 1, + marker: "granted-files-exact", + uid: 123, + gid: 456, + members: grants.map((row) => ({ + target: row.target, + mode: row.mode, + uid: 234, + gid: 345, + })), +}; +test("guest permission decoder binds explicit modes and observed owner/process IDs without returning content", () => { + const value = readNativeFileFixtureGuest({ + text: JSON.stringify(observed), + expected: grants, + }); + expect(value.uid).toBe(123); + expect(value.members.map((row) => row.mode)).toEqual([ + "0444", + "0400", + "0600", + ]); + expect(nativeFileFixtureNonowner(value)).toBe(65_534); + expect(JSON.stringify(value)).not.toContain('"bytes"'); +}); +test.each([ + "mode", + "target", + "uid", + "extra", + "missing", + "order", +])("guest permission decoder refuses %s", (kind) => { + const value = structuredClone(observed); + if (kind === "mode") { + value.members[1]!.mode = "0444"; + } else if (kind === "target") { + value.members[1]!.target = "/foreign"; + } else if (kind === "uid") { + value.members[1]!.uid = -1; + } else if (kind === "extra") { + Object.assign(value, { bytes: [2] }); + } else if (kind === "missing") { + value.members.pop(); + } else { + value.members.reverse(); + } + expect(() => + readNativeFileFixtureGuest({ + text: JSON.stringify(value), + expected: grants, + }) + ).toThrow(); +}); +const ids = ["a".repeat(64), "b".repeat(64), "c".repeat(64)], + prepared = { + id: "d".repeat(32), + manifest: { dev: 1, ino: 2, hash: "e".repeat(64) }, + }; +test("source refusal requires the exact closed redacted state code and message", () => { + const value = { + ok: false, + error: { + code: "E_CONFIG_INVALID", + message: + "Legacy adoption generation state is invalid, unsafe or changed; values omitted.", + }, + }; + expect(nativeProtectedFileStateRefused(value)).toBe(true); + for (const row of [ + { ...value, ok: true }, + { ...value, error: { ...value.error, code: "E_STARTUP_INCOMPLETE" } }, + { + ...value, + error: { ...value.error, message: "arbitrary source refusal" }, + }, + Object.create(value), + ]) { + expect(nativeProtectedFileStateRefused(row)).toBe(false); + } +}); +const receipt = { + adoption_receipt_version: 8, + prepared, + publication: { phase: "active", generation: prepared }, + pendingOperation: { + operation: "start", + generation: prepared, + services: ["db", "reader", "ungranted"], + }, +}; +test("interrupted start marker requires the real whole original-ID pending selection", () => + expect( + nativeProtectedFileStartAllowed({ + args: ["container", "start", ...ids], + ids, + prepared, + receipt, + }) + ).toBe(true)); +test.each([ + "no-pending", + "foreign-generation", + "wrong-operation", + "wrong-role", + "extra-id", + "force", + "duplicate-id", +])("interrupted start refuses %s before effects", (kind) => { + const value: Record = structuredClone(receipt); + const args = ["container", "start", ...ids]; + if (kind === "no-pending") { + value.pendingOperation = null; + } else if (kind === "foreign-generation") { + value.prepared = { ...prepared, id: "f".repeat(32) }; + } else if (kind === "wrong-operation") { + value.pendingOperation = { ...receipt.pendingOperation, operation: "stop" }; + } else if (kind === "wrong-role") { + value.pendingOperation = { + ...receipt.pendingOperation, + services: ["db", "reader", "foreign"], + }; + } else if (kind === "extra-id") { + args.push("f".repeat(64)); + } else if (kind === "force") { + args.splice(2, 0, "--force"); + } else { + args[3] = ids[0]!; + } + expect( + nativeProtectedFileStartAllowed({ args, ids, prepared, receipt: value }) + ).toBe(false); +}); +const scope = { + projectRoot: "/fixture", + project: "fixture", + containerIds: ids, + networkId: "e".repeat(64), + volumeName: "fixture_data", + generationId: "f".repeat(32), + reader: ids[1]!, + targets: ["/settings"], +}; +test("closed forwarder permits fixed proof stat/hash and rejects content or other effects", () => { + expect( + nativeProtectedFileReadAllowed({ + ...scope, + args: [ + "exec", + scope.reader, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + "/settings", + ], + }) + ).toBe(true); + expect( + nativeProtectedFileReadAllowed({ + ...scope, + args: ["exec", scope.reader, "sha256sum", "--", "/settings"], + }) + ).toBe(true); + for (const args of [ + ["exec", scope.reader, "cat", "/settings"], + ["exec", ids[0]!, "sha256sum", "--", "/settings"], + ["exec", scope.reader, "sha256sum", "--", "/foreign"], + ["container", "rm", "--force", scope.reader], + ["compose", "up"], + ]) { + expect(nativeProtectedFileReadAllowed({ ...scope, args })).toBe(false); + } +}); +const pin = { + id: ids[0], + createdAt: "2026-01-01T00:00:00Z", + image: `sha256:${ids[2]}`, + labels: { project: "fixture" }, + entrypoint: ["sh", "-c"], + command: ["first", "second"], + mounts: [ + { type: "bind", source: "/fixture/secret", target: "/secret", rw: false }, + { + type: "bind", + source: "/fixture/settings", + target: "/settings", + rw: false, + }, + ], + networks: [{ name: "fixture_default", id: ids[1] }], + running: true, + paused: false, + status: "running", +}; +test("stopped cleanup preserves full immutable facts and argv order", () => { + const stopped = { ...pin, running: false, status: "exited" }; + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: stopped, + }) + ).toBe(true); + for (const value of [ + { ...stopped, running: true }, + { ...stopped, createdAt: "2027-01-01" }, + { ...stopped, id: ids[1] }, + { ...stopped, command: ["second", "first"] }, + { ...stopped, mounts: [{ ...pin.mounts[0], rw: true }, pin.mounts[1]] }, + { ...stopped, networks: [{ name: "foreign", id: ids[1] }] }, + ]) { + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: value, + }) + ).toBe(false); + } + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: { ...stopped, mounts: [...stopped.mounts].reverse() }, + }) + ).toBe(true); + expect( + nativeProtectedFileContainerIdentity({ + ...pin, + mounts: [...pin.mounts].reverse(), + }) + ).toBe(nativeProtectedFileContainerIdentity(pin)); +}); +let directory: string; +beforeEach(async () => { + directory = await realpath( + await mkdtemp(join(tmpdir(), "protected-command-")) + ); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); +test("bounded command privately delivers stdin and captures a known nonzero unchanged", async () => { + const result = await runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + 'const text=await Bun.stdin.text();if(text!=="fixture-only")process.exit(99);console.log("stdin-verified");process.exit(17)', + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + stdin: Buffer.from("fixture-only"), + timeoutMs: 1000, + capturePrefix: join(directory, "result"), + }); + expect(result.exitCode).toBe(17); + expect(result.stdout.trim()).toBe("stdin-verified"); + expect(await readFile(join(directory, "result.json"), "utf8")).not.toContain( + "fixture-only" + ); +}); +test("caller mutation cannot retarget capture or change an admitted invocation", async () => { + const foreign = join(directory, "foreign"); + await mkdir(foreign, { mode: 0o700 }); + const wrong = join(directory, "wrong-child"), + input = Buffer.from("captured-input"), + signal = new AbortController(); + const script = `const bytes=await Bun.stdin.text();if(bytes!=="captured-input"||process.env.SELECTED!=="original"||process.cwd()!==${JSON.stringify(directory)})process.exit(99);await Bun.sleep(150);console.log("captured-selection");process.exit(17)`; + const opts = { + argv: [process.execPath, "-e", script], + cwd: directory, + env: { PATH: process.env.PATH ?? "", SELECTED: "original" }, + timeoutMs: 1000, + signal: signal.signal, + stdin: input, + capturePrefix: join(directory, "captured"), + }; + const pending = runNativeFileFixtureCommand(opts); + opts.argv.splice( + 0, + opts.argv.length, + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(wrong)},"wrong")` + ); + opts.env.SELECTED = "changed"; + opts.cwd = foreign; + opts.timeoutMs = 1; + input.fill(120); + opts.capturePrefix = join(foreign, "redirected"); + const other = new AbortController(); + other.abort(); + opts.signal = other.signal; + const result = await pending; + expect(result.exitCode).toBe(17); + expect(result.stdout.trim()).toBe("captured-selection"); + expect(await Bun.file(join(directory, "captured.json")).exists()).toBe(true); + expect(await Bun.file(join(foreign, "redirected.stdout")).exists()).toBe( + false + ); + expect(await Bun.file(wrong).exists()).toBe(false); +}); +test("leader exit does not disarm an owned descendant holding capture pipes", async () => { + const pidPath = join(directory, "descendant"), + late = join(directory, "late"), + child = join(directory, "child.ts"); + await writeFile( + child, + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));process.on('SIGTERM',()=>{});await Bun.sleep(2000);await Bun.write(${JSON.stringify(late)},'late');` + ); + const controller = new AbortController(), + timer = setTimeout(() => controller.abort(), 300); + try { + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `Bun.spawn([${JSON.stringify(process.execPath)},${JSON.stringify(child)}],{stdin:'ignore',stdout:'inherit',stderr:'inherit'});await Bun.sleep(150);process.exit(0)`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + signal: controller.signal, + }) + ).rejects.toThrow(); + } finally { + clearTimeout(timer); + } + const pid = Number(await readFile(pidPath, "utf8")); + const deadline = Date.now() + 2000; + while (true) { + try { + process.kill(pid, 0); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + break; + } + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(10); + } + expect(await Bun.file(late).exists()).toBe(false); +}); +test("capture overflow cancels and reaps an owned sleeping leader", async () => { + const pidPath = join(directory, "leader"); + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));try{await Bun.write(Bun.stdout,'x'.repeat(300000))}catch{};await Bun.sleep(5000)`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + }) + ).rejects.toThrow(); + const pid = Number(await readFile(pidPath, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(); + try { + process.kill(pid, 0); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + } +}); +test("publication refusal after leader and both captures settle never signals a former group", async () => { + const prefix = join(directory, "publication"); + await writeFile(`${prefix}.stdout`, "keep-existing", { + flag: "wx", + mode: 0o600, + }); + const original = process.kill, + groups: number[] = []; + const signal = spyOn(process, "kill").mockImplementation((pid, kind) => { + if (pid < 0) { + groups.push(pid); + } + return original.call(process, pid, kind); + }); + try { + await expect( + runNativeFileFixtureCommand({ + argv: [process.execPath, "-e", 'console.log("complete")'], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + capturePrefix: prefix, + }) + ).rejects.toThrow(); + } finally { + signal.mockRestore(); + } + expect(groups).toEqual([]); + expect(await readFile(`${prefix}.stdout`, "utf8")).toBe("keep-existing"); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); +}); +test("pre-cancelled command refuses before spawning or publishing", async () => { + const marker = join(directory, "never"), + controller = new AbortController(); + controller.abort(); + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(marker)},"unexpected")`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + signal: controller.signal, + }) + ).rejects.toThrow(); + expect(await Bun.file(marker).exists()).toBe(false); +}); From dbfb022f89b6c8c2a53e39e27afb2bcc35a8e734 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:37:10 -0400 Subject: [PATCH 08/23] test(native): bind protected fixture socket through owner --- src/lib/native-compose-engine-identity.ts | 32 ++++++++ .../native-config-protected-files.ts | 10 ++- tests/native-compose-engine-identity.test.ts | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/lib/native-compose-engine-identity.ts b/src/lib/native-compose-engine-identity.ts index 06e710b81..6682491f4 100644 --- a/src/lib/native-compose-engine-identity.ts +++ b/src/lib/native-compose-engine-identity.ts @@ -115,6 +115,38 @@ function bindPath(path: string, kind: "socket" | "executable"): Binding { function sameBinding(left: Binding, right: Binding): boolean { return JSON.stringify(left) === JSON.stringify(right); } +/** + * Pure filesystem binding only: this neither admits an engine nor performs a + * probe. Reuses the transport owner's bounded component walk because Bun's + * async realpath rejects a Unix-socket leaf on macOS. All named ancestor/link + * and final socket identity facts are rechecked by assertFresh. + */ +export function bindNativeComposeEngineSocketPath(path: string): { + readonly path: string; + readonly assertFresh: () => void; +} { + try { + requireValue( + typeof path === "string" && + !AMBIGUOUS_PATH.test(path) && + normalize(path) === path + ); + const bound = bindPath(path, "socket"); + return Object.freeze({ + path: bound.canonical, + assertFresh: () => { + try { + requireValue(sameBinding(bound, bindPath(path, "socket"))); + } catch { + refuse(); + } + }, + }); + } catch { + return refuse(); + } +} + function check(signal: AbortSignal | undefined, deadline: number): void { requireValue(!signal?.aborted && Date.now() < deadline); } diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index adb793578..6fea81fa6 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -12,6 +12,7 @@ import { import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { isRecord } from "../../../src/lib/guards.ts"; +import { bindNativeComposeEngineSocketPath } from "../../../src/lib/native-compose-engine-identity.ts"; import { parseNativeComposeFileReference } from "../../../src/lib/native-compose-file-state.ts"; import { openNativeComposeGenerationStore } from "../../../src/lib/native-compose-generation.ts"; import { @@ -401,7 +402,10 @@ async function setup(ctx: ScenarioContext) { requireValue( typeof requestedSocket === "string" && requestedSocket.startsWith("unix://") ); - const socket = await realpath(requestedSocket.slice(7)), + const socketBinding = bindNativeComposeEngineSocketPath( + requestedSocket.slice(7) + ); + const socket = socketBinding.path, socketInfo = await lstat(socket); requireValue(socketInfo.isSocket()); const artifacts = await Promise.all( @@ -496,13 +500,15 @@ async function setup(ctx: ScenarioContext) { }; const fence = async () => { remaining(); + socketBinding.assertFresh(); const currentSocket = await lstat(socket); requireValue( currentSocket.isSocket() && currentSocket.dev === socketInfo.dev && currentSocket.ino === socketInfo.ino && currentSocket.mode === socketInfo.mode && - (await realpath(socket)) === socket + currentSocket.uid === socketInfo.uid && + currentSocket.gid === socketInfo.gid ); for (const pin of artifacts) { requireValue((await realpath(pin.path)) === pin.physical); diff --git a/tests/native-compose-engine-identity.test.ts b/tests/native-compose-engine-identity.test.ts index 91d76a6b5..e42757772 100644 --- a/tests/native-compose-engine-identity.test.ts +++ b/tests/native-compose-engine-identity.test.ts @@ -22,6 +22,7 @@ import type { Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + bindNativeComposeEngineSocketPath, createNativeComposeEngineIdentityObserver, NativeComposeEngineIdentityError, } from "../src/lib/native-compose-engine-identity.ts"; @@ -584,3 +585,75 @@ else console.log(JSON.stringify({id:${JSON.stringify(PROXY)}, project:"hack-dev- } } }); + +test("pure socket path binding accepts a real Unix leaf without querying an engine or spawning", () => { + const spawn = spyOn(Bun, "spawn"); + try { + const bound = bindNativeComposeEngineSocketPath(socket); + expect(bound.path).toBe(socket); + expect(() => bound.assertFresh()).not.toThrow(); + expect(Object.isFrozen(bound)).toBe(true); + expect(Object.keys(bound)).toEqual(["path", "assertFresh"]); + expect(requests).toHaveLength(0); + expect(connections.size).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + } finally { + spawn.mockRestore(); + } +}); +test("pure socket path binding pins alias identity and refuses retargeting before a query", async () => { + const other = join(root, "other.sock"), + alias = join(root, "pure-alias.sock"); + await listen(other); + await symlink("engine.sock", alias); + const bound = bindNativeComposeEngineSocketPath(alias); + expect(bound.path).toBe(socket); + await unlink(alias); + await symlink("other.sock", alias); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding refuses ancestor rebinding even to the same target", async () => { + const alias = join(root, "pure-parent"); + await symlink(root, alias); + const bound = bindNativeComposeEngineSocketPath(join(alias, "engine.sock")); + await unlink(alias); + await symlink(root, alias); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding refuses physical replacement and unchanged-name wrong type", async () => { + const bound = bindNativeComposeEngineSocketPath(socket); + await rename(socket, join(root, "pure-original.sock")); + await listen(socket); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + const replacement = bindNativeComposeEngineSocketPath(socket); + await rename(socket, join(root, "pure-replacement.sock")); + await writeFile(socket, "not a socket", { mode: 0o600 }); + expect(() => replacement.assertFresh()).toThrow( + NativeComposeEngineIdentityError + ); + expect(() => bindNativeComposeEngineSocketPath(socket)).toThrow( + NativeComposeEngineIdentityError + ); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding pins mode and refuses ambiguous or relative selection", async () => { + const bound = bindNativeComposeEngineSocketPath(socket); + await chmod(socket, 0o600); + const changed = bindNativeComposeEngineSocketPath(socket); + await chmod(socket, 0o660); + expect(() => changed.assertFresh()).toThrow(NativeComposeEngineIdentityError); + for (const path of [ + "engine.sock", + `${socket}?query`, + `${socket}\0`, + join(root, "missing.sock"), + ]) { + expect(() => bindNativeComposeEngineSocketPath(path)).toThrow( + NativeComposeEngineIdentityError + ); + } + expect(bound.path).toBe(socket); + expect(requests).toHaveLength(0); +}); From 8a57ac157a1f77c8a5e5ebaa0b8bbe9b3ce3f765 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:37:10 -0400 Subject: [PATCH 09/23] test(native): budget protected snapshot lifecycle --- tests/native-compose-file-owner.test.ts | 281 ++++++++++++------------ 1 file changed, 145 insertions(+), 136 deletions(-) diff --git a/tests/native-compose-file-owner.test.ts b/tests/native-compose-file-owner.test.ts index 97c558b8a..2733c9c52 100644 --- a/tests/native-compose-file-owner.test.ts +++ b/tests/native-compose-file-owner.test.ts @@ -1430,153 +1430,162 @@ test("known reaped readiness failure retains pending material until a separate v test.each([ ["0400", 0o400], ["0600", 0o600], -] as const)("protected %s delivery preserves original permissions and binds exact private snapshot2 modes", async (mode, bits) => { - const secretPath = join(root, "protected.bin"); - await writeFile(secretPath, BYTES, { mode: bits }); - const original = await lstat(secretPath); - await writeFile( - join(root, ".hack/hack.project.json"), - JSON.stringify({ - ...SOURCE, - secrets: { empty: { file: "protected.bin" } }, - services: { - reader: { - ...SOURCE.services.reader, - mounts: [ - SOURCE.services.reader.mounts[0], - { - secret: "empty", - target: "/run/empty", - access: "read-only", - mode, - }, - ], +] as const)( + "protected %s delivery preserves original permissions and binds exact private snapshot2 modes", + async (mode, bits) => { + const secretPath = join(root, "protected.bin"); + await writeFile(secretPath, BYTES, { mode: bits }); + const original = await lstat(secretPath); + await writeFile( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + ...SOURCE, + secrets: { empty: { file: "protected.bin" } }, + services: { + reader: { + ...SOURCE.services.reader, + mounts: [ + SOURCE.services.reader.mounts[0], + { + secret: "empty", + target: "/run/empty", + access: "read-only", + mode, + }, + ], + }, }, - }, - }) - ); - const selected = await store.withMutation(running); - expect(selected.projection.reference.version).toBe(2); - const granted = selected.projection.workloads.reader; - const secret = granted?.find((grant) => grant.target === "/run/empty"); - const config = granted?.find((grant) => grant.target === "/etc/settings"); - if (!(secret && config)) { - throw new Error("Missing selected file grants"); - } - expect(await readFile(secret.source)).toEqual(BYTES); - expect((await lstat(secret.source)).mode & 0o777).toBe(bits); - expect((await lstat(config.source)).mode & 0o777).toBe(0o444); - const after = await lstat(secretPath); - expect({ - dev: after.dev, - ino: after.ino, - mode: after.mode, - uid: after.uid, - gid: after.gid, - }).toEqual({ - dev: original.dev, - ino: original.ino, - mode: original.mode, - uid: original.uid, - gid: original.gid, - }); - const reference = selected.projection.reference; - const text = await readFile( - join( - reference.root, - `${reference.generationId}-${reference.snapshotToken}`, - "manifest.json" - ), - "utf8" - ); - const raw: unknown = JSON.parse(text); - if (!(isRecord(raw) && isRecord(raw.creation))) { - throw new Error("Missing owned material binding"); - } - // This binding comes from the genuine owner-produced private manifest; parsing - // below verifies it and no external effect authority is obtained from this fixture. - const binding = raw.creation as NativeComposeMaterialBinding; - const manifest = parseNativeComposeFileManifest({ text, reference, binding }); - expect(manifest.version).toBe(2); - expect( - manifest.members.find((member) => member.target === "/run/empty")?.file.mode - ).toBe(bits); - for (const changed of [0o000, 0o644, 0o777, "0400", null]) { - const forged = structuredClone(manifest); - const originalMembers = forged.members.map((member) => ({ - ...member, - file: { ...member.file }, - })); - const member = originalMembers.find( - (entry) => entry.target === "/run/empty" + }) ); - if (!member) { - throw new Error("Missing owned secret member"); + const selected = await store.withMutation(running); + expect(selected.projection.reference.version).toBe(2); + const granted = selected.projection.workloads.reader; + const secret = granted?.find((grant) => grant.target === "/run/empty"); + const config = granted?.find((grant) => grant.target === "/etc/settings"); + if (!(secret && config)) { + throw new Error("Missing selected file grants"); } - const altered = { - ...forged, - members: originalMembers.map((entry) => - entry === member - ? { ...entry, file: { ...entry.file, mode: changed } } - : entry + expect(await readFile(secret.source)).toEqual(BYTES); + expect((await lstat(secret.source)).mode & 0o777).toBe(bits); + expect((await lstat(config.source)).mode & 0o777).toBe(0o444); + const after = await lstat(secretPath); + expect({ + dev: after.dev, + ino: after.ino, + mode: after.mode, + uid: after.uid, + gid: after.gid, + }).toEqual({ + dev: original.dev, + ino: original.ino, + mode: original.mode, + uid: original.uid, + gid: original.gid, + }); + const reference = selected.projection.reference; + const text = await readFile( + join( + reference.root, + `${reference.generationId}-${reference.snapshotToken}`, + "manifest.json" ), + "utf8" + ); + const raw: unknown = JSON.parse(text); + if (!(isRecord(raw) && isRecord(raw.creation))) { + throw new Error("Missing owned material binding"); + } + // This binding comes from the genuine owner-produced private manifest; parsing + // below verifies it and no external effect authority is obtained from this fixture. + const binding = raw.creation as NativeComposeMaterialBinding; + const manifest = parseNativeComposeFileManifest({ + text, + reference, + binding, + }); + expect(manifest.version).toBe(2); + expect( + manifest.members.find((member) => member.target === "/run/empty")?.file + .mode + ).toBe(bits); + for (const changed of [0o000, 0o644, 0o777, "0400", null]) { + const forged = structuredClone(manifest); + const originalMembers = forged.members.map((member) => ({ + ...member, + file: { ...member.file }, + })); + const member = originalMembers.find( + (entry) => entry.target === "/run/empty" + ); + if (!member) { + throw new Error("Missing owned secret member"); + } + const altered = { + ...forged, + members: originalMembers.map((entry) => + entry === member + ? { ...entry, file: { ...entry.file, mode: changed } } + : entry + ), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(altered), + reference, + binding, + }) + ).toThrow(); + } + const legacy = { + ...manifest, + version: 1, + reference: { ...manifest.reference, version: 1 }, }; expect(() => parseNativeComposeFileManifest({ - text: JSON.stringify(altered), - reference, + text: JSON.stringify(legacy), + reference: { ...reference, version: 1 }, binding, }) ).toThrow(); - } - const legacy = { - ...manifest, - version: 1, - reference: { ...manifest.reference, version: 1 }, - }; - expect(() => - parseNativeComposeFileManifest({ - text: JSON.stringify(legacy), - reference: { ...reference, version: 1 }, - binding, - }) - ).toThrow(); - const allPublic = { - ...manifest, - members: manifest.members.map((member) => ({ - ...member, - file: { ...member.file, mode: 0o444 }, - })), - }; - expect(() => - parseNativeComposeFileManifest({ - text: JSON.stringify(allPublic), - reference, - binding, - }) - ).toThrow(); - expect(() => - parseNativeComposeFileReference({ ...reference, version: 3 }) - ).toThrow(); - const unknownVersion = { - ...manifest, - version: 3, - reference: { ...manifest.reference, version: 3 }, - }; - expect(() => - Reflect.apply(parseNativeComposeFileManifest, undefined, [ - { - text: JSON.stringify(unknownVersion), - reference: { ...reference, version: 3 }, + const allPublic = { + ...manifest, + members: manifest.members.map((member) => ({ + ...member, + file: { ...member.file, mode: 0o444 }, + })), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(allPublic), + reference, binding, - }, - ]) - ).toThrow(); - await store.withMutation(async (mutation) => { - await ownerFor(mutation).assertSavedReady(selected.generation); - }); - expect(JSON.stringify(selected.attempt)).toBe("{}"); -}); + }) + ).toThrow(); + expect(() => + parseNativeComposeFileReference({ ...reference, version: 3 }) + ).toThrow(); + const unknownVersion = { + ...manifest, + version: 3, + reference: { ...manifest.reference, version: 3 }, + }; + expect(() => + Reflect.apply(parseNativeComposeFileManifest, undefined, [ + { + text: JSON.stringify(unknownVersion), + reference: { ...reference, version: 3 }, + binding, + }, + ]) + ).toThrow(); + await store.withMutation(async (mutation) => { + await ownerFor(mutation).assertSavedReady(selected.generation); + }); + expect(JSON.stringify(selected.attempt)).toBe("{}"); + }, + 30_000 +); test("protected snapshot mode drift refuses saved readiness while preserving owned stop recovery", async () => { await writeFile(join(root, "protected.bin"), BYTES, { mode: 0o600 }); From b7f0b5a1a0e6c07ab84f8ee8199a47c6af8306c8 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:30:07 -0400 Subject: [PATCH 10/23] test(native): fence canonical macOS Git fixture identity --- tests/e2e/README.md | 3 + tests/e2e/native-file-permission-control.ts | 36 ++++++++++ .../native-config-protected-files.ts | 48 ++++++++++---- ...ive-config-protected-files-fixture.test.ts | 65 +++++++++++++++++++ 4 files changed, 139 insertions(+), 13 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 92b303ffc..685d2020a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -378,3 +378,6 @@ and retained; there is no general cache prune or cache reclamation claim. Failed exact cleanup retains the private fixture and recovery identities. Host DNS/trust, registry network denial and advanced builder features are separate gates. + + +The protected-file selector admits Darwin's exact root-owned `/usr/bin/git` with positive shared system link count, while every private artifact and alternate Git selection remains single-link. It pins and rechecks that exact link count, owner IDs, physical path, mode, inode and bytes throughout the attempt. This prerequisite does not add engine authority or prove guest/lifecycle acceptance. diff --git a/tests/e2e/native-file-permission-control.ts b/tests/e2e/native-file-permission-control.ts index 23f5272de..7267552e6 100644 --- a/tests/e2e/native-file-permission-control.ts +++ b/tests/e2e/native-file-permission-control.ts @@ -94,3 +94,39 @@ export function nativeProtectedFileStartAllowed(opts: { [...pending.services].sort().join() === "db,reader,ungranted" ); } + +/** Only Darwin's canonical root-owned OS Git may have shared system hard links. Private copies remain single-link. */ +export function nativeProtectedFileToolAllowed(opts: { + readonly role: "artifact" | "git"; + readonly platform: string; + readonly selected: string; + readonly physical: string; + readonly regular: boolean; + readonly symlink: boolean; + readonly uid: number; + readonly mode: number; + readonly nlink: number; +}): boolean { + if ( + !( + opts.regular && + !opts.symlink && + (opts.mode & 0o111) !== 0 && + Number.isSafeInteger(opts.nlink) && + opts.nlink > 0 + ) + ) { + return false; + } + if (opts.nlink === 1) { + return true; + } + return ( + opts.role === "git" && + opts.platform === "darwin" && + opts.selected === "/usr/bin/git" && + opts.physical === "/usr/bin/git" && + opts.uid === 0 && + (opts.mode & 0o022) === 0 + ); +} diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index 6fea81fa6..39c5801d2 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -30,7 +30,10 @@ import { type NativeFileFixtureCommandResult, runNativeFileFixtureCommand, } from "../native-file-permission-command.ts"; -import { nativeProtectedFileStateRefused } from "../native-file-permission-control.ts"; +import { + nativeProtectedFileStateRefused, + nativeProtectedFileToolAllowed, +} from "../native-file-permission-control.ts"; import { type NativeFileFixtureGrant, nativeFileFixtureAbsentProgram, @@ -409,22 +412,38 @@ async function setup(ctx: ScenarioContext) { socketInfo = await lstat(socket); requireValue(socketInfo.isSocket()); const artifacts = await Promise.all( - [cli, compiler, docker, git, process.execPath].map(async (path) => ({ - path, - physical: await realpath(path), - info: await lstat(await realpath(path)), - hash: hash(await readFile(await realpath(path))), - })) + [ + { path: cli, role: "artifact" as const }, + { path: compiler, role: "artifact" as const }, + { path: docker, role: "artifact" as const }, + { path: git, role: "git" as const }, + { path: process.execPath, role: "artifact" as const }, + ].map(async ({ path, role }) => { + const physical = await realpath(path); + return { + path, + role, + physical, + info: await lstat(physical), + hash: hash(await readFile(physical)), + }; + }) ); requireValue( artifacts[0]?.hash === expectedCli && artifacts[1]?.hash === expectedCompiler && - artifacts.every( - (pin) => - pin.info.isFile() && - !pin.info.isSymbolicLink() && - pin.info.nlink === 1 && - (pin.info.mode & 0o111) !== 0 + artifacts.every((pin) => + nativeProtectedFileToolAllowed({ + role: pin.role, + platform: process.platform, + selected: pin.path, + physical: pin.physical, + regular: pin.info.isFile(), + symlink: pin.info.isSymbolicLink(), + uid: pin.info.uid, + mode: pin.info.mode, + nlink: pin.info.nlink, + }) ) ); const home = join(ctx.tempRoot, "protected-home"), @@ -518,6 +537,9 @@ async function setup(ctx: ScenarioContext) { latest.ino === pin.info.ino && latest.size === pin.info.size && latest.mode === pin.info.mode && + latest.uid === pin.info.uid && + latest.gid === pin.info.gid && + latest.nlink === pin.info.nlink && hash(await readFile(pin.physical)) === pin.hash ); } diff --git a/tests/native-config-protected-files-fixture.test.ts b/tests/native-config-protected-files-fixture.test.ts index 029e1a7fa..127499efa 100644 --- a/tests/native-config-protected-files-fixture.test.ts +++ b/tests/native-config-protected-files-fixture.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { + lstat, mkdir, mkdtemp, readFile, @@ -14,6 +15,7 @@ import { nativeProtectedFileReadAllowed, nativeProtectedFileStartAllowed, nativeProtectedFileStateRefused, + nativeProtectedFileToolAllowed, } from "./e2e/native-file-permission-control.ts"; import { nativeFileFixtureNonowner, @@ -446,3 +448,66 @@ test("pre-cancelled command refuses before spawning or publishing", async () => ).rejects.toThrow(); expect(await Bun.file(marker).exists()).toBe(false); }); + +const systemGit = { + role: "git" as const, + platform: "darwin", + selected: "/usr/bin/git", + physical: "/usr/bin/git", + regular: true, + symlink: false, + uid: 0, + mode: 0o755, + nlink: 78, +}; +test("canonical root-owned Darwin Git admits positive shared links while private and alternate tools stay single-link", () => { + expect(nativeProtectedFileToolAllowed(systemGit)).toBe(true); + expect( + nativeProtectedFileToolAllowed({ ...systemGit, role: "artifact", nlink: 1 }) + ).toBe(true); + for (const changed of [ + { role: "artifact" as const }, + { platform: "linux" }, + { selected: "/tmp/git" }, + { physical: "/tmp/git" }, + { uid: 123 }, + { mode: 0o775 }, + { mode: 0o777 }, + { mode: 0o644 }, + { nlink: 0 }, + { nlink: -1 }, + { nlink: Number.NaN }, + { nlink: 1.5 }, + { regular: false }, + { symlink: true }, + ]) { + expect(nativeProtectedFileToolAllowed({ ...systemGit, ...changed })).toBe( + false + ); + } +}); +test.skipIf(process.platform !== "darwin")( + "canonical OS Git filesystem correspondence preserves the old single-link RED", + async () => { + const info = await lstat("/usr/bin/git"), + physical = await realpath("/usr/bin/git"); + expect( + nativeProtectedFileToolAllowed({ + role: "git", + platform: process.platform, + selected: "/usr/bin/git", + physical, + regular: info.isFile(), + symlink: info.isSymbolicLink(), + uid: info.uid, + mode: info.mode, + nlink: info.nlink, + }) + ).toBe(true); + expect(info.uid).toBe(0); + expect(info.mode & 0o022).toBe(0); + if (info.nlink > 1) { + expect(info.nlink === 1).toBe(false); + } + } +); From 1aa67ac0ac10eb89a44c14db83bf6b37a7a9120b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 21:23:20 -0400 Subject: [PATCH 11/23] fix(native): observe unnamed retained file binds safely --- docs/reference/native-compose-adoption.md | 4 +- src/lib/native-compose-adoption-binding.ts | 5 +- tests/helpers/docker-container-format.ts | 201 ++++++++++++++++++ tests/native-compose-adoption-binding.test.ts | 12 ++ ...tive-compose-adoption-mount-format.test.ts | 148 +++++++++++++ 5 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 tests/helpers/docker-container-format.ts create mode 100644 tests/native-compose-adoption-mount-format.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index e46e914fb..13e2769ad 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -307,7 +307,9 @@ locals remain refused by this file family before material or engine acquisition. Unused declarations do not grant access or authorize material reads. Preparation checks every original bind's exact source path, target and read-only -flag alongside the existing original resource identities. Descriptor-held reads +flag alongside the existing original resource identities. Docker may omit a +bind's `Name` field; its observation projects that absence as the exact empty +name. Named volumes still require their literal verified name. Descriptor-held reads require canonical owned directories and regular, single-link source files; no symlink, hardlink, path escape, source replacement or unsafe writable material is adopted. It never rewrites or chmods the original. Private source facts retain diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index d0df6250d..6713805f8 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -56,7 +56,7 @@ const ROUTING = [ const formats = { container: { list: `{"id":{{json .ID}},"name":{{json .Names}},"project":{{json (.Label "${PROJECT}")}}}`, - inspect: `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT}")}},"native":{{json (index .Config.Labels "${VERSION}")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"number":{{json (index .Config.Labels "com.docker.compose.container-number")}},"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},"running":{{json .State.Running}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i, $m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"source":{{json $m.Source}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}],"networks":[{{$first := true}}{{range $name, $n := .NetworkSettings.Networks}}{{if not $first}},{{end}}{{$first = false}}{"name":{{json $name}},"id":{{json $n.NetworkID}}}{{end}}]}`, + inspect: `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT}")}},"native":{{json (index .Config.Labels "${VERSION}")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"number":{{json (index .Config.Labels "com.docker.compose.container-number")}},"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},"running":{{json .State.Running}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i, $m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{$name := ""}}{{range $key, $value := $m}}{{if eq $key "Name"}}{{$name = $value}}{{end}}{{end}}{{json $name}},"source":{{json $m.Source}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}],"networks":[{{$first := true}}{{range $name, $n := .NetworkSettings.Networks}}{{if not $first}},{{end}}{{$first = false}}{"name":{{json $name}},"id":{{json $n.NetworkID}}}{{end}}]}`, }, volume: { list: `{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (.Label "${PROJECT}")}}}`, @@ -67,6 +67,9 @@ const formats = { inspect: `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Labels "${PROJECT}")}},"native":{{json (index .Labels "${VERSION}")}},"logical":{{json (index .Labels "com.docker.compose.network")}},"createdAt":{{json .Created}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}},"containers":[{{$first := true}}{{range $id, $c := .Containers}}{{if not $first}},{{end}}{{$first = false}}{{json $id}}{{end}}]}`, }, } as const; +/** Observation text only: only omitted bind Name defaults empty; present values stay literal. */ +export const legacyComposeAdoptionContainerInspectFormat = + formats.container.inspect; const CUSTOM_CONTAINER_NETWORK_FORMAT = formats.container.inspect.replace( '"id":{{json $n.NetworkID}}}', '"id":{{json $n.NetworkID}},"aliases":{{json $n.Aliases}}}' diff --git a/tests/helpers/docker-container-format.ts b/tests/helpers/docker-container-format.ts new file mode 100644 index 000000000..0207f55d8 --- /dev/null +++ b/tests/helpers/docker-container-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_CONTAINER_ID = "a".repeat(64); +const HASH = /^[a-f0-9]{64}$/; +const INSPECT_PATH = `/v1.41/containers/${DOCKER_FORMAT_CONTAINER_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 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 withDockerContainerFormatFixture(opts: { + readonly binary: string; + readonly sha256: string; + readonly container: Readonly>; + readonly observe: (probe: (format: string) => Promise) => Promise; +}): Promise { + const binary = opts.binary, + sha256 = opts.sha256, + response = JSON.stringify(opts.container), + 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([ + "container", + "inspect", + "--format", + format, + DOCKER_FORMAT_CONTAINER_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-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index 2e2672826..a826b62a6 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -648,6 +648,18 @@ test.each([ await save(); await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); }); +test.each([ + undefined, + "", +])("a named volume cannot acquire an absent or empty observed name: %j", async (name) => { + const mount = container().mounts; + if (!(Array.isArray(mount) && mount[0])) { + throw new Error("Missing synthetic retained mount"); + } + mount[0].name = name; + await save(); + await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); +}); test.each([ "container", "network", diff --git a/tests/native-compose-adoption-mount-format.test.ts b/tests/native-compose-adoption-mount-format.test.ts new file mode 100644 index 000000000..6c37a026b --- /dev/null +++ b/tests/native-compose-adoption-mount-format.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { isRecord } from "../src/lib/guards.ts"; +import { legacyComposeAdoptionContainerInspectFormat } from "../src/lib/native-compose-adoption-binding.ts"; +import { nativeComposeProbeFailure } from "../src/lib/native-compose-ownership.ts"; +import { + DOCKER_FORMAT_CONTAINER_ID, + withDockerContainerFormatFixture, +} from "./helpers/docker-container-format.ts"; + +const SYNTHETIC_CONTAINER = { + Id: DOCKER_FORMAT_CONTAINER_ID, + Name: "/synthetic-db-1", + Config: { + Labels: { + "com.docker.compose.project": "synthetic", + "com.docker.compose.service": "db", + "com.docker.compose.container-number": "1", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project.working_dir": "/synthetic/.hack", + "com.docker.compose.project.config_files": + "/synthetic/.hack/docker-compose.yml", + }, + }, + State: { Running: true }, + Mounts: [ + { + Type: "bind", + Source: "/synthetic/config", + Destination: "/config", + RW: false, + }, + { + Type: "volume", + Name: "synthetic_data", + Source: "/var/lib/docker/volumes/synthetic_data/_data", + Destination: "/data", + RW: true, + }, + ], + NetworkSettings: { + Networks: { synthetic_default: { NetworkID: "b".repeat(64) } }, + }, +}; +const binary = process.env.HACK_TEST_DOCKER_FORMAT_BINARY, + sha256 = process.env.HACK_TEST_DOCKER_FORMAT_SHA256; +test.skipIf(binary === undefined && sha256 === undefined)( + "real pinned Docker formatter accepts omitted bind Name while preserving volume name", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerContainerFormatFixture({ + binary, + sha256, + container: SYNTHETIC_CONTAINER, + observe: async (probe) => { + const old = legacyComposeAdoptionContainerInspectFormat.replace( + '{{$name := ""}}{{range $key, $value := $m}}{{if eq $key "Name"}}{{$name = $value}}{{end}}{{end}}{{json $name}}', + "{{json $m.Name}}" + ); + expect(old).not.toBe(legacyComposeAdoptionContainerInspectFormat); + let failure: unknown; + try { + await probe(old); + } catch (error: unknown) { + failure = error; + } + expect(nativeComposeProbeFailure(failure)).toBe("child"); + const value: unknown = JSON.parse( + await probe(legacyComposeAdoptionContainerInspectFormat) + ); + if (!isRecord(value)) { + throw new Error("Synthetic container projection is malformed"); + } + expect(value.id).toBe(DOCKER_FORMAT_CONTAINER_ID); + expect(value.mounts).toEqual([ + { + type: "bind", + name: "", + source: "/synthetic/config", + target: "/config", + rw: false, + }, + { + type: "volume", + name: "synthetic_data", + source: "/var/lib/docker/volumes/synthetic_data/_data", + target: "/data", + rw: true, + }, + ]); + }, + }); + }, + 20_000 +); +test + .skipIf(binary === undefined && sha256 === undefined) + .each([ + { name: false }, + { name: 0 }, + { name: null }, + { name: [] }, + { name: {} }, + ])( + "real client preserves or refuses malformed bind Name without empty normalization: %j", + async ({ name }) => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + const container = { + ...SYNTHETIC_CONTAINER, + Mounts: [{ ...SYNTHETIC_CONTAINER.Mounts[0], Name: name }], + }; + await withDockerContainerFormatFixture({ + binary, + sha256, + container, + observe: async (probe) => { + let text: string; + try { + text = await probe(legacyComposeAdoptionContainerInspectFormat); + } catch (error: unknown) { + // Null must reach the presence-specific template. Other malformed + // types may be refused by the client before returning a projection. + if (name === null) { + throw error; + } + expect(nativeComposeProbeFailure(error)).toBe("child"); + return; + } + const value: unknown = JSON.parse(text); + if ( + !( + isRecord(value) && + Array.isArray(value.mounts) && + isRecord(value.mounts[0]) + ) + ) { + throw new Error("Synthetic container projection is malformed"); + } + expect(value.mounts[0].name).toEqual(name); + expect(value.mounts[0].name).not.toBe(""); + }, + }); + }, + 20_000 +); From c5d1b551947e0cf9049a950ad1bfecd2f3c891e3 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:01:52 -0400 Subject: [PATCH 12/23] test: admit the closed retained-file fixture sources --- tests/e2e/README.md | 8 + .../native-config-protected-files.ts | 100 +++++++--- tests/native-config-protected-source.test.ts | 188 ++++++++++++++++++ 3 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 tests/native-config-protected-source.test.ts diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 63b05dfc4..5dc305a82 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -190,6 +190,14 @@ pins after source and artifact qualification; the selector does not manufacture the grant or use caller Docker credentials. It publishes no ports and performs no pulls, builds, DNS changes or trust changes. +Retained fixture inputs use only the closed file8 source subset: no worktree +policy or authored pull policy. Cached image selection is fenced independently, +and the controlled Compose bootstrap keeps its fixed `--pull never` option. +The pre-effect mode control first refuses an explicit 0444 grant on its owned +0400 source, then changes only the declaration to 0400 and must reach the +rejecting, never-forwarding Docker shim. Canonical mapper and shim controls cover +this prerequisite separately from guest/runtime acceptance. + The ordinary native fixture checks snapshot version 2. Two real linked retained checkouts then bind their original readonly sources, observed guest UID/GID and 0400/0600 mode, original container/bridge/volume identities and distinct SQL diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index 1a4aff407..ef578769f 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -263,13 +263,28 @@ export function nativeProtectedFileRemovalMatches(opts: { } return canonical(opts.pin) === canonical(opts.current); } -function legacy(instance: Checkout, bun: string, db: string) { +/** Authored retained inputs stay inside the shipped file8 family; cached-image admission uses fixed --pull never. */ +export function nativeProtectedFileRetainedInputs(opts: { + readonly instance: Pick; + readonly bun: string; + readonly db: string; +}) { + const { instance, bun, db } = opts; + return { + config: { name: instance.name }, + compose: legacy(instance, bun, db), + }; +} +function legacy( + instance: Pick, + bun: string, + db: string +) { return { name: instance.name, services: { db: { image: db, - pull_policy: "never", environment: { POSTGRES_HOST_AUTH_METHOD: "trust", POSTGRES_DB: "fixture", @@ -280,7 +295,6 @@ function legacy(instance: Checkout, bun: string, db: string) { }, reader: { image: bun, - pull_policy: "never", command: LOOP, restart: "no", stop_grace_period: "1s", @@ -302,7 +316,6 @@ function legacy(instance: Checkout, bun: string, db: string) { }, ungranted: { image: bun, - pull_policy: "never", command: LOOP, restart: "no", stop_grace_period: "1s", @@ -316,6 +329,40 @@ function legacy(instance: Checkout, bun: string, db: string) { volumes: { data: { name: `${instance.name}_data` } }, }; } +/** Same controlled source for the mismatched-mode negative and legal0400 engine tripwire. */ +export function nativeProtectedFileModeRefusalInputs(opts: { + readonly name: string; + readonly bun: string; + readonly db: string; +}) { + return { + config: { name: opts.name }, + compose: { + name: opts.name, + services: { + db: { + image: opts.db, + environment: { + POSTGRES_HOST_AUTH_METHOD: "trust", + POSTGRES_DB: "fixture", + }, + volumes: ["data:/var/lib/postgresql/data"], + restart: "no", + stop_grace_period: "1s", + }, + reader: { + image: opts.bun, + command: LOOP, + restart: "no", + stop_grace_period: "1s", + secrets: [{ source: "private", mode: "0444" }], + }, + }, + secrets: { private: { file: "../material/private" } }, + volumes: { data: { name: `${opts.name}_data` } }, + }, + }; +} function native(instance: Checkout, bun: string) { return { schema_version: 1, @@ -782,14 +829,19 @@ async function setup(ctx: ScenarioContext) { const first = await make(primaryRoot, `file-first-${suffix}`, "first"), second = await make(secondRoot, `file-second-${suffix}`, "second"); for (const instance of [first, second]) { + const inputs = nativeProtectedFileRetainedInputs({ + instance, + bun: tags[BUN_TAG], + db: tags[DB_TAG], + }); await writeFile( join(instance.root, ".hack/hack.config.json"), - JSON.stringify({ name: instance.name, worktree: { inherit: false } }), + JSON.stringify(inputs.config), { mode: 0o600, flag: "wx" } ); await writeFile( join(instance.root, ".hack/docker-compose.yml"), - JSON.stringify(legacy(instance, tags[BUN_TAG], tags[DB_TAG])), + JSON.stringify(inputs.compose), { mode: 0o600, flag: "wx" } ); successful( @@ -1842,37 +1894,19 @@ async function refusalBeforeEngine(h: Fixture) { Buffer.from("synthetic-private-refusal"), 0o400 ); + const bun = h.baseline.tags[BUN_TAG], + db = h.baseline.tags[DB_TAG]; + requireValue(typeof bun === "string" && typeof db === "string"); + const { config, compose } = nativeProtectedFileModeRefusalInputs({ + name, + bun, + db, + }); await writeFile( join(root, ".hack/hack.config.json"), - JSON.stringify({ name, worktree: { inherit: false } }), + JSON.stringify(config), { flag: "wx", mode: 0o600 } ); - const compose = { - name, - services: { - db: { - image: h.baseline.tags[DB_TAG], - pull_policy: "never", - environment: { - POSTGRES_HOST_AUTH_METHOD: "trust", - POSTGRES_DB: "fixture", - }, - volumes: ["data:/var/lib/postgresql/data"], - restart: "no", - stop_grace_period: "1s", - }, - reader: { - image: h.baseline.tags[BUN_TAG], - pull_policy: "never", - command: LOOP, - restart: "no", - stop_grace_period: "1s", - secrets: [{ source: "private", mode: "0444" }], - }, - }, - secrets: { private: { file: "../material/private" } }, - volumes: { data: { name: `${name}_data` } }, - }; await writeFile( join(root, ".hack/docker-compose.yml"), JSON.stringify(compose), diff --git a/tests/native-config-protected-source.test.ts b/tests/native-config-protected-source.test.ts new file mode 100644 index 000000000..6615e3c6c --- /dev/null +++ b/tests/native-config-protected-source.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { legacyComposeRetainedFileGrants } from "../src/lib/native-compose-adoption-files.ts"; +import { planLegacyComposeRetainedFileAdoption } from "../src/lib/native-compose-adoption-plan.ts"; +import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; +import { mapLegacyNativeRetainedFileStorage } from "../src/lib/native-config-import-plan.ts"; +import { + nativeProtectedFileModeRefusalInputs, + nativeProtectedFileRetainedInputs, +} from "./e2e/scenarios/native-config-protected-files.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const image = `sha256:${"a".repeat(64)}`; +function source(inputs: { + readonly config: unknown; + readonly compose: unknown; +}) { + return { + configText: JSON.stringify(inputs.config), + composeText: JSON.stringify(inputs.compose), + }; +} +const retained = () => + nativeProtectedFileRetainedInputs({ + instance: { + name: "fixture", + grants: [ + { target: "/settings", mode: "0444", bytes: [1] }, + { target: "/run/secrets/owner", mode: "0400", bytes: [2] }, + { target: "/run/secrets/private", mode: "0600", bytes: [3] }, + ], + }, + bun: image, + db: image, + }); +test("maintained retained inputs are admitted by the canonical closed file8 mapper", () => { + const inputs = retained(); + expect(Object.keys(inputs.config)).toEqual(["name"]); + for (const service of Object.values(inputs.compose.services)) { + expect(Object.hasOwn(service, "pull_policy")).toBe(false); + } + expect( + planLegacyComposeRetainedFileAdoption(source(inputs)).intent + ).toBeDefined(); + const mapped = mapLegacyNativeRetainedFileStorage(source(inputs)); + expect(mapped.report.complete).toBe(true); + expect( + legacyComposeRetainedFileGrants(mapped.candidate).map((row) => [ + row.service, + row.name, + row.target, + ]) + ).toEqual([ + ["reader", "owner", "/run/secrets/owner"], + ["reader", "private", "/run/secrets/private"], + ["reader", "settings", "/settings"], + ]); +}); +test("historical and canonical worktree and authored pull policy remain refused by retained file8", () => { + const inputs = retained(); + const raw = { ...inputs.config, worktree: { inherit: false } }; + const planned = planLegacyComposeRetainedFileAdoption( + source({ ...inputs, config: raw }) + ); + expect(planned.intent).toBeUndefined(); + expect(planned.report.fields).toContainEqual( + expect.objectContaining({ + document: "config", + pointer: "/worktree/inherit", + status: "refused", + }) + ); + const canonical = mapLegacyNativeRetainedFileStorage( + source({ + ...inputs, + config: { ...inputs.config, worktree: { inherit_local: false } }, + }) + ); + expect(canonical.candidate).toBeDefined(); + expect(() => legacyComposeRetainedFileGrants(canonical.candidate)).toThrow(); + const withPull = structuredClone(inputs); + Object.assign(withPull.compose.services.reader, { pull_policy: "never" }); + const mapped = mapLegacyNativeRetainedFileStorage(source(withPull)); + expect(mapped.candidate).toBeDefined(); + expect(() => legacyComposeRetainedFileGrants(mapped.candidate)).toThrow(); +}); + +let root: string; +let savedEnv: Map; +const keys = [ + "PATH", + "HOME", + "HACK_HOME", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_API_VERSION", +]; +beforeEach(async () => { + savedEnv = new Map(keys.map((key) => [key, process.env[key]])); + root = await realpath(await mkdtemp(join(tmpdir(), "protected-source-"))); + await chmod(root, 0o700); +}); +afterEach(async () => { + for (const [key, value] of savedEnv) { + restoreEnv(key, value); + } + await rm(root, { recursive: true, force: true }); +}); +test("same legal0400 source reaches only the rejecting shim after the mismatched-mode refusal", async () => { + const checkout = join(root, "checkout"), + marker = join(root, "invoked"); + await mkdir(join(checkout, ".hack"), { recursive: true }); + await mkdir(join(checkout, ".git")); + await mkdir(join(checkout, "material")); + const material = join(checkout, "material/private"); + await writeFile(material, "synthetic-owned-material", { + flag: "wx", + mode: 0o400, + }); + const before = await lstat(material); + expect(before.mode & 0o777).toBe(0o400); + const inputs = nativeProtectedFileModeRefusalInputs({ + name: "fixture", + bun: image, + db: image, + }); + await writeFile( + join(checkout, ".hack/hack.config.json"), + JSON.stringify(inputs.config) + ); + const composePath = join(checkout, ".hack/docker-compose.yml"); + await writeFile(composePath, JSON.stringify(inputs.compose)); + await writeFile( + join(root, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';const f=await open(${JSON.stringify(marker)},'wx',0o600);try{await f.writeFile(JSON.stringify(process.argv.slice(2)));await f.sync()}finally{await f.close()}process.exit(98);\n`, + { flag: "wx", mode: 0o700 } + ); + for (const key of keys) { + restoreEnv(key, undefined); + } + process.env.PATH = `${root}:/usr/bin:/bin`; + process.env.HOME = root; + process.env.HACK_HOME = join(root, "hack-home"); + process.env.DOCKER_HOST = "unix:///synthetic-never-forwarded.sock"; + const denied = await previewLegacyComposeAdoption({ + projectRoot: checkout, + stop: true, + }); + expect(denied.complete).toBe(false); + await expect(lstat(marker)).rejects.toMatchObject({ code: "ENOENT" }); + inputs.compose.services.reader.secrets[0]!.mode = "0400"; + await writeFile(composePath, JSON.stringify(inputs.compose)); + const legal = await previewLegacyComposeAdoption({ + projectRoot: checkout, + stop: true, + }); + expect(legal.complete).toBe(false); + expect(JSON.parse(await readFile(marker, "utf8"))).toEqual([ + "info", + "--format", + '{"id":{{json .ID}},"os":{{json .OSType}}}', + ]); + const after = await lstat(material); + expect([after.dev, after.ino, after.mode, after.uid, after.gid]).toEqual([ + before.dev, + before.ino, + before.mode, + before.uid, + before.gid, + ]); + await expect( + lstat(join(checkout, ".hack/.internal/legacy-compose-adoption-v1")) + ).rejects.toMatchObject({ code: "ENOENT" }); +}, 15_000); From 7be8ad22f88468131d2ecb82e6ca3e8e0b113dec Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:24:04 -0400 Subject: [PATCH 13/23] test: align dependency forwarding with owned mount inspection --- ...tive-compose-adoption-dependency-inputs.ts | 4 +- ...ompose-adoption-dependency-fixture.test.ts | 76 +++++++++++++++++++ ...tive-compose-adoption-mount-format.test.ts | 64 ++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts index 9f2bcd977..b928f5cdb 100644 --- a/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts @@ -99,7 +99,9 @@ const DEPENDENCY_READ_FORMATS = { container: { list: "b2e981e67b44cf6e783ce33d9bf30e6acf8ece4f4577e0e271b2375d041a49be", inspect: [ - "51d0339130db3ac49d475ed2c5060e8cf1ab97111f3f158c19062b787ec3e65e", + "e0e3f0a70d4d4f591add56cad0cccd87d8c5a0a19651457c90f460d25ca36f26", + "91c2e3a5ba23f39982fc158a61bb6548d5fa8d1c6ac06b42b573b8acaa1ed15b", + // Exact runtime config-hash observation is separate from full ownership inspection. "a0c775c27577062be0deaf03cc10ccec9deec5512c7f75fa0225ac0fbef58764", "2678a2db0e9f357cd7f58dd42d5dc613371e3d69b29a20bab7d37959d73fb551", "999c6f5f7f6765c00dbb66f7fc6aa2decef1ec316f7fb6ddcadaa64a1a36eaa8", diff --git a/tests/native-compose-adoption-dependency-fixture.test.ts b/tests/native-compose-adoption-dependency-fixture.test.ts index cb4d5ca31..8d305c0a3 100644 --- a/tests/native-compose-adoption-dependency-fixture.test.ts +++ b/tests/native-compose-adoption-dependency-fixture.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; +import { legacyComposeAdoptionContainerInspectFormat } from "../src/lib/native-compose-adoption-binding.ts"; import { adoptionDependencyHealthcheck, adoptionDependencyReadAllowed, @@ -114,6 +115,8 @@ const originalCompose = "/synthetic/nc04-checkout/.hack/docker-compose.yml"; const savedCompose = `/synthetic/nc04-checkout/.hack/.internal/legacy-compose-adoption-v1/generations/${readScope.generationId}/legacy-compose.yml`; const statesFormat = '{"id":{{json .Id}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"status":{{json .State.Status}}}'; +const configHashFormat = + '{"id":{{json .Id}},"hash":{{json (index .Config.Labels "com.docker.compose.config-hash")}}}'; test("dependency forwarder accepts only canonical original or receipt-anchored saved config hashes", () => { for (const file of [originalCompose, savedCompose]) { @@ -145,6 +148,44 @@ test("dependency forwarder accepts only canonical original or receipt-anchored s ).toBe(true); }); +test("dependency forwarder preserves the separate shipping config-hash observation", async () => { + const source = await readFile( + new URL("../src/lib/native-compose-adoption-runtime.ts", import.meta.url), + "utf8" + ); + expect(source).toContain(`'${configHashFormat}'`); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: ["container", "inspect", "--format", configHashFormat, db], + }) + ).toBe(true); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: [ + "container", + "inspect", + "--format", + configHashFormat, + "e".repeat(64), + ], + }) + ).toBe(false); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: [ + "container", + "inspect", + "--format", + configHashFormat.replace("config-hash", "PRIVATE"), + db, + ], + }) + ).toBe(false); +}); + test("combined bridge and health forwarder accepts only the binding owner's exact alias inspection", async () => { const source = await readFile( new URL("../src/lib/native-compose-adoption-binding.ts", import.meta.url), @@ -157,12 +198,19 @@ test("combined bridge and health forwarder accepts only the binding owner's exac const ordinary = template ?.replaceAll("${PROJECT}", "com.docker.compose.project") .replaceAll("${VERSION}", "io.hack.native-config.version"); + expect(ordinary).toBe(legacyComposeAdoptionContainerInspectFormat); const custom = ordinary?.replace( '"id":{{json $n.NetworkID}}}', '"id":{{json $n.NetworkID}},"aliases":{{json $n.Aliases}}}' ); expect(custom).toBeDefined(); expect(custom).not.toBe(ordinary); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: ["container", "inspect", "--format", ordinary ?? "", db], + }) + ).toBe(true); expect( adoptionDependencyReadAllowed({ ...readScope, @@ -181,6 +229,34 @@ test("combined bridge and health forwarder accepts only the binding owner's exac args: ["container", "inspect", "--format", `${custom}PRIVATE`, db], }) ).toBe(false); + const missingFieldPredecessor = ordinary?.replace( + '{{$name := ""}}{{range $key, $value := $m}}{{if eq $key "Name"}}{{$name = $value}}{{end}}{{end}}{{json $name}}', + "{{json $m.Name}}" + ); + expect(missingFieldPredecessor).not.toBe(ordinary); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: [ + "container", + "inspect", + "--format", + missingFieldPredecessor ?? "", + db, + ], + }) + ).toBe(false); + const truthinessDefault = ordinary?.replace( + '{{$name := ""}}{{range $key, $value := $m}}{{if eq $key "Name"}}{{$name = $value}}{{end}}{{end}}{{json $name}}', + '{{with (index $m "Name")}}{{json .}}{{else}}""{{end}}' + ); + expect(truthinessDefault).not.toBe(ordinary); + expect( + adoptionDependencyReadAllowed({ + ...readScope, + args: ["container", "inspect", "--format", truthinessDefault ?? "", db], + }) + ).toBe(false); }); for (const [name, args] of [ diff --git a/tests/native-compose-adoption-mount-format.test.ts b/tests/native-compose-adoption-mount-format.test.ts index 6c37a026b..9a50efa44 100644 --- a/tests/native-compose-adoption-mount-format.test.ts +++ b/tests/native-compose-adoption-mount-format.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { isRecord } from "../src/lib/guards.ts"; import { legacyComposeAdoptionContainerInspectFormat } from "../src/lib/native-compose-adoption-binding.ts"; import { nativeComposeProbeFailure } from "../src/lib/native-compose-ownership.ts"; +import { adoptionDependencyReadAllowed } from "./e2e/scenarios/native-compose-adoption-dependency-inputs.ts"; import { DOCKER_FORMAT_CONTAINER_ID, withDockerContainerFormatFixture, @@ -43,6 +44,22 @@ const SYNTHETIC_CONTAINER = { }; const binary = process.env.HACK_TEST_DOCKER_FORMAT_BINARY, sha256 = process.env.HACK_TEST_DOCKER_FORMAT_SHA256; +function forwardingAllowed(format: string): boolean { + return adoptionDependencyReadAllowed({ + projectRoot: "/synthetic", + project: "synthetic", + containerIds: [DOCKER_FORMAT_CONTAINER_ID], + networkId: "b".repeat(64), + volumeName: "synthetic_data", + args: [ + "container", + "inspect", + "--format", + format, + DOCKER_FORMAT_CONTAINER_ID, + ], + }); +} test.skipIf(binary === undefined && sha256 === undefined)( "real pinned Docker formatter accepts omitted bind Name while preserving volume name", async () => { @@ -59,6 +76,10 @@ test.skipIf(binary === undefined && sha256 === undefined)( "{{json $m.Name}}" ); expect(old).not.toBe(legacyComposeAdoptionContainerInspectFormat); + expect(forwardingAllowed(old)).toBe(false); + expect( + forwardingAllowed(legacyComposeAdoptionContainerInspectFormat) + ).toBe(true); let failure: unknown; try { await probe(old); @@ -117,6 +138,9 @@ test sha256, container, observe: async (probe) => { + expect( + forwardingAllowed(legacyComposeAdoptionContainerInspectFormat) + ).toBe(true); let text: string; try { text = await probe(legacyComposeAdoptionContainerInspectFormat); @@ -146,3 +170,43 @@ test }, 20_000 ); +test.skipIf(binary === undefined && sha256 === undefined)( + "closed dependency forwarder preserves an explicit empty bind Name through the real formatter", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerContainerFormatFixture({ + binary, + sha256, + container: { + ...SYNTHETIC_CONTAINER, + Mounts: [ + { ...SYNTHETIC_CONTAINER.Mounts[0], Name: "" }, + SYNTHETIC_CONTAINER.Mounts[1], + ], + }, + observe: async (probe) => { + expect( + forwardingAllowed(legacyComposeAdoptionContainerInspectFormat) + ).toBe(true); + const value: unknown = JSON.parse( + await probe(legacyComposeAdoptionContainerInspectFormat) + ); + if ( + !( + isRecord(value) && + Array.isArray(value.mounts) && + isRecord(value.mounts[0]) && + isRecord(value.mounts[1]) + ) + ) { + throw new Error("Synthetic container projection is malformed"); + } + expect(value.mounts[0].name).toBe(""); + expect(value.mounts[1].name).toBe("synthetic_data"); + }, + }); + }, + 20_000 +); From e205e3543d23837f4d18c70bf52bcc6f78335efc Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 00:44:14 -0400 Subject: [PATCH 14/23] feat: map retained legacy routing without activation --- docs/reference/native-compose-adoption.md | 18 ++ src/lib/native-compose-adoption-plan.ts | 32 +- src/lib/native-config-import-plan.ts | 66 +++- src/lib/native-config-import-routing.ts | 349 +++++++++++++++++++++ src/lib/native-config-import-storage.ts | 3 + tests/native-config-import-routing.test.ts | 280 +++++++++++++++++ 6 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 src/lib/native-config-import-routing.ts create mode 100644 tests/native-config-import-routing.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index a9b9fd75d..247bcc58d 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -497,3 +497,21 @@ static fixture's exact ownership and daemon checks for cleanup. Use the same prerequisites and flags as above with `--only=native-compose-adoption-managed-worktrees`. Registration and synthetic fixture controls do not establish a live pass. + +## Retained routing contract under implementation + +The separate private version 14 mapper admits literal legacy `dev_host`, its +already configured OAuth alias and `open.prefer`, together with closed static +Caddy HTTP upstream labels. It pins full HTTPS origins rather than deriving a +new host from the project name or a global domain. Routed services must configure +exactly the existing `hack-dev` attachment and the project default bridge; other +services retain only the default bridge and existing local named storage. + +This mapper is not enabled by the ordinary import preview or adoption command. +It supplies private intent and value-free field provenance only. Resource and +ingress incarnation, hostname reservations, active proxy dispatch, typed local +precedence, generated-source fidelity and stopped rollback must be admitted by +the distinct retained routing owner before activation. Builds, jobs, readiness, +source binds, files, branch overrides and custom bridges remain outside this +initial routing family. Neither the map nor a synthetic control proves browser +TLS, OAuth login or application acceptance; global DNS and trust are unchanged. diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 4ddf0caaa..f2caad2b8 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -9,8 +9,13 @@ import { mapLegacyNativeAdoptionBaseline, mapLegacyNativeCompletedJobAdoptionBaseline, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { type LegacyComposeStorageIntent, mapLegacyComposeStorage, @@ -90,9 +95,18 @@ export function planLegacyComposeRetainedBasicBuildAdoption(opts: { return plan(opts, mapLegacyNativeRetainedBasicBuild(opts)); } +/** Separate v14 authored contract; mixed bind/build/job/branch families remain refused. */ +export function planLegacyComposeRetainedRoutingAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedRouting(opts), true); +} + function plan( opts: { readonly configText: string; readonly composeText: string }, - baseline: ReturnType + baseline: ReturnType, + routingFamily = false ): LegacyComposeAdoptionPlan { const config = parseImportDocument({ text: opts.configText, @@ -102,12 +116,22 @@ function plan( text: opts.composeText, document: "compose", }).value; - const qualified = mapLegacyComposeStorage({ config, compose }); + const routing = routingFamily + ? mapLegacyComposeRouting({ config, compose }) + : undefined; + const qualified = mapLegacyComposeStorage({ + config, + compose: + routing && compose ? legacyRoutingStorageDocument(compose) : compose, + }); const mapping = { - supported: qualified !== undefined, + supported: + qualified !== undefined && (!routingFamily || routing !== undefined), accepted: qualified?.accepted ?? new Map(), }; - const intent = qualified?.intent; + const intent = qualified + ? { ...qualified.intent, ...(routing ? { routing: routing.intent } : {}) } + : undefined; const fields = storageFields(baseline.report.fields, mapping); const supported = mapping.supported && !fields.some((field) => field.status === "refused"); diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 69eefd6aa..7d5d57c46 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -22,6 +22,10 @@ import { mapLegacyComposeDependencies, mapLegacyComposeHealthcheck, } from "./native-config-import-readiness.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { mapLegacyComposeStorage } from "./native-config-import-storage.ts"; import { normalizeEnvConfigName } from "./project.ts"; @@ -142,6 +146,7 @@ type NativeImportPurpose = | "adoption-baseline" | "completed-job-adoption" | "retained-basic-build" + | "retained-routing" | "storage-adoption"; /** @@ -189,6 +194,16 @@ function mapLegacyNativeInput(opts: { } } const context = { config: config.value, candidate, mark, refuse }; + const routing = + opts.purpose === "retained-routing" + ? mapLegacyComposeRouting({ + config: config.value, + compose: compose.value, + }) + : undefined; + if (opts.purpose === "retained-routing" && !routing) { + refuse("config", "/dev_host", "explicit_retained_routing_required"); + } mapOverlay(context); mapWorktree(context); mapServices({ @@ -200,26 +215,43 @@ function mapLegacyNativeInput(opts: { opts.purpose === "preview" || opts.purpose === "retained-basic-build", jobPreview: opts.purpose !== "adoption-baseline" && - opts.purpose !== "retained-basic-build", - }); - mapOwnedNetwork({ - project: name, - compose: compose.value, - candidate, - mark, - refuse, - purpose: opts.purpose, + opts.purpose !== "retained-basic-build" && + opts.purpose !== "retained-routing", }); + if (routing) { + Object.assign(candidate, routing.candidate); + for (const pointer of routing.pointers) { + mark( + pointer.document, + pointer.source, + pointer.target, + "existing_routing_binding", + true + ); + } + } else { + mapOwnedNetwork({ + project: name, + compose: compose.value, + candidate, + mark, + refuse, + purpose: opts.purpose, + }); + } if (opts.purpose === "preview") { mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); } if ( opts.purpose === "storage-adoption" || - opts.purpose === "retained-basic-build" + opts.purpose === "retained-basic-build" || + opts.purpose === "retained-routing" ) { mapStorageCandidate({ config: config.value, - compose: compose.value, + compose: routing + ? legacyRoutingStorageDocument(compose.value) + : compose.value, candidate, mark, refuse, @@ -390,6 +422,18 @@ export function mapLegacyNativeRetainedBasicBuild(opts: { }); } +/** Literal legacy origins only. A complete map grants no ingress or retained resource authority. */ +export function mapLegacyNativeRetainedRouting(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-routing", + }); +} + type FileMappingContext = Pick; function mapFileDeclarations( diff --git a/src/lib/native-config-import-routing.ts b/src/lib/native-config-import-routing.ts new file mode 100644 index 000000000..3faf89873 --- /dev/null +++ b/src/lib/native-config-import-routing.ts @@ -0,0 +1,349 @@ +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import { importPointer } from "./native-config-import-parser.ts"; +import { resolveProjectOauthAliasHost } from "./project.ts"; + +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const UPSTREAM = /^\{\{upstreams (?:http )?([1-9][0-9]{0,4})\}\}$/; +const ROUTE_KEYS = [ + "caddy", + "caddy.reverse_proxy", + "caddy.tls", + "caddy_ingress_network", +]; + +export type LegacyComposeRoutingIntent = { + readonly version: 14; + readonly devHost: string; + readonly aliasHost: string | null; + readonly openPreference: "auto" | "alias" | "dev"; + readonly openOrigin: string; + readonly routes: readonly { + readonly service: string; + readonly hostname: string; + readonly port: number; + readonly origins: readonly string[]; + readonly labels: Readonly>; + }[]; +}; +type Pointer = { + readonly document: "config" | "compose"; + readonly source: string; + readonly target: string; +}; + +function exact( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} +function host(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 253 && + value.includes(".") && + value.split(".").every((part) => LABEL.test(part)) + ); +} +function labels(value: unknown): Readonly> | undefined { + const result: Record = Object.create(null); + let entries: unknown[][] = []; + if (isRecord(value)) { + entries = Object.entries(value); + } else if (Array.isArray(value)) { + entries = value.map((item) => + typeof item === "string" && item.includes("=") + ? [item.slice(0, item.indexOf("=")), item.slice(item.indexOf("=") + 1)] + : [] + ); + } + if (!entries.length) { + return undefined; + } + for (const entry of entries) { + const [key, raw] = entry; + if ( + typeof key !== "string" || + typeof raw !== "string" || + !ROUTE_KEYS.includes(key) || + Object.hasOwn(result, key) + ) { + return undefined; + } + result[key] = raw; + } + return result; +} +function attached(value: unknown, routed: boolean): boolean { + if (value === undefined) { + return !routed; + } + const expected = routed + ? [DEFAULT_INGRESS_NETWORK, "default"].sort() + : ["default"]; + return ( + Array.isArray(value) && + value.every((item) => typeof item === "string") && + JSON.stringify([...value].sort()) === JSON.stringify(expected) + ); +} +function network(source: unknown): boolean { + if ( + !(isRecord(source) && exact(source, [DEFAULT_INGRESS_NETWORK, "default"])) + ) { + return false; + } + const ingress = source[DEFAULT_INGRESS_NETWORK]; + const local = source.default; + return ( + isRecord(ingress) && + exact(ingress, ["external"]) && + ingress.external === true && + (!Object.hasOwn(source, "default") || + local === null || + (isRecord(local) && Object.keys(local).length === 0)) + ); +} + +/** Closed static route conversion. Literal origins preserve the original host even + * when it differs from the Compose name. This grants no ingress/resource authority. */ +export function mapLegacyComposeRouting(opts: { + readonly config: Record | undefined; + readonly compose: Record | undefined; +}): + | { + readonly intent: LegacyComposeRoutingIntent; + readonly candidate: Readonly>; + readonly pointers: readonly Pointer[]; + } + | undefined { + const { config, compose } = opts; + if ( + !( + config && + compose && + host(config.dev_host) && + isRecord(compose.services) && + network(compose.networks) + ) + ) { + return undefined; + } + const devHost = config.dev_host; + let aliasHost: string | null = null; + if (Object.hasOwn(config, "oauth")) { + if ( + !( + isRecord(config.oauth) && + exact(config.oauth, ["enabled", "tld"]) && + (!Object.hasOwn(config.oauth, "enabled") || + typeof config.oauth.enabled === "boolean") && + (!Object.hasOwn(config.oauth, "tld") || + (typeof config.oauth.tld === "string" && + LABEL.test(config.oauth.tld))) + ) + ) { + return undefined; + } + aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { + ...(config.oauth.enabled === true ? { enabled: true } : {}), + ...(typeof config.oauth.tld === "string" + ? { tld: config.oauth.tld } + : {}), + }, + }); + if (aliasHost !== null && !host(aliasHost)) { + return undefined; + } + } + let openPreference: "auto" | "alias" | "dev" = "auto"; + if (Object.hasOwn(config, "open")) { + if (!(isRecord(config.open) && exact(config.open, ["prefer"]))) { + return undefined; + } + if (Object.hasOwn(config.open, "prefer")) { + const value = config.open.prefer; + if (value !== "auto" && value !== "alias" && value !== "dev") { + return undefined; + } + openPreference = value; + } + } + if (openPreference === "alias" && aliasHost === null) { + return undefined; + } + const routes: LegacyComposeRoutingIntent["routes"][number][] = []; + const pointers: Pointer[] = [ + { document: "config", source: "/dev_host", target: "/routes/origin" }, + { document: "compose", source: "/networks", target: "/existing_ingress" }, + ]; + if (Object.hasOwn(config, "oauth")) { + pointers.push({ + document: "config", + source: "/oauth", + target: "/routes/aliases", + }); + } + if (Object.hasOwn(config, "open")) { + pointers.push({ document: "config", source: "/open", target: "/open" }); + } + const occupied = new Set(); + for (const [service, raw] of Object.entries(compose.services).sort( + ([a], [b]) => a.localeCompare(b) + )) { + if ( + !(isRecord(raw) && LABEL.test(service)) || + [ + "build", + "profiles", + "depends_on", + "healthcheck", + "ports", + "deploy", + "configs", + "secrets", + ].some((key) => Object.hasOwn(raw, key)) + ) { + return undefined; + } + const selected = Object.hasOwn(raw, "labels") + ? labels(raw.labels) + : undefined; + if (Object.hasOwn(raw, "labels") && !selected) { + return undefined; + } + if (!attached(raw.networks, selected !== undefined)) { + return undefined; + } + const servicePointer = importPointer("/services", service); + if (Object.hasOwn(raw, "networks")) { + pointers.push({ + document: "compose", + source: `${servicePointer}/networks`, + target: "/existing_ingress/attachments", + }); + } + if (!selected) { + continue; + } + const sites = selected.caddy?.split(",").map((site) => site.trim()); + const upstream = + typeof selected["caddy.reverse_proxy"] === "string" + ? UPSTREAM.exec(selected["caddy.reverse_proxy"]) + : null; + const port = Number(upstream?.[1]); + if ( + !( + sites?.length && + sites.every(host) && + new Set(sites).size === sites.length && + upstream && + port <= 65_535 && + selected["caddy.tls"] === "internal" && + (!Object.hasOwn(selected, "caddy_ingress_network") || + selected.caddy_ingress_network === DEFAULT_INGRESS_NETWORK) + ) + ) { + return undefined; + } + const primary = sites.find( + (site) => site === devHost || site.endsWith(`.${devHost}`) + ); + if (!primary) { + return undefined; + } + const prefix = + primary === devHost ? "" : primary.slice(0, -(devHost.length + 1)); + // The compiler reserves "project" as its apex sentinel, not a literal prefix. + if (prefix === "project" || (prefix !== "" && !LABEL.test(prefix))) { + return undefined; + } + const aliases = aliasHost + ? [prefix ? `${prefix}.${aliasHost}` : aliasHost] + : []; + if ( + JSON.stringify([...sites].sort()) !== + JSON.stringify([primary, ...aliases].sort()) || + sites.some((site) => occupied.has(site)) + ) { + return undefined; + } + for (const site of sites) { + occupied.add(site); + } + routes.push({ + service, + hostname: prefix || "project", + port, + origins: sites.map((site) => `https://${site}`).sort(), + labels: Object.freeze({ ...selected }), + }); + pointers.push({ + document: "compose", + source: `${servicePointer}/labels`, + target: `/routes/http/${service}`, + }); + } + if (!routes.some((route) => route.hostname === "project")) { + return undefined; + } + const openOrigin = `https://${openPreference !== "dev" && aliasHost ? aliasHost : devHost}`; + return { + intent: Object.freeze({ + version: 14, + devHost, + aliasHost, + openPreference, + openOrigin, + routes: Object.freeze(routes), + }), + candidate: Object.freeze({ + routes: { + origin: `https://${devHost}`, + ...(aliasHost + ? { + aliases: { oauth: { origin: `https://${aliasHost}` } }, + oauth_alias: "oauth", + } + : {}), + http: Object.fromEntries( + routes.map((route) => [ + route.service, + { + service: route.service, + port: route.port, + hostname: route.hostname, + }, + ]) + ), + }, + open: { prefer: openPreference }, + }), + pointers: Object.freeze(pointers), + }; +} + +/** Storage admission remains the existing default-bridge/named-volume contract; + * the separately verified routing owner owns the removed shared attachment. */ +export function legacyRoutingStorageDocument( + compose: Record +): Record { + const result: Record = { ...compose }; + Reflect.deleteProperty(result, "networks"); + if (isRecord(compose.services)) { + result.services = Object.fromEntries( + Object.entries(compose.services).map(([name, value]) => { + if (!isRecord(value)) { + return [name, value]; + } + const service = { ...value }; + Reflect.deleteProperty(service, "networks"); + return [name, service]; + }) + ); + } + return result; +} diff --git a/src/lib/native-config-import-storage.ts b/src/lib/native-config-import-storage.ts index d5cb04b2c..db7aad319 100644 --- a/src/lib/native-config-import-storage.ts +++ b/src/lib/native-config-import-storage.ts @@ -6,6 +6,7 @@ import { mapLegacyOwnedNetwork, } from "./native-config-import-network.ts"; import { importPointer } from "./native-config-import-parser.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; @@ -16,6 +17,8 @@ export type LegacyComposeStorageIntent = { readonly services: readonly string[]; readonly ownedNetwork?: LegacyOwnedNetworkIntent; readonly ownedNetworks?: LegacyOwnedNetworksIntent; + /** Required only by the distinct private retained-routing owner. */ + readonly routing?: LegacyComposeRoutingIntent; readonly volumes: readonly { readonly storage: string; readonly name: string; diff --git a/tests/native-config-import-routing.test.ts b/tests/native-config-import-routing.test.ts new file mode 100644 index 000000000..57ddc5f3e --- /dev/null +++ b/tests/native-config-import-routing.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedRoutingAdoption, +} from "../src/lib/native-compose-adoption-plan.ts"; +import { + mapLegacyNativeImport, + mapLegacyNativeRetainedRouting, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; + +const CANARY = "private-route-source-canary"; +function fixture() { + return { + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true, tld: "gy" }, + open: { prefer: "alias" }, + worktree: { auto_branch: false }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: CANARY, + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + db: { image: "db:1", volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + }, + }; +} +function inputs(config: unknown, compose: unknown) { + return { + configText: JSON.stringify(config), + composeText: JSON.stringify(compose), + }; +} +test("v14 preserves the literal legacy host, existing alias, open preference and named storage without changing old families", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const before = JSON.stringify({ config, compose }); + const mapped = mapLegacyNativeRetainedRouting(source); + const planned = planLegacyComposeRetainedRoutingAdoption(source); + expect(mapped.report.complete).toBe(true); + expect(mapped.candidate).toMatchObject({ + routes: { + origin: "https://original.hack.local", + aliases: { oauth: { origin: "https://original.hack.gy" } }, + oauth_alias: "oauth", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }, + open: { prefer: "alias" }, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); + expect(planned.report.supported).toBe(true); + expect(planned.intent?.routing?.openOrigin).toBe("https://original.hack.gy"); + expect(planned.intent?.volumes).toEqual([ + { name: "fixture_data", storage: "data" }, + ]); + expect(planned.intent?.ownedNetwork).toBeUndefined(); + expect(JSON.stringify({ config, compose })).toBe(before); + for (const older of [ + mapLegacyNativeImport(source), + mapLegacyNativeStorageAdoption(source), + ]) { + expect(older.report.complete).toBe(false); + } + expect(planLegacyComposeAdoption(source).report.supported).toBe(false); + expect(JSON.stringify(mapped)).not.toContain(CANARY); + expect(JSON.stringify(planned)).not.toContain("original.hack"); + expect(Object.isFrozen(planned.intent?.routing?.routes[0]?.labels)).toBe( + true + ); +}); +test("custom existing hosts keep their exact primary origin without inventing an OAuth alias", () => { + const { config, compose } = fixture(); + const selected = { + ...config, + dev_host: "app.example.test", + open: { prefer: "auto" }, + }; + compose.services.web.labels.caddy = "app.example.test"; + const mapped = mapLegacyComposeRouting({ config: selected, compose }); + expect(mapped?.intent.aliasHost).toBeNull(); + expect(mapped?.intent.openOrigin).toBe("https://app.example.test"); + expect(mapped?.candidate.routes).toEqual({ + origin: "https://app.example.test", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }); + expect( + mapLegacyComposeRouting({ + config: { ...selected, open: { prefer: "alias" } }, + compose, + }) + ).toBeUndefined(); +}); +test("closed literal label-list syntax and one relative service route preserve paired hosts", () => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + api: { + image: "api:1", + networks: ["default", "hack-dev"], + labels: [ + "caddy=api.original.hack.local,api.original.hack.gy", + "caddy.reverse_proxy={{upstreams http 8080}}", + "caddy.tls=internal", + "caddy_ingress_network=hack-dev", + ], + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ?.routing?.routes[0] + ).toMatchObject({ service: "api", hostname: "api", port: 8080 }); +}); +test.each([ + { dev_host: "${PRIVATE}" }, + { dev_host: "UPPER.hack" }, + { dev_host: "https://original.hack.local" }, + { oauth: { enabled: true, tld: "../private" } }, + { oauth: { enabled: true, extra: CANARY } }, + { open: { prefer: "unknown" } }, + { open: { prefer: "dev", service: CANARY } }, + { domain: CANARY }, + { routes: { origin: CANARY } }, + { branch: CANARY }, +])("unsupported config cannot mint a routing candidate: %j", (change) => { + const { config, compose } = fixture(); + const result = mapLegacyNativeRetainedRouting( + inputs({ ...config, ...change }, compose) + ); + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(CANARY); +}); +test.each([ + { "caddy.tls": "external" }, + { caddy: "original.hack.local" }, + { caddy: "*.original.hack.local,original.hack.gy" }, + { caddy: "original.hack.local,original.hack.gy,extra.example.test" }, + { "caddy.reverse_proxy": "{{upstreams 0}}" }, + { "caddy.reverse_proxy": "{{upstreams 65536}}" }, + { "caddy.reverse_proxy": "http://foreign:3000" }, + { caddy_ingress_network: "foreign" }, + { caddy_1: CANARY }, + { private: CANARY }, +])("unknown label or changed host/upstream refuses the full source: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { + ...compose.services.web, + labels: { ...compose.services.web.labels, ...change }, + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); +}); +test.each([ + { networks: ["hack-dev"] }, + { networks: ["hack-dev", "default", "foreign"] }, + { networks: { "hack-dev": {}, default: {} } }, + { ports: ["3000:3000"] }, + { build: "." }, + { profiles: ["inactive"] }, + { healthcheck: { test: ["true"] } }, + { configs: [] }, + { secrets: [] }, + { volumes: ["./source:/app:ro"] }, +])("unqualified capability intersections stay refused: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { ...compose.services.web, ...change }, + }, + }; + const result = planLegacyComposeRetainedRoutingAdoption( + inputs(config, selected) + ); + expect(result.report.supported).toBe(false); + expect(result.intent).toBeUndefined(); +}); +test("extra inactive fields, duplicate host ownership and external network options never disappear from refusal", () => { + const { config, compose } = fixture(); + for (const selected of [ + { + ...compose, + networks: { "hack-dev": { external: true, name: "foreign" } }, + }, + { + ...compose, + services: { ...compose.services, other: { ...compose.services.web } }, + }, + { ...compose, "x-private": CANARY }, + { + ...compose, + services: { + ...compose.services, + db: { ...compose.services.db, network_mode: "host" }, + }, + }, + ]) { + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); + } +}); +test("the compiler apex sentinel cannot stand in for a literal project prefix", () => { + const { config, compose } = fixture(); + const prefixed = { + ...compose.services.web, + labels: { + ...compose.services.web.labels, + caddy: "project.original.hack.local,project.original.hack.gy", + }, + }; + for (const services of [ + { ...compose.services, web: prefixed }, + { ...compose.services, prefixed }, + ]) { + const source = inputs(config, { ...compose, services }); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); + } +}); +test("an explicit routing mapper never upgrades a non-routing source", () => { + const source = inputs( + { name: "fixture" }, + { + name: "fixture", + services: { db: { image: "db:1", volumes: ["data:/data"] } }, + volumes: { data: {} }, + } + ); + expect(mapLegacyNativeStorageAdoption(source).report.complete).toBe(true); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); +}); +test("private non-enumerable preparation bytes reach the exact routing mapper", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const privateSource = Object.defineProperties( + {}, + { + configText: { value: source.configText }, + composeText: { value: source.composeText }, + } + ) as typeof source; + expect(Object.keys(privateSource)).toEqual([]); + expect(mapLegacyNativeRetainedRouting(privateSource).candidate).toEqual( + mapLegacyNativeRetainedRouting(source).candidate + ); + expect( + planLegacyComposeRetainedRoutingAdoption(privateSource).intent + ).toEqual(planLegacyComposeRetainedRoutingAdoption(source).intent); +}); From e326ea75f5dbffedddb596d0a9ec2073e1461abd Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 01:27:27 -0400 Subject: [PATCH 15/23] feat: retain original Compose routing during native adoption --- docs/reference/native-compose-adoption.md | 24 +- src/commands/config-adopt.ts | 9 + src/lib/native-compose-adoption-binding.ts | 102 +- src/lib/native-compose-adoption-command.ts | 15 +- src/lib/native-compose-adoption-env-inputs.ts | 37 +- src/lib/native-compose-adoption-execution.ts | 6 +- src/lib/native-compose-adoption-generation.ts | 964 ++++++++++++++---- src/lib/native-compose-adoption-local.ts | 49 +- src/lib/native-compose-adoption-projection.ts | 123 ++- src/lib/native-compose-adoption-receipt.ts | 107 +- ...tive-compose-adoption-routing-execution.ts | 146 +++ ...ive-compose-adoption-routing-resolution.ts | 51 + src/lib/native-compose-adoption-routing.ts | 490 +++++++++ src/lib/native-compose-adoption-runtime.ts | 3 +- src/lib/native-compose-open.ts | 65 +- src/lib/native-compose-proxy-routes.ts | 92 ++ src/lib/native-compose-route-claims.ts | 66 ++ tests/helpers/retained-routing-adoption.ts | 615 +++++++++++ ...compose-adoption-routing-execution.test.ts | 242 +++++ ...ompose-adoption-routing-generation.test.ts | 488 +++++++++ ...e-compose-adoption-routing-receipt.test.ts | 159 +++ ...ompose-adoption-routing-resolution.test.ts | 112 ++ tests/native-compose-route-claims.test.ts | 105 ++ 23 files changed, 3801 insertions(+), 269 deletions(-) create mode 100644 src/lib/native-compose-adoption-routing-execution.ts create mode 100644 src/lib/native-compose-adoption-routing-resolution.ts create mode 100644 src/lib/native-compose-adoption-routing.ts create mode 100644 tests/helpers/retained-routing-adoption.ts create mode 100644 tests/native-compose-adoption-routing-execution.test.ts create mode 100644 tests/native-compose-adoption-routing-generation.test.ts create mode 100644 tests/native-compose-adoption-routing-receipt.test.ts create mode 100644 tests/native-compose-adoption-routing-resolution.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 247bcc58d..8b03b2ce7 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -507,11 +507,19 @@ new host from the project name or a global domain. Routed services must configur exactly the existing `hack-dev` attachment and the project default bridge; other services retain only the default bridge and existing local named storage. -This mapper is not enabled by the ordinary import preview or adoption command. -It supplies private intent and value-free field provenance only. Resource and -ingress incarnation, hostname reservations, active proxy dispatch, typed local -precedence, generated-source fidelity and stopped rollback must be admitted by -the distinct retained routing owner before activation. Builds, jobs, readiness, -source binds, files, branch overrides and custom bridges remain outside this -initial routing family. Neither the map nor a synthetic control proves browser -TLS, OAuth login or application acceptance; global DNS and trust are unchanged. +Ordinary import preview remains outside this private family. The retained routing +owner binds the original resources and ingress incarnations, reserves the exact +hostnames, and verifies current proxy dispatch before clearing a startup receipt. +Saved reads preserve literal origins and typed local precedence without acquiring +managed values. Every original-ID lifecycle child has a durable prospective +record; only its one-use known-return and process-group-absence proof settles that +record. Explicit recovery can contain an uncertain child but cannot clear its +uncertainty merely because containers are stopped. + +Rollback requires restored source bytes, stopped original resources and absent +proxy dispatch before handing hostname claims back to the restored legacy source. +It retains a durable handoff state across interrupted claim removal. Builds, jobs, +readiness, source binds, files, branch overrides and custom bridges remain outside +this initial routing family. The owner and private-store model do not prove live +TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated +ingress lifecycle fixture remains required; global DNS and trust are unchanged. diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index 6fe298378..3f9c3da3e 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -16,6 +16,7 @@ import { } from "../lib/native-compose-adoption-generation.ts"; import { previewLegacyComposeAdoption } from "../lib/native-compose-adoption-preview.ts"; import { legacyComposeRetainedOrdered } from "../lib/native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../lib/native-compose-adoption-routing-execution.ts"; import { requireNativeComposeBackend } from "../lib/native-compose-selection.ts"; import { run } from "../lib/shell.ts"; @@ -111,6 +112,14 @@ async function adoptPrepared( if (opts.signal.aborted) { throw new Error("Legacy adoption cancelled; values omitted."); } + if (input.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + signal: opts.signal, + }); + } if ( legacyComposeRetainedOrdered(input.retainedPlan) || input.retainedBuild diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 48d355bb2..eed1aa338 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeBuildSource, @@ -17,12 +18,21 @@ import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedRoutingAdoption, } from "./native-compose-adoption-plan.ts"; import { hasLegacyComposeGeneratedSources, LegacyComposeAdoptionProjection, } from "./native-compose-adoption-projection.ts"; import { legacyComposeRetainedPlan } from "./native-compose-adoption-readiness.ts"; +import { + inspectLegacyComposeRetainedRouting, + type LegacyComposeRetainedRoutingProof, +} from "./native-compose-adoption-routing.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; import { createNativeComposeProbe, NativeComposeOwnershipError, @@ -33,6 +43,7 @@ import { } from "./native-config-import-inputs.ts"; import { freezeImportValue, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -313,6 +324,12 @@ export type LegacyComposeVerifiedBinding = LegacyComposeVerifiedBindingBase & readonly composeFiles: readonly string[]; readonly networks: readonly LegacyComposeVerifiedNetwork[]; } + | { + readonly binding_version: 14; + readonly composeFiles: readonly string[]; + readonly network: LegacyComposeOriginalNetwork; + readonly routing: LegacyComposeRetainedRoutingProof; + } ); type ProjectedPreparation = Pick< @@ -430,6 +447,7 @@ function containerRows( readonly network?: { readonly name: string; readonly id: string }; readonly networks?: readonly LegacyComposeVerifiedNetwork[]; readonly composeFiles: readonly string[]; + readonly ingress?: NativeComposeIngressBinding; } ): LegacyComposeVerifiedContainer[] { requireValue(rows.length === opts.intent.services.length); @@ -521,8 +539,36 @@ function containerRows( } return { id: row.id, name: row.name.slice(1), service: row.service }; } - requireValue(Array.isArray(row.networks) && row.networks.length === 1); - const network = row.networks[0]; + requireValue(Array.isArray(row.networks)); + const routed = + opts.intent.routing?.routes.some( + (route) => route.service === row.service + ) === true; + if (opts.intent.routing) { + requireValue( + opts.ingress && + !opts.intent.ownedNetwork && + !opts.intent.ownedNetworks && + row.networks.length === (routed ? 2 : 1) + ); + const shared = row.networks.filter( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + requireValue(routed ? shared.length === 1 : shared.length === 0); + if (routed) { + const item = shared[0]; + requireValue(isRecord(item)); + keys(item, ["id", "name"]); + requireValue(item.id === opts.ingress.networkId); + } + } else { + requireValue(row.networks.length === 1); + } + const local = row.networks.filter( + (item) => !isRecord(item) || item.name !== DEFAULT_INGRESS_NETWORK + ); + requireValue(local.length === 1); + const network = local[0]; requireValue(isRecord(network)); keys( network, @@ -748,12 +794,17 @@ export async function inspectLegacyComposeAdoptionResources(opts: { opts.intent.ownedNetwork !== undefined || opts.intent.ownedNetworks !== undefined, }); + const ingress = opts.intent.routing + ? await observeNativeComposeIngress({ signal: opts.signal }) + : undefined; + requireValue(!ingress || ingress.engineId === engineId); const containers = containerRows(containerFacts, { ...opts, composeFiles, volumes, network: single?.network, networks: plural?.networks, + ingress, }); // Docker drops stopped endpoints from network inspection. Each original must // still configure every declared NetworkID; live members are exact per bridge. @@ -820,6 +871,26 @@ export async function inspectLegacyComposeAdoptionResources(opts: { }; } requireValue(single); + if (opts.intent.routing) { + requireValue( + !(plural || opts.intent.ownedNetwork || opts.intent.ownedNetworks) && + ingress + ); + const routing = await inspectLegacyComposeRetainedRouting({ + binding: common, + routing: opts.intent.routing, + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }); + requireValue(JSON.stringify(routing.ingress) === JSON.stringify(ingress)); + return { + ...common, + binding_version: 14, + composeFiles: Object.freeze(composeFiles), + network: single.network, + routing, + }; + } return { ...(opts.composeFiles ? { @@ -839,7 +910,7 @@ export async function inspectLegacyComposeAdoptionResources(opts: { } export type LegacyComposeAdoptionBinding = { readonly report: { - readonly binding_version: 1 | 2 | 3 | 4 | 5 | 6; + readonly binding_version: 1 | 2 | 3 | 4 | 5 | 6 | 14; readonly status: "verified"; readonly adoption: "not_performed"; readonly containers: number; @@ -950,12 +1021,19 @@ async function acquireBinding( configText: source.configText, composeText: source.composeText, }); + const routed = + purpose === "preparation" && !ordinary.intent + ? planLegacyComposeRetainedRoutingAdoption(source) + : undefined; const basic = purpose === "basic-build" || - (purpose === "preparation" && !ordinary.intent); - const planned = basic - ? planLegacyComposeRetainedBasicBuildAdoption(source) - : ordinary; + (purpose === "preparation" && !ordinary.intent && !routed?.intent); + let planned = ordinary; + if (basic) { + planned = planLegacyComposeRetainedBasicBuildAdoption(source); + } else if (routed?.intent) { + planned = routed; + } const intent = planned.intent; if (!intent) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); @@ -963,7 +1041,11 @@ async function acquireBinding( const buildSource = basic ? await acquireLegacyComposeBuildSource({ source, signal }) : undefined; - const mapped = mapLegacyNativeStorageAdoption({ + const mapped = ( + intent.routing + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )({ configText: source.configText, composeText: source.composeText, }); @@ -984,7 +1066,8 @@ async function acquireBinding( ); if ( candidate && - (generatedPresent || + (intent.routing !== undefined || + generatedPresent || !(await legacyComposeAdoptionLayoutSupported({ projectRoot: root, candidate, @@ -998,6 +1081,7 @@ async function acquireBinding( source, signal, binary, + retainedRouting: intent.routing !== undefined, }); const resolved = await projection.resolve({ signal }); projected = Object.freeze({ diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index dfc31e5c0..2668ce8dd 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -11,6 +11,7 @@ import { } from "./native-compose-adoption-generation.ts"; import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; import { legacyComposeRetainedOrdered } from "./native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates } from "./native-compose-adoption-runtime.ts"; import type { NativeComposeCommandOptions } from "./native-compose-command.ts"; import { requireNativeComposeBackend } from "./native-compose-selection.ts"; @@ -61,7 +62,9 @@ async function registeredAdoptedRoot(project: string) { } /** Check private adoption ownership before ordinary authored discovery can allocate a fresh native namespace. */ -async function adoptedRoot(options: NativeComposeCommandOptions) { +export async function selectLegacyComposeAdoptedRoot( + options: Pick +) { if (options.path && options.project !== undefined) { throw new CliUsageError("Use either --path or --project (not both)."); } @@ -253,7 +256,7 @@ export async function tryLegacyComposeAdoptedCommand( command: input.command ? [...input.command] : undefined, profiles: input.profiles ? [...input.profiles] : undefined, }; - const projectRoot = await adoptedRoot(options); + const projectRoot = await selectLegacyComposeAdoptedRoot(options); if (!projectRoot) { return null; } @@ -293,6 +296,14 @@ export async function tryLegacyComposeAdoptedCommand( deadline, run: async (privateInput) => { cancelled(signal); + if (privateInput.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input: privateInput, + operation, + deadline, + signal, + }); + } if ( legacyComposeRetainedOrdered(privateInput.retainedPlan) || privateInput.retainedBuild diff --git a/src/lib/native-compose-adoption-env-inputs.ts b/src/lib/native-compose-adoption-env-inputs.ts index 251bac097..c73a3a581 100644 --- a/src/lib/native-compose-adoption-env-inputs.ts +++ b/src/lib/native-compose-adoption-env-inputs.ts @@ -12,7 +12,11 @@ import { privateNativeConfigImportSourceProof, } from "./native-config-import-inputs.ts"; import { parseImportDocument } from "./native-config-import-parser.ts"; -import { mapLegacyNativeStorageAdoption } from "./native-config-import-plan.ts"; +import { + mapLegacyNativeRetainedRouting, + mapLegacyNativeStorageAdoption, +} from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; import type { NativeProjectEnvSelectionOptions } from "./project-env-config.ts"; import { @@ -76,6 +80,7 @@ export class LegacyAdoptionManagedEnvAdmission { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { if ( @@ -86,11 +91,31 @@ export class LegacyAdoptionManagedEnvAdmission { const source = opts.source; const signal = opts.signal; const binary = opts.binary; + const retainedRouting = opts.retainedRouting === true; if (signal !== undefined && !(signal instanceof AbortSignal)) { refuse(); } await source.assertFresh({ signal }); - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose: parseImportDocument({ + text: source.composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if (retainedRouting && !routing) { + refuse(); + } const candidate = mapped.candidate; if (!(candidate && isRecord(candidate.services))) { refuse(); @@ -132,6 +157,7 @@ export class LegacyAdoptionManagedEnvAdmission { overlay, binary, signal, + routing, }); const context = { source, @@ -167,6 +193,13 @@ export class LegacyAdoptionManagedEnvAdmission { return this.#context.local.fields; } + get routingResolution() { + if (!ownedAdmissions.has(this)) { + refuse(); + } + return this.#context.local.routingResolution; + } + /** Private manifest provenance; captured sources are factory-issued and still fresh. No key or layer reread. */ async resolvePrivatePrimaryProof() { await this.assertRoot(this.selection); diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index 3cfa1c3a3..c5c47e651 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -17,6 +17,7 @@ import { legacyComposeRetainedReady, } from "./native-compose-adoption-readiness.ts"; import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import type { LegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeJobStates, inspectLegacyComposeReadiness, @@ -38,7 +39,10 @@ function refuse( } const COMPLETION = Symbol("legacy-compose-job-completion"); export type LegacyComposeJobCompletion = { readonly [COMPLETION]: true }; -export type LegacyComposeRetainedOutcome = number | LegacyComposeJobCompletion; +export type LegacyComposeRetainedOutcome = + | number + | LegacyComposeJobCompletion + | LegacyComposeRoutingCompletion; type CompletionWitness = { readonly plan: LegacyComposeRetainedPlan; readonly binding: LegacyComposeVerifiedBinding; diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index b45fca7f5..828680bbe 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { Stats } from "node:fs"; import { link, lstat, mkdir, rename, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeAdoptionPreparationBinding, @@ -31,6 +32,7 @@ import { import { planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedRoutingAdoption, } from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; import { @@ -49,6 +51,8 @@ import { type Receipt, parseLegacyComposeAdoptionReceipt as receipt, } from "./native-compose-adoption-receipt.ts"; +import { assertLegacyComposeRetainedRoutingState } from "./native-compose-adoption-routing.ts"; +import { consumeLegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates, inspectLegacyComposeJobStates, @@ -71,6 +75,13 @@ import { token, writeExclusive, } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteAttempt, + type NativeComposeRouteClaims, + type NativeComposeRouteReference, + openNativeComposeRouteClaims, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; import { NATIVE_CONFIG_INPUT_LIMIT } from "./native-config-compiler.ts"; import { type NativeConfigImportSourceIdentity, @@ -81,8 +92,14 @@ import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + type LegacyComposeRoutingIntent, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; const HASH = /^[a-f0-9]{64}$/; @@ -100,13 +117,25 @@ const ROUTING = [ "HACK_EXECUTION_MODE", ] as const; type SavedManifest = { - readonly adoption_generation_version: 1 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; + readonly adoption_generation_version: + | 1 + | 3 + | 4 + | 5 + | 6 + | 7 + | 9 + | 10 + | 11 + | 14; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly routingClaims?: NativeComposeRouteReference; + readonly routingRoot?: string; readonly buildProof?: { readonly source: unknown; readonly images: unknown }; readonly sourceFiles: { readonly config: NativeConfigImportSourceIdentity; @@ -129,10 +158,14 @@ type PrivateInputs = { readonly projectionMetadata?: NativeProjectEnvMetadata; /** Private version9 policy; never serialized into a compiler report or receipt label. */ readonly retainedBuild?: true; + readonly retainedRouting?: true; + readonly routingResolution?: NativeRoutingResolution; }; type MutationInputs = PrivateInputs & { /** Issued while the journal and mutation lock are held; valid only during this callback. */ readonly assertFresh: () => Promise; + /** Synchronous revocation fence for the final spawn boundary. */ + readonly assertActive: () => void; readonly retainedPlan: LegacyComposeRetainedPlan; }; @@ -241,6 +274,9 @@ function sourceFileIdentity( ); } function manifestFieldKeys(value: Record) { + if (value.adoption_generation_version === 14) { + return "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,routingClaims,routingRoot,runtimeConfig,sourceFiles"; + } if (value.adoption_generation_version === 9) { return "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; } @@ -259,6 +295,12 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { isRecord(value) && keys(value, manifestFieldKeys(value)) && (value.adoption_generation_version === 1 || + (value.adoption_generation_version === 14 && + isRecord(value.projectionProof) && + value.projectionProof.projection_version === 3 && + isRecord(value.routingClaims) && + typeof value.routingRoot === "string" && + resolve(value.routingRoot) === value.routingRoot) || value.adoption_generation_version === 7 || value.adoption_generation_version === 6 || (value.adoption_generation_version === 9 && @@ -301,6 +343,12 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { id, binding: value.binding, runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 14 + ? { + routingClaims: parseNativeComposeRouteReference(value.routingClaims), + routingRoot: String(value.routingRoot), + } + : {}), ...(value.adoption_generation_version === 9 && isRecord(value.buildProof) ? { buildProof: { @@ -312,7 +360,8 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { ...((value.adoption_generation_version === 5 && Object.hasOwn(value, "projectionProof")) || value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 + value.adoption_generation_version === 4 || + value.adoption_generation_version === 14 ? { projectionProof: value.projectionProof } : {}), sourceFiles: { @@ -429,6 +478,7 @@ export type LegacyComposeAdoptedGenerationStore = { type Context = { readonly root: string; + readonly routingRoot: string; readonly checkout: Checkout; readonly directories: HeldDirectory[]; readonly stateRoot: string; @@ -522,7 +572,15 @@ async function requireSelectedTopologyOwner(opts: { requiresV5)) || (!plural && (meta.binding.binding_version === 5 || - meta.binding.binding_version === 6)) + meta.binding.binding_version === 6)) || + (meta.adoption_generation_version === 14) !== + (meta.binding.binding_version === 14) || + (meta.adoption_generation_version === 14 && + (custom || + plural || + requiresV5 || + !meta.routingClaims || + meta.routingClaims.generationIdentity !== selected.id)) ) { refuse(); } @@ -537,11 +595,114 @@ async function requireSelectedTopologyOwner(opts: { (state.adoption_receipt_version === 10) !== bridgeAndHealth || (state.adoption_receipt_version === 9) !== (meta.adoption_generation_version === 9) || + (state.adoption_receipt_version === 14) !== + (meta.adoption_generation_version === 14) || JSON.stringify(state.prepared) !== JSON.stringify(selected) ) { refuse(); } } +function retainedRoutingIntent(input: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeRoutingIntent { + const mapped = mapLegacyComposeRouting({ + config: parseImportDocument({ text: input.configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: input.composeText, + document: "compose", + }).value, + }); + return mapped?.intent ?? refuse(); +} +function openRetainedRoutingClaims( + ctx: Context, + generation: string, + binding: LegacyComposeVerifiedBinding +): Promise { + if (binding.binding_version !== 14) { + refuse(); + } + return openNativeComposeRouteClaims({ + root: ctx.routingRoot, + binding: { + engineId: binding.engineId, + proxyId: binding.routing.ingress.proxyId, + networkId: binding.routing.ingress.networkId, + }, + owner: { composeProject: binding.composeProject, ownerToken: generation }, + }); +} +function requireRetainedClaimContext(opts: { + readonly inputs: PrivateInputs; + readonly generation: string; + readonly claim: Parameters< + Parameters[0]["assertAbsent"] + >[0]; + readonly handoff?: true; +}): void { + const binding = opts.inputs.binding; + const names = retainedRoutingIntent(opts.inputs) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort(); + if ( + binding.binding_version !== 14 || + (opts.handoff + ? opts.claim.hostnames.some((hostname) => !names.includes(hostname)) + : JSON.stringify([...opts.claim.hostnames].sort()) !== + JSON.stringify(names)) || + opts.claim.binding.engineId !== binding.engineId || + opts.claim.binding.proxyId !== binding.routing.ingress.proxyId || + opts.claim.binding.networkId !== binding.routing.ingress.networkId || + opts.claim.owner.composeProject !== binding.composeProject || + opts.claim.owner.ownerToken !== opts.generation + ) { + refuse(); + } +} +async function assertRetainedRouteState( + ctx: Context, + loaded: { readonly inputs: PrivateInputs }, + phase: "active" | "stopped", + deadline: number, + assertOwner: () => Promise +): Promise { + const binding = loaded.inputs.binding; + if (binding.binding_version !== 14) { + refuse(); + } + await assertLegacyComposeRetainedRoutingState({ + binding, + routing: retainedRoutingIntent(loaded.inputs), + proof: binding.routing, + phase, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + deadline, + assertOwner, + }); +} +function selectedMapper(routing: boolean, basic: boolean) { + if (routing) { + return mapLegacyNativeRetainedRouting; + } + if (basic) { + return mapLegacyNativeRetainedBasicBuild; + } + return mapLegacyNativeStorageAdoption; +} +function selectedPlanner(routing: boolean, basic: boolean) { + if (routing) { + return planLegacyComposeRetainedRoutingAdoption; + } + if (basic) { + return planLegacyComposeRetainedBasicBuildAdoption; + } + return planLegacyComposeAdoption; +} async function readInputs( ctx: Context, selected: Anchor, @@ -575,13 +736,14 @@ async function readInputs( meta.files.candidate ); const basic = meta.adoption_generation_version === 9; - const mapped = ( - basic ? mapLegacyNativeRetainedBasicBuild : mapLegacyNativeStorageAdoption - )({ configText, composeText }); - const planned = ( + const routing = meta.adoption_generation_version === 14; + if (routing && meta.routingRoot !== ctx.routingRoot) { + refuse(); + } + const mapped = selectedMapper(routing, basic)({ configText, composeText }); + const planned = selectedPlanner( + routing, basic - ? planLegacyComposeRetainedBasicBuildAdoption - : planLegacyComposeAdoption )({ configText, composeText }); const assertBuildSource = () => assertRetainedBuildSource({ ctx, meta, configText, composeText }); @@ -607,6 +769,7 @@ async function readInputs( const projection = (meta.adoption_generation_version === 5 && meta.projectionProof !== undefined) || + meta.adoption_generation_version === 14 || meta.adoption_generation_version === 3 || meta.adoption_generation_version === 4 ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) @@ -647,6 +810,38 @@ async function readInputs( if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } + if (routing) { + if (!meta.routingClaims || observed.binding_version !== 14) { + refuse(); + } + const claims = await openRetainedRoutingClaims( + ctx, + selected.id, + observed + ); + try { + const attempt = await claims.reopen(meta.routingClaims); + if ( + attempt.reference.generationIdentity !== selected.id || + JSON.stringify(attempt.hostnames) !== + JSON.stringify( + retainedRoutingIntent({ configText, composeText }) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort() + ) + ) { + refuse(); + } + const current = preparing ? undefined : await publicationState(ctx); + if (current?.routingHandoff !== "releasing") { + await claims.assertHeld(meta.routingClaims); + } + } finally { + await claims.close(); + } + } const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ binding: observed, composeFile: join(generationRoot, "legacy-compose.yml"), @@ -702,6 +897,10 @@ async function readInputs( binding: observed, ...(projection ? { projectionMetadata: projection.metadata } : {}), ...(basic ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + ...(projection?.routingResolution + ? { routingResolution: projection.routingResolution } + : {}), }), }; } finally { @@ -735,6 +934,11 @@ async function save( if (opts) { await opts.beforeCommit(); } + if (value.adoption_receipt_version === 14) { + // New routing proofs may await ingress and source owners. Preserve the + // exact receipt incarnation after that last awaited admission boundary. + await requireReceiptSnapshot(ctx, expected); + } await rename(temporary, ctx.receiptPath); await ctx.directories.at(-2)?.file.sync(); const published = await json(ctx.receiptPath); @@ -776,6 +980,12 @@ function manifestVersion( readonly projectionProof: { readonly projection_version: number }; } ): Manifest["adoption_generation_version"] { + if (binding.binding_version === 14) { + if (requiresV5 || projection?.projectionProof.projection_version !== 3) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + return 14; + } if (binding.binding_version === 5) { if (requiresV5 || projection) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); @@ -847,10 +1057,9 @@ async function prepare( ) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } - const mapped = ( - acquired.build - ? mapLegacyNativeRetainedBasicBuild - : mapLegacyNativeStorageAdoption + const mapped = selectedMapper( + acquired.binding.binding_version === 14, + Boolean(acquired.build) )(acquired); if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); @@ -890,7 +1099,19 @@ async function prepare( const generationRoot = join(ctx.generationsRoot, id); await mkdir(generationRoot, { mode: 0o700 }); const held = await holdDirectory(generationRoot, true); + let routeClaims: NativeComposeRouteClaims | undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + let prepared = false; try { + if (acquired.binding.binding_version === 14) { + routeClaims = await openRetainedRoutingClaims(ctx, id, acquired.binding); + routeAttempt = await routeClaims.acquire({ + generationIdentity: id, + hostnames: retainedRoutingIntent(acquired).routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ), + }); + } const files = { config: await writeArtifact( join(generationRoot, "legacy-config.json"), @@ -919,6 +1140,12 @@ async function prepare( projectRoot: ctx.root, id, binding: acquired.binding, + ...(routeAttempt + ? { + routingClaims: routeAttempt.reference, + routingRoot: ctx.routingRoot, + } + : {}), ...(acquired.build ? { buildProof: acquired.build } : {}), ...(acquired.projection ? { projectionProof: acquired.projection.projectionProof } @@ -945,9 +1172,25 @@ async function prepare( await recheckDirectories([held]); await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); await ctx.check(); + if (routeAttempt) { + await routeClaims?.assertHeld(routeAttempt.reference); + await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); + await ctx.check(); + } + prepared = true; return { id, manifest: { ...fileIdentity(written), hash: hash(text) } }; } finally { - await held.file.close(); + try { + try { + if (!prepared && routeAttempt) { + await routeClaims?.rollback(routeAttempt); + } + } finally { + await routeClaims?.close(); + } + } finally { + await held.file.close(); + } } } @@ -1385,10 +1628,19 @@ async function completePublication( if (!publication || publication.phase !== "switching") { refuse(); } + if ( + state.adoption_receipt_version === 14 && + state.routingHandoff !== "held" + ) { + refuse(); + } + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); const loaded = await readInputs(ctx, publication.generation); await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); + await assertPublicationRoutingStopped(ctx, loaded, state, routingDeadline); const held = await holdDirectory( join(ctx.generationsRoot, publication.generation.id, "originals"), true @@ -1445,7 +1697,18 @@ async function completePublication( ...current, publication: { ...installed, phase: "active" }, }, - current + current, + loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline + ), + } + : undefined ); } finally { await held.file.close(); @@ -1456,6 +1719,8 @@ async function completeRollback(ctx: Context, state: Receipt) { if (!publication || publication.phase !== "rolling-back") { refuse(); } + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); const loaded = await readInputs(ctx, publication.generation); await requireStopped(ctx, loaded.inputs.binding); const held = await holdDirectory( @@ -1508,19 +1773,116 @@ async function completeRollback(ctx: Context, state: Receipt) { await readInputs(transaction, publication.generation); await requireStopped(transaction, loaded.inputs.binding); await recheckDirectories([held]); + if (loaded.inputs.retainedRouting) { + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + if (current.routingHandoff !== "releasing") { + current = await save( + transaction, + { ...current, routingHandoff: "releasing" }, + current, + { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + ); + } + const routingGeneration = publication.generation.id; + const claims = await openRetainedRoutingClaims( + transaction, + routingGeneration, + loaded.inputs.binding + ); + try { + await claims.releaseRetained({ + assertStoppedAndRestored: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: routingGeneration, + claim, + handoff: true, + }); + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + }, + }); + } finally { + await claims.close(); + } + } await save( transaction, { ...current, publication: { ...publication, phase: "rolled-back" }, }, - current + current, + loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + : undefined ); } finally { await held.file.close(); } } +/** Retained originals remain present. This proves their stopped state and no + * proxy route; the native ABSENT-only release boundary remains unchanged. */ +async function assertPublicationRoutingStopped( + ctx: Context, + loaded: Awaited>, + state: Receipt, + deadline: number, + restored = false +): Promise { + if (!loaded.inputs.retainedRouting) { + return; + } + if ( + state.pendingOperation !== null || + state.routingOperation?.disposition === "prospective" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } + await assertRetainedRouteState(ctx, loaded, "stopped", deadline, async () => { + await readInputs(ctx, state.prepared ?? refuse()); + await requireStopped(ctx, loaded.inputs.binding); + if (restored) { + await requireRestoredRoutingSourceInputs(ctx, loaded); + } + await requireReceiptSnapshot(ctx, state); + cancelled(ctx.signal); + if (Date.now() >= deadline) { + refuse(); + } + }); +} + type MutationOptions = Parameters< LegacyComposeAdoptedGenerationStore["withMutation"] >[0]; @@ -1530,7 +1892,8 @@ function requireMutationDeadline( ) { if ( (legacyComposeRetainedOrdered(plan) || - captured.generation.report.adoption_generation_version === 9) && + captured.generation.report.adoption_generation_version === 9 || + captured.generation.report.adoption_generation_version === 14) && (captured.deadline === undefined || !Number.isFinite(captured.deadline) || captured.deadline <= Date.now()) @@ -1590,7 +1953,8 @@ function preparedReceiptVersion( prior.adoption_receipt_version === 9 || prior.adoption_receipt_version === 7 || prior.adoption_receipt_version === 10 || - prior.adoption_receipt_version === 11 + prior.adoption_receipt_version === 11 || + prior.adoption_receipt_version === 14 ) { return "kind" in checkout.git ? 2 : 1; } @@ -1642,6 +2006,32 @@ async function requirePreparedSourceInputs( } await ctx.check(); } +/** Authenticated restoration preserves the original inode and bytes, while its + * link/unlink transaction changes ctime. Initial preparation remains strict. */ +async function requireRestoredRoutingSourceInputs( + ctx: Context, + loaded: Awaited> +) { + if ( + loaded.manifest.adoption_generation_version !== 14 || + loaded.inputs.retainedRouting !== true + ) { + refuse(); + } + await requireFirstSliceLayout(ctx, loaded.inputs); + if (!(await absent(join(ctx.root, ".hack/hack.project.json")))) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + for (const location of originalLocations( + ctx, + { id: loaded.manifest.id }, + loaded.manifest, + loaded.inputs + )) { + await requireOriginal(ctx, location.active, location); + } + await ctx.check(); +} async function requireMutationInputs( ctx: Context, active: Publication | null, @@ -1664,7 +2054,7 @@ async function mutateRetainedContainers( refuse(); } if ( - ![5, 7, 9, 10].includes( + ![5, 7, 9, 10, 14].includes( captured.generation.report.adoption_generation_version ) ) { @@ -1740,221 +2130,362 @@ async function mutateRetainedContainersWithinBudget( } const loaded = await readInputs(ctx, owned); await requireMutationInputs(ctx, activePublication, loaded); - await admitCandidate(ctx, loaded.inputs, captured.binary); - const services = loaded.inputs.binding.containers.map( - (container) => container.service - ); - const selectedServices = captured.services.length - ? captured.services - : services; - const retainedPlan = legacyComposeRetainedPlan( - JSON.parse(loaded.inputs.candidateText) - ); - if ( - (legacyComposeRetainedOrdered(retainedPlan) || - loaded.inputs.retainedBuild) && - JSON.stringify([...selectedServices].sort()) !== - JSON.stringify([...services].sort()) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); - } - validateMutationSelection( - state, - { ...captured, services: selectedServices }, - services - ); - const observed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - observed.some( - (value) => - value.paused || !["created", "running", "exited"].includes(value.status) - ) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + const routing = loaded.inputs.retainedRouting === true; + if (routing && state.routingHandoff !== "held") { + refuse("E_LEGACY_ADOPTION_BUSY"); } - if (!captured.recover) { - requireMutationDeadline(captured, retainedPlan); - state = await save( - ctx, - { - ...state, - pendingOperation: { - generation: owned, - operation: captured.operation, - services: selectedServices, - }, - }, - state + const priorUnknown = + routing && + captured.recover === true && + state.routingOperation?.disposition === "prospective"; + const routeClaims = routing + ? await openRetainedRoutingClaims(ctx, owned.id, loaded.inputs.binding) + : undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + try { + await admitCandidate(ctx, loaded.inputs, captured.binary); + const services = loaded.inputs.binding.containers.map( + (container) => container.service ); - } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - requireMutationDeadline(captured, retainedPlan); - let callbackOpen = true; - const privateInput = privateResult({ - configText: loaded.inputs.configText, - composeText: loaded.inputs.composeText, - candidateText: loaded.inputs.candidateText, - binding: loaded.inputs.binding, - ...(loaded.inputs.projectionMetadata - ? { projectionMetadata: loaded.inputs.projectionMetadata } - : {}), - retainedPlan, - ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), - assertFresh: async () => { + const selectedServices = captured.services.length + ? captured.services + : services; + const retainedPlan = legacyComposeRetainedPlan( + JSON.parse(loaded.inputs.candidateText) + ); + if ( + (legacyComposeRetainedOrdered(retainedPlan) || + loaded.inputs.retainedBuild || + routing) && + JSON.stringify([...selectedServices].sort()) !== + JSON.stringify([...services].sort()) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + validateMutationSelection( + state, + { ...captured, services: selectedServices }, + services + ); + const observed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + if ( + observed.some( + (value) => + value.paused || + !["created", "running", "exited"].includes(value.status) + ) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + if (!captured.recover) { + requireMutationDeadline(captured, retainedPlan); + routeAttempt = routeClaims + ? await routeClaims.acquire({ + generationIdentity: owned.id, + hostnames: retainedRoutingIntent(loaded.inputs).routes.flatMap( + (route) => route.origins.map((origin) => new URL(origin).hostname) + ), + }) + : undefined; + state = await save( + ctx, + { + ...state, + pendingOperation: { + generation: owned, + operation: captured.operation, + services: selectedServices, + }, + ...(routeAttempt + ? { + routingOperation: { + generation: owned, + token: token(), + reference: routeAttempt.reference, + disposition: "prospective" as const, + code: null, + }, + } + : {}), + }, + state + ); + } else if (routing) { + const previous = state.routingOperation; + if ( + !previous || + JSON.stringify(previous.generation) !== JSON.stringify(owned) || + !routeClaims + ) { + refuse(); + } + routeAttempt = await routeClaims.reopen(previous.reference); + await routeClaims.assertHeld(previous.reference); + if (!priorUnknown) { + state = await save( + ctx, + { + ...state, + routingOperation: { + ...previous, + disposition: "prospective", + code: null, + }, + }, + state + ); + } + } + if (routeClaims && routeAttempt && !captured.recover) { + await routeClaims.markEffectsPossible(routeAttempt); + } + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + let callbackOpen = true; + const assertActive = () => { if (!callbackOpen) { refuse(); } - const current = await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, current); - await requireReceiptSnapshot(ctx, state); + cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }, - }); - let outcome: LegacyComposeRetainedOutcome; - try { - outcome = await captured.run(privateInput); - } finally { - callbackOpen = false; - } - await ctx.check(); - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - const completed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - const ids = new Set( - loaded.inputs.binding.containers - .filter((container) => selectedServices.includes(container.service)) - .map((container) => container.id) - ); - if (typeof outcome === "number" && outcome !== 0) { - return outcome; - } - const jobAttempts = - retainedPlan.requiresV7 && captured.operation !== "stop" - ? consumeLegacyComposeJobCompletion({ - outcome, - plan: retainedPlan, - binding: privateInput.binding, - operation: captured.operation, - deadline: captured.deadline ?? 0, - assertFresh: privateInput.assertFresh, - }) - : undefined; - if (!jobAttempts && outcome !== 0) { - refuse("E_LEGACY_ADOPTION_CHANGED"); - } - const jobIds = new Set( - retainedPlan.ordered - .filter((item) => item.kind === "job") - .map( - (item) => - loaded.inputs.binding.containers.find( - (container) => container.service === item.service - )?.id - ) - ); - requireRetainedCompletionState({ - completed, - ids, - jobIds, - operation: captured.operation, - }); - const confirmV7Commit = async () => { + }; + const privateInput = privateResult({ + configText: loaded.inputs.configText, + composeText: loaded.inputs.composeText, + candidateText: loaded.inputs.candidateText, + binding: loaded.inputs.binding, + ...(loaded.inputs.projectionMetadata + ? { projectionMetadata: loaded.inputs.projectionMetadata } + : {}), + retainedPlan, + ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + assertActive, + assertFresh: async () => { + assertActive(); + const current = await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, current); + await requireReceiptSnapshot(ctx, state); + assertActive(); + }, + }); + let outcome: LegacyComposeRetainedOutcome; + try { + outcome = await captured.run(privateInput); + } finally { + callbackOpen = false; + } + if (routing) { + const code = consumeLegacyComposeRoutingCompletion({ + outcome, + input: privateInput, + operation: captured.operation, + deadline: captured.deadline ?? 0, + }); + if (priorUnknown) { + // A fresh stop cannot establish the disposition of an earlier unknown child. + // No claim, receipt or original resource may be retired from engine absence. + refuse("E_LEGACY_ADOPTION_BUSY"); + } + const pendingRoute = state.routingOperation; + if (!pendingRoute) { + refuse(); + } + state = await save( + ctx, + { + ...state, + routingOperation: { ...pendingRoute, disposition: "settled", code }, + }, + state + ); + outcome = code; + } + await ctx.check(); await readInputs(ctx, owned); await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - if (jobAttempts) { - const finalRows = legacyComposeJobStates({ - binding: loaded.inputs.binding, - observed: await inspectLegacyComposeJobStates({ + const completed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + const ids = new Set( + loaded.inputs.binding.containers + .filter((container) => selectedServices.includes(container.service)) + .map((container) => container.id) + ); + if (typeof outcome === "number" && outcome !== 0) { + return outcome; + } + const jobAttempts = + retainedPlan.requiresV7 && captured.operation !== "stop" + ? consumeLegacyComposeJobCompletion({ + outcome, + plan: retainedPlan, + binding: privateInput.binding, + operation: captured.operation, + deadline: captured.deadline ?? 0, + assertFresh: privateInput.assertFresh, + }) + : undefined; + if (!jobAttempts && outcome !== 0) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + const jobIds = new Set( + retainedPlan.ordered + .filter((item) => item.kind === "job") + .map( + (item) => + loaded.inputs.binding.containers.find( + (container) => container.service === item.service + )?.id + ) + ); + requireRetainedCompletionState({ + completed, + ids, + jobIds, + operation: captured.operation, + }); + const confirmV7Commit = async () => { + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + if (jobAttempts) { + const finalRows = legacyComposeJobStates({ binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }), + observed: await inspectLegacyComposeJobStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }), + }); + const serviceRequired = retainedPlan.ordered + .filter((item) => item.kind !== "job") + .map((item) => ({ + service: item.service, + condition: item.healthy ? ("ready" as const) : ("started" as const), + })); + if ( + jobAttempts.length !== jobIds.size || + jobAttempts.some((attempt) => { + const row = finalRows.find((item) => item.id === attempt.id); + return ( + !(jobIds.has(attempt.id) && row) || + legacyComposeFreshJobResult({ attempt, observed: row }) !== + "ready" + ); + }) || + !legacyComposeRetainedReady({ + plan: retainedPlan, + ids: new Map( + loaded.inputs.binding.containers.map((item) => [ + item.service, + item.id, + ]) + ), + observed: finalRows, + required: serviceRequired, + }) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + } else { + await requireStopped(ctx, loaded.inputs.binding); + } + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + await ctx.check(); + cancelled(ctx.signal); + requireMutationDeadline(captured, retainedPlan); + }; + if ( + !retainedPlan.requiresV7 && + retainedPlan.requiresV5 && + captured.operation !== "stop" + ) { + const readiness = await inspectLegacyComposeReadiness({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, }); - const serviceRequired = retainedPlan.ordered - .filter((item) => item.kind !== "job") - .map((item) => ({ - service: item.service, - condition: item.healthy ? ("ready" as const) : ("started" as const), - })); if ( - jobAttempts.length !== jobIds.size || - jobAttempts.some((attempt) => { - const row = finalRows.find((item) => item.id === attempt.id); - return ( - !(jobIds.has(attempt.id) && row) || - legacyComposeFreshJobResult({ attempt, observed: row }) !== "ready" - ); - }) || !legacyComposeRetainedReady({ plan: retainedPlan, ids: new Map( - loaded.inputs.binding.containers.map((item) => [ - item.service, - item.id, + loaded.inputs.binding.containers.map((container) => [ + container.service, + container.id, ]) ), - observed: finalRows, - required: serviceRequired, + observed: readiness, }) ) { refuse("E_LEGACY_ADOPTION_CHANGED"); } - } else { - await requireStopped(ctx, loaded.inputs.binding); + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - await ctx.check(); - cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }; - if ( - !retainedPlan.requiresV7 && - retainedPlan.requiresV5 && - captured.operation !== "stop" - ) { - const readiness = await inspectLegacyComposeReadiness({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - !legacyComposeRetainedReady({ - plan: retainedPlan, - ids: new Map( - loaded.inputs.binding.containers.map((container) => [ - container.service, - container.id, - ]) - ), - observed: readiness, - }) - ) { - refuse("E_LEGACY_ADOPTION_CHANGED"); + const confirmRoutingCommit = async () => { + await assertRetainedRouteState( + ctx, + loaded, + captured.operation === "stop" ? "stopped" : "active", + captured.deadline ?? 0, + async () => { + const fresh = await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, fresh); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + } + ); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + }; + if (routeClaims && routeAttempt) { + if (captured.recover) { + await routeClaims.recoverRetainedStopped({ + references: [routeAttempt.reference], + assertStopped: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: owned.id, + claim, + }); + await confirmRoutingCommit(); + }, + }); + } else { + await routeClaims.complete({ + attempt: routeAttempt, + assertTransition: confirmRoutingCommit, + }); + } } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); + let beforeCommit: (() => Promise) | undefined; + if (routing) { + beforeCommit = confirmRoutingCommit; + } else if (retainedPlan.requiresV7) { + beforeCommit = confirmV7Commit; + } + await save( + ctx, + { ...state, pendingOperation: null }, + state, + beforeCommit ? { beforeCommit } : undefined + ); + return 0; + } finally { + await routeClaims?.close(); } - requireMutationDeadline(captured, retainedPlan); - await save( - ctx, - { ...state, pendingOperation: null }, - state, - retainedPlan.requiresV7 ? { beforeCommit: confirmV7Commit } : undefined - ); - return 0; } /** @@ -1989,7 +2520,8 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { mode = input.mode ?? "prepare"; signal = input.signal; const timeoutMs = input.timeoutMs, - capturedRoute = route(); + capturedRoute = route(), + routingRoot = join(resolveGlobalHackDir(), "compose-routing"); cancelled(signal); for (const path of [root, join(root, ".hack")]) { directories.push(await holdDirectory(path, false)); @@ -2049,6 +2581,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { }; const ctx: Context = { root, + routingRoot, checkout, directories, stateRoot, @@ -2127,6 +2660,9 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { prepared: generated, publication: null, pendingOperation: null, + ...(loaded.manifest.adoption_generation_version === 14 + ? { routingOperation: null, routingHandoff: "held" as const } + : {}), }, prior ); @@ -2204,6 +2740,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { refuse(); } const loaded = await readInputs(ctx, owned); + if ( + loaded.inputs.retainedRouting && + state.routingHandoff !== "held" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); diff --git a/src/lib/native-compose-adoption-local.ts b/src/lib/native-compose-adoption-local.ts index 977b81f37..fc9a88118 100644 --- a/src/lib/native-compose-adoption-local.ts +++ b/src/lib/native-compose-adoption-local.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { lstat } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "./guards.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode } from "./native-compose-private-state.ts"; import { NativeConfigCompilerError, @@ -17,6 +18,8 @@ import { parseImportDocument, } from "./native-config-import-parser.ts"; import { freezeImportValue } from "./native-config-import-plan.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; type Source = Extract; type Role = "primary_local" | "checkout_local"; @@ -66,6 +69,7 @@ function check(signal?: AbortSignal) { export function mapLegacyAdoptionLocalInput(opts: { readonly text: string; readonly document: Role; + readonly retainedRouting?: boolean; }) { const parsed = parseImportDocument(opts); const value = parsed.value; @@ -77,14 +81,29 @@ export function mapLegacyAdoptionLocalInput(opts: { value !== undefined && value.schema_version === 1 && Object.keys(value).every( - (key) => key === "schema_version" || key === "environment" + (key) => + key === "schema_version" || + key === "environment" || + (opts.retainedRouting === true && (key === "routes" || key === "open")) ) && (environment === undefined || (isRecord(environment) && Object.keys(environment).every((key) => key === "default_overlay") && (!Object.hasOwn(environment, "default_overlay") || environment.default_overlay === null || - typeof environment.default_overlay === "string"))); + typeof environment.default_overlay === "string"))) && + (!Object.hasOwn(value, "routes") || + (opts.retainedRouting === true && + isRecord(value.routes) && + Object.keys(value.routes).every((key) => key === "domain") && + (!Object.hasOwn(value.routes, "domain") || + typeof value.routes.domain === "string"))) && + (!Object.hasOwn(value, "open") || + (opts.retainedRouting === true && + isRecord(value.open) && + Object.keys(value.open).every((key) => key === "prefer") && + (!Object.hasOwn(value.open, "prefer") || + ["auto", "alias", "dev"].includes(String(value.open.prefer))))); const fields: readonly ImportField[] = parsed.fields.map((field) => ({ ...field, status: valid ? "exact" : "refused", @@ -116,7 +135,9 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { readonly overlay: string | null; readonly binary?: string; readonly signal?: AbortSignal; + readonly routing?: LegacyComposeRoutingIntent; }) { + const routing = opts.routing; check(opts.signal); const checkout = privateNativeConfigImportLocalInput(opts.source); const primary = opts.primary @@ -131,6 +152,7 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { const mapped = mapLegacyAdoptionLocalInput({ text: input.text, document, + retainedRouting: routing !== undefined, }); if (!mapped.complete) { refuseFields(mapped.fields); @@ -139,15 +161,25 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { } } const present = checkout?.text != null || primary?.text != null; - if (present) { + let routingResolution: NativeRoutingResolution | undefined; + if (present || routing) { const resolved = await resolveNativeConfig({ input: new TextEncoder().encode(JSON.stringify(opts.candidate)), primaryLocal: encodedLocal(primary?.text), checkoutLocal: encodedLocal(checkout?.text), binary: opts.binary, signal: opts.signal, + requireRoutingPlanning: routing !== undefined, }); - if (!resolved.ok || resolved.local_resolution.overlay !== opts.overlay) { + if ( + !resolved.ok || + resolved.local_resolution.overlay !== opts.overlay || + (routing && + !legacyComposeRoutingResolutionMatches({ + routing, + resolution: resolved.routing_resolution, + })) + ) { refuseFields( fields.map((field) => ({ ...field, @@ -156,12 +188,16 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { })) ); } + if (resolved.ok) { + routingResolution = resolved.routing_resolution; + } } await opts.source.assertFresh({ signal: opts.signal }); await opts.primary?.assertFresh({ signal: opts.signal }); check(opts.signal); const result = { fields, + routingResolution, proof: present ? { checkout: checkout?.proof ?? null, primary: primary?.proof ?? null } : undefined, @@ -178,6 +214,7 @@ async function assertCheckoutLocal(opts: { readonly projectRoot: string; readonly proof: unknown; readonly signal?: AbortSignal; + readonly retainedRouting?: boolean; }) { const proof = opts.proof; const path = join(opts.projectRoot, ".hack/hack.local.json"); @@ -223,6 +260,7 @@ async function assertCheckoutLocal(opts: { !mapLegacyAdoptionLocalInput({ text: new TextDecoder("utf-8", { fatal: true }).decode(current.bytes), document: "checkout_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); @@ -237,6 +275,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { readonly proof: unknown; readonly signal?: AbortSignal; readonly checkOwner: () => Promise; + readonly retainedRouting?: boolean; }) { check(opts.signal); const proof = opts.proof; @@ -252,6 +291,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { projectRoot: opts.projectRoot, proof: proof.checkout, signal: opts.signal, + retainedRouting: opts.retainedRouting, }); const primary = opts.primary ? privateNativeConfigImportLocalInput(opts.primary) @@ -261,6 +301,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { !mapLegacyAdoptionLocalInput({ text: primary.text, document: "primary_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); diff --git a/src/lib/native-compose-adoption-projection.ts b/src/lib/native-compose-adoption-projection.ts index 3c683ad68..94a60acd5 100644 --- a/src/lib/native-compose-adoption-projection.ts +++ b/src/lib/native-compose-adoption-projection.ts @@ -17,6 +17,7 @@ import { assertSavedLegacyAdoptionLocalInputs, retainLegacyAdoptionLocalRefusal, } from "./native-compose-adoption-local.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode, holdDirectory, @@ -37,9 +38,12 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; +import { parseNativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import { defaultProjectSlugFromPath } from "./project.ts"; import { acquireProjectEnvForLegacyAdoption, @@ -163,6 +167,23 @@ function primarySourceFactory(localInputs: unknown) { : acquireLegacyAdoptionSourceInputs; } +function projectionKeys(version: unknown): string { + if (version === 3) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version,routingResolution"; + } + if (version === 2) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version"; + } + return "generated,inheritPrimaryLocal,managedRevision,primary,projection_version"; +} +function projectionVersion( + admission: LegacyAdoptionManagedEnvAdmission +): number { + if (admission.routingResolution) { + return 3; + } + return admission.localFields.length ? 2 : 1; +} /** Validate and snapshot the bounded private envelope before any async recheck. */ function savedProjectionEnvelope(value: unknown) { const text = JSON.stringify(value); @@ -174,13 +195,11 @@ function savedProjectionEnvelope(value: unknown) { if ( !( isRecord(proof) && - keys( - proof, - proof.projection_version === 2 - ? "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version" - : "generated,inheritPrimaryLocal,managedRevision,primary,projection_version" - ) && + keys(proof, projectionKeys(proof.projection_version)) && (proof.projection_version === 1 || + (proof.projection_version === 3 && + (proof.localInputs === null || isRecord(proof.localInputs)) && + parseNativeRoutingResolution(proof.routingResolution) !== null) || (proof.projection_version === 2 && isRecord(proof.localInputs))) && typeof proof.managedRevision === "string" && typeof proof.inheritPrimaryLocal === "boolean" && @@ -206,7 +225,15 @@ function savedProjectionEnvelope(value: unknown) { inheritPrimaryLocal: proof.inheritPrimaryLocal, generated: proof.generated, primary, - localInputs: proof.projection_version === 2 ? proof.localInputs : undefined, + localInputs: + proof.projection_version === 2 || + (proof.projection_version === 3 && proof.localInputs !== null) + ? proof.localInputs + : undefined, + routingResolution: + proof.projection_version === 3 + ? parseNativeRoutingResolution(proof.routingResolution) + : undefined, }; } @@ -240,7 +267,11 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const proof = savedProjectionEnvelope(opts.proof); check(signal); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); + const mapped = ( + proof.routingResolution + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )({ configText, composeText }); const candidate = mapped.candidate; if ( !( @@ -251,6 +282,28 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { ) { refuse(); } + const routing = proof.routingResolution + ? mapLegacyComposeRouting({ + config: parseImportDocument({ text: configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if ( + proof.routingResolution && + !( + routing && + legacyComposeRoutingResolutionMatches({ + routing, + resolution: proof.routingResolution, + }) + ) + ) { + refuse(); + } const selection = { projectRoot, overlay: @@ -317,6 +370,7 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { proof: proof.localInputs, signal: current.signal ?? signal, checkOwner, + retainedRouting: routing !== undefined, }); } if ( @@ -365,8 +419,10 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { const runtimeText = buildRuntimeHostMetadataOverride({ composeYamls: [composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }); if (runtime && runtime.text !== runtimeText) { @@ -374,6 +430,9 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const result = { candidate: projectRuntimeFallbacks(candidate, runtime), + ...(proof.routingResolution + ? { routingResolution: proof.routingResolution } + : {}), metadata, composeFiles: [ join(projectRoot, ".hack/docker-compose.yml"), @@ -575,18 +634,25 @@ export class LegacyComposeAdoptionProjection { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { const { source, signal } = opts; + const retainedRouting = opts.retainedRouting === true; const admission = await LegacyAdoptionManagedEnvAdmission.acquire({ source, signal, binary: opts.binary, + retainedRouting, }); if (!source.ok) { refuse(); } - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); const candidate = mapped.candidate; if (!(candidate && legacyComposeAdoptionCandidateSupported(candidate))) { refuse(); @@ -598,6 +664,18 @@ export class LegacyComposeAdoptionProjection { if (!(compose && isRecord(compose.services))) { refuse(); } + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose, + })?.intent + : undefined; + if (retainedRouting && !(routing && admission.routingResolution)) { + refuse(); + } const env = await acquireProjectEnvForLegacyAdoption({ admission }); const generated = await acquireGenerated({ projectRoot: source.projectRoot, @@ -617,8 +695,10 @@ export class LegacyComposeAdoptionProjection { runtimeText: buildRuntimeHostMetadataOverride({ composeYamls: [source.composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }), signal, @@ -638,7 +718,7 @@ export class LegacyComposeAdoptionProjection { } const context = this.#context; const result = { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), status: "acquired", admission: "not_performed", files: [ @@ -715,6 +795,9 @@ export class LegacyComposeAdoptionProjection { refuse(); } const candidate = projectRuntimeFallbacks(context.candidate, runtime); + const primary = privatePrimaryProof( + await context.admission.resolvePrivatePrimaryProof() + ); await this.assertFresh({ signal }); const result = { candidate, @@ -728,12 +811,16 @@ export class LegacyComposeAdoptionProjection { metadata: context.env.metadata, localFields: context.admission.localFields, projectionProof: { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), managedRevision: privateLegacyAdoptionEnvRevision(context.env), - ...privatePrimaryProof( - await context.admission.resolvePrivatePrimaryProof() - ), + ...primary, generated: generatedProof(context.generated), + ...(context.admission.routingResolution + ? { + routingResolution: context.admission.routingResolution, + localInputs: primary.localInputs ?? null, + } + : {}), }, }; for (const [key, value] of Object.entries(result)) { diff --git a/src/lib/native-compose-adoption-receipt.ts b/src/lib/native-compose-adoption-receipt.ts index 8a8b75e1b..c69df8f12 100644 --- a/src/lib/native-compose-adoption-receipt.ts +++ b/src/lib/native-compose-adoption-receipt.ts @@ -1,6 +1,10 @@ import { isRecord } from "./guards.ts"; import type { LinkedAdoptionGitIdentity } from "./native-compose-adoption-checkout.ts"; import { keys } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteReference, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; const KIND = "legacy-compose-adopted"; const TOKEN = /^[a-f0-9]{32}$/; @@ -17,12 +21,35 @@ export type Checkout = { }; export type Anchor = { readonly id: string; readonly manifest: Artifact }; export type Receipt = { - readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; + readonly adoption_receipt_version: + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 9 + | 10 + | 11 + | 14; readonly kind: typeof KIND; readonly checkout: Checkout; readonly prepared: Anchor | null; readonly publication: Publication | null; readonly pendingOperation: PendingOperation | null; + /** Required only by v14. Unknown child disposition survives stop containment. */ + readonly routingOperation?: RetainedRoutingOperation | null; + /** Required only by v14; releasing is an interrupted rollback handoff, + * never active workload or publisher admission. */ + readonly routingHandoff?: "held" | "releasing"; +}; +export type RetainedRoutingOperation = { + readonly generation: Anchor; + readonly token: string; + readonly reference: NativeComposeRouteReference; + readonly disposition: "prospective" | "settled"; + readonly code: number | null; }; export type AdoptionOperation = "start" | "restart" | "stop"; export type PendingOperation = { @@ -75,9 +102,12 @@ export function parseLegacyComposeAdoptionReceipt( isRecord(value) && keys( value, - "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" + value.adoption_receipt_version === 14 + ? "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication,routingHandoff,routingOperation" + : "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" ) && - (value.adoption_receipt_version === 11 || + (value.adoption_receipt_version === 14 || + value.adoption_receipt_version === 11 || value.adoption_receipt_version === 10 || value.adoption_receipt_version === 9 || value.adoption_receipt_version === 7 || @@ -96,6 +126,67 @@ export function parseLegacyComposeAdoptionReceipt( ) { refuse(); } + let routingOperation: RetainedRoutingOperation | null | undefined; + if (value.adoption_receipt_version === 14) { + if ( + value.prepared === null || + (value.routingHandoff !== "held" && value.routingHandoff !== "releasing") + ) { + refuse(); + } + if ( + value.routingHandoff === "releasing" && + (value.pendingOperation !== null || + !value.publication || + !["rolling-back", "rolled-back"].includes(value.publication.phase)) + ) { + refuse(); + } + const operation = value.routingOperation; + if (operation === null) { + routingOperation = null; + } else { + if ( + !( + isRecord(operation) && + keys(operation, "code,disposition,generation,reference,token") && + anchor(operation.generation) && + typeof operation.token === "string" && + TOKEN.test(operation.token) && + (operation.disposition === "prospective" + ? operation.code === null + : operation.disposition === "settled" && + Number.isSafeInteger(operation.code) && + typeof operation.code === "number" && + operation.code >= 0 && + operation.code <= 255) && + JSON.stringify(operation.generation) === + JSON.stringify(value.prepared) + ) + ) { + refuse(); + } + routingOperation = { + generation: operation.generation, + token: operation.token, + reference: parseNativeComposeRouteReference(operation.reference), + disposition: + operation.disposition === "prospective" ? "prospective" : "settled", + code: typeof operation.code === "number" ? operation.code : null, + }; + if ( + routingOperation.reference.generationIdentity !== + operation.generation.id || + (routingOperation.disposition === "prospective" && + value.pendingOperation === null) + ) { + refuse(); + } + } + if (value.pendingOperation !== null && routingOperation === null) { + refuse(); + } + } // The distinct job family is issued with a prepared generation, never a bare version upgrade. if (value.adoption_receipt_version === 7 && value.prepared === null) { refuse(); @@ -121,6 +212,7 @@ export function parseLegacyComposeAdoptionReceipt( const version = value.adoption_receipt_version; return { adoption_receipt_version: + version === 14 || version === 11 || version === 10 || version === 9 || @@ -136,6 +228,15 @@ export function parseLegacyComposeAdoptionReceipt( prepared: value.prepared, publication: value.publication, pendingOperation: value.pendingOperation, + ...(version === 14 + ? { + routingOperation: routingOperation ?? null, + routingHandoff: + value.routingHandoff === "held" + ? ("held" as const) + : ("releasing" as const), + } + : {}), }; } function pendingSelectionMatches( diff --git a/src/lib/native-compose-adoption-routing-execution.ts b/src/lib/native-compose-adoption-routing-execution.ts new file mode 100644 index 000000000..8c9c181ca --- /dev/null +++ b/src/lib/native-compose-adoption-routing-execution.ts @@ -0,0 +1,146 @@ +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import { run } from "./shell.ts"; + +const COMPLETE = Symbol("legacy-retained-routing-effect"); +export type LegacyComposeRoutingCompletion = { readonly [COMPLETE]: true }; +type Input = { + readonly binding: LegacyComposeVerifiedBinding; + readonly assertFresh: () => Promise; + readonly assertActive: () => void; +}; +type Witness = { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly code: number; +}; +const completions = new WeakMap(); +function refuse(): never { + throw new Error( + "Retained routing child disposition is uncertain; values omitted." + ); +} +function check(signal: AbortSignal | undefined, deadline: number): void { + if (signal?.aborted || !Number.isFinite(deadline) || deadline <= Date.now()) { + refuse(); + } +} +function absent(pid: number): boolean { + try { + process.kill(-pid, 0); + return false; + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) { + return true; + } + return refuse(); + } +} + +/** One fixed original-ID child. Completion means known return and fresh group + * absence; it does not infer container readiness or terminate an unknown peer. */ +export async function runLegacyComposeRetainedRoutingOperation(opts: { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly signal?: AbortSignal; +}): Promise { + const input = opts.input; + const binding = input.binding; + const projectRoot = binding.projectRoot; + const assertFresh = input.assertFresh; + const assertActive = input.assertActive; + const operation = opts.operation; + const deadline = opts.deadline; + const signal = opts.signal; + const ids = binding.containers.map((row) => row.id); + if ( + binding.binding_version !== 14 || + typeof assertActive !== "function" || + !ids.length || + new Set(ids).size !== ids.length || + !["start", "restart", "stop"].includes(operation) + ) { + refuse(); + } + check(signal, deadline); + assertActive(); + await assertFresh(); + assertActive(); + check(signal, deadline); + let group: number | undefined; + let observationFailed = false; + const code = await run(["docker", "container", operation, ...ids], { + cwd: projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + // This finite data-free child never needs a controlling-terminal handoff. + signal, + timeoutMs: Math.max(1, deadline - Date.now()), + beforeSpawn: () => { + assertActive(); + check(signal, deadline); + }, + onSpawn: (event) => { + if ( + !(event.ownsProcessGroup && Number.isSafeInteger(event.pid)) || + event.pid <= 1 + ) { + observationFailed = true; + } else { + group = event.pid; + } + return Promise.resolve(); + }, + }); + if ( + observationFailed || + group === undefined || + !Number.isSafeInteger(code) || + code < 0 || + code > 255 + ) { + refuse(); + } + const drainDeadline = Math.min(deadline, Date.now() + 3000); + while (!absent(group)) { + if (Date.now() >= drainDeadline) { + refuse(); + } + await Bun.sleep(Math.min(25, drainDeadline - Date.now())); + } + const completion = Object.freeze({ [COMPLETE]: true as const }); + completions.set(completion, { input, operation, deadline, code }); + return completion; +} + +/** Consumed by the same held generation callback; numeric/structural/replayed + * results cannot clear its durable prospective child disposition. */ +export function consumeLegacyComposeRoutingCompletion(opts: { + readonly outcome: unknown; + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; +}): number { + if (typeof opts.outcome !== "object" || opts.outcome === null) { + refuse(); + } + const witness = completions.get(opts.outcome); + if ( + !witness || + witness.input !== opts.input || + witness.operation !== opts.operation || + witness.deadline !== opts.deadline + ) { + refuse(); + } + completions.delete(opts.outcome); + return witness.code; +} diff --git a/src/lib/native-compose-adoption-routing-resolution.ts b/src/lib/native-compose-adoption-routing-resolution.ts new file mode 100644 index 000000000..5c9a197de --- /dev/null +++ b/src/lib/native-compose-adoption-routing-resolution.ts @@ -0,0 +1,51 @@ +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; + +/** Effective typed-local precedence may qualify only the already served origins. */ +export function legacyComposeRoutingResolutionMatches(opts: { + readonly routing: LegacyComposeRoutingIntent; + readonly resolution: NativeRoutingResolution | null | undefined; +}): boolean { + const { routing, resolution } = opts; + if ( + !resolution || + resolution.branch !== undefined || + resolution.project_origin !== `https://${routing.devHost}` || + resolution.open_origin !== routing.openOrigin + ) { + return false; + } + const aliases = routing.aliasHost + ? { oauth: `https://${routing.aliasHost}` } + : {}; + if ( + JSON.stringify(resolution.aliases) !== JSON.stringify(aliases) || + resolution.oauth_alias !== (routing.aliasHost ? "oauth" : null) + ) { + return false; + } + if ( + Object.keys(resolution.routes).sort().join(",") !== + routing.routes + .map((route) => route.service) + .sort() + .join(",") + ) { + return false; + } + return routing.routes.every((route) => { + const found = resolution.routes[route.service]; + const primary = `https://${route.hostname === "project" ? routing.devHost : `${route.hostname}.${routing.devHost}`}`; + const alias = routing.aliasHost + ? `https://${route.hostname === "project" ? routing.aliasHost : `${route.hostname}.${routing.aliasHost}`}` + : null; + return ( + found?.service === route.service && + found.port === route.port && + found.protocol === "http" && + found.origin === primary && + JSON.stringify(found.aliases) === + JSON.stringify(alias ? { oauth: alias } : {}) + ); + }); +} diff --git a/src/lib/native-compose-adoption-routing.ts b/src/lib/native-compose-adoption-routing.ts new file mode 100644 index 000000000..cbd89d0f8 --- /dev/null +++ b/src/lib/native-compose-adoption-routing.ts @@ -0,0 +1,490 @@ +import { isIP } from "node:net"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { + assertBoundNativeComposeProxyRoutes, + assertNativeComposeProxyAccess, + type BoundNativeComposeProxyRoute, +} from "./native-compose-proxy-routes.ts"; +import { NativeComposeRoutingError } from "./native-compose-routing.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; + +const ID = /^[a-f0-9]{64}$/; +const SITE_SEPARATOR = /[\s,]+/; +const CREATED = /^\d{4}-\d\d-\d\dT/; +const CONTAINER = + '{"id":{{json .Id}},"created":{{json .Created}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"native":{{json (index .Config.Labels "io.hack.native-config.owner")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"number":{{json (index .Config.Labels "com.docker.compose.container-number")}},"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy_")) (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy."))}}{"key":{{json $key}},"value":{{json $value}}},{{end}}{{end}}null],"networks":[{{range $key,$value := .NetworkSettings.Networks}}{"name":{{json $key}},"id":{{json $value.NetworkID}},"ip":{{json $value.IPAddress}}},{{end}}null]}'; +const BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const NETWORK_BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const SITES = + '{"id":{{json .Id}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 7) (eq (slice $key 0 6) "caddy_") (eq (len (split $key ".")) 1) (ne $key "caddy_ingress_network"))}}{{json $value}},{{end}}{{end}}null]}'; + +export type LegacyComposeRetainedRoutingProof = { + readonly routing_version: 14; + readonly ingress: NativeComposeIngressBinding; + readonly proxyCreatedAt: string; + readonly networkCreatedAt: string; + readonly originals: readonly { + readonly id: string; + readonly service: string; + readonly createdAt: string; + readonly labels: Readonly>; + }[]; +}; +type Binding = Pick< + LegacyComposeVerifiedBinding, + "engineId" | "composeProject" | "containers" +>; +type Observed = { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly expected: readonly BoundNativeComposeProxyRoute[]; + readonly stopped: boolean; +}; +function refuse(): never { + throw new NativeComposeRoutingError(); +} +function keys(value: Record, wanted: string) { + return Object.keys(value).sort().join(",") === wanted; +} +function rows(text: string): Record[] { + const values: unknown[] = text.trim() + ? text + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (!values.every(isRecord)) { + return refuse(); + } + return values; +} +function birth(text: string, id: string): string { + const values = rows(text), + value = values[0]; + if ( + !( + values.length === 1 && + value && + keys(value, "created,id") && + value.id === id && + typeof value.created === "string" && + CREATED.test(value.created) && + Number.isFinite(Date.parse(value.created)) + ) + ) { + return refuse(); + } + return value.created; +} +function labels(value: unknown): Readonly> { + if (!(Array.isArray(value) && value.at(-1) === null)) { + return refuse(); + } + const result: Record = {}; + for (const pair of value.slice(0, -1)) { + if ( + !( + isRecord(pair) && + keys(pair, "key,value") && + typeof pair.key === "string" && + typeof pair.value === "string" && + !Object.hasOwn(result, pair.key) + ) + ) { + return refuse(); + } + result[pair.key] = pair.value; + } + return Object.freeze( + Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)) + ) + ); +} +function equal(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right); +} +function covers(site: string, hostname: string): boolean { + if (site.startsWith(":")) { + return true; + } + const url = new URL(site.includes("://") ? site : `https://${site}`); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !["http:", "https:"].includes(url.protocol) + ) { + return refuse(); + } + return ( + url.hostname === "*" || + url.hostname === hostname || + (url.hostname.startsWith("*.") && hostname.endsWith(url.hostname.slice(1))) + ); +} +/** Foreign existing site writers, including stopped containers, cannot inherit a retained ID exception. */ +async function inventory( + probe: ReturnType, + binding: Binding, + routing: LegacyComposeRoutingIntent +) { + const hosts = routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ); + const owned = new Set( + binding.containers + .filter((item) => + routing.routes.some((route) => route.service === item.service) + ) + .map((item) => item.id) + ); + for (let pass = 0; pass < 2; pass++) { + const selected = rows( + await probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--format", + '{"id":{{json .ID}}}', + ]) + ); + const ids = selected.map((row) => { + if (!(keys(row, "id") && typeof row.id === "string" && ID.test(row.id))) { + return refuse(); + } + return row.id; + }); + if ( + new Set(ids).size !== ids.length || + [...owned].some((id) => !ids.includes(id)) + ) { + return refuse(); + } + for (let offset = 0; offset < ids.length; offset += 64) { + const batch = ids.slice(offset, offset + 64); + const observed = rows( + await probe(["container", "inspect", "--format", SITES, ...batch]) + ); + const seen = new Set(); + if (observed.length !== batch.length) { + return refuse(); + } + for (const row of observed) { + if ( + !( + keys(row, "id,sites") && + typeof row.id === "string" && + batch.includes(row.id) && + !seen.has(row.id) && + Array.isArray(row.sites) && + row.sites.at(-1) === null && + row.sites.slice(0, -1).every((site) => typeof site === "string") + ) + ) { + return refuse(); + } + seen.add(row.id); + if ( + !owned.has(row.id) && + row.sites.slice(0, -1).some((site: string) => + site + .split(SITE_SEPARATOR) + .filter(Boolean) + .some((value) => + hosts.some((hostname) => covers(value, hostname)) + ) + ) + ) { + return refuse(); + } + } + } + } +} +function snapshot(binding: Binding, routing: LegacyComposeRoutingIntent) { + if ( + routing.version !== 14 || + !binding.containers.length || + new Set(binding.containers.map((row) => row.id)).size !== + binding.containers.length || + binding.containers.some((row) => !ID.test(row.id)) + ) { + return refuse(); + } + const selectedBinding = Object.freeze({ + ...binding, + containers: Object.freeze( + binding.containers.map((item) => Object.freeze({ ...item })) + ), + }); + const selectedRouting = Object.freeze({ + ...routing, + routes: Object.freeze( + routing.routes.map((route) => + Object.freeze({ + ...route, + origins: Object.freeze([...route.origins]), + labels: Object.freeze({ ...route.labels }), + }) + ) + ), + }); + return { binding: selectedBinding, routing: selectedRouting }; +} +async function observe(opts: { + readonly binding: Binding; + readonly routing: LegacyComposeRoutingIntent; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + const { binding, routing } = snapshot(opts.binding, opts.routing); + const signal = opts.signal; + const probe = createNativeComposeProbe({ signal, timeoutMs: opts.timeoutMs }); + const ingress = await observeNativeComposeIngress({ signal }); + if (ingress.engineId !== binding.engineId) { + return refuse(); + } + const proxyCreatedAt = birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ); + const networkCreatedAt = birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ); + const originals: LegacyComposeRetainedRoutingProof["originals"][number][] = + []; + const expected: BoundNativeComposeProxyRoute[] = []; + let stopped = true; + for (const original of binding.containers) { + const found = rows( + await probe(["container", "inspect", "--format", CONTAINER, original.id]) + ); + const row = found[0]; + if ( + !( + found.length === 1 && + row && + keys( + row, + "created,id,native,networks,number,oneoff,paused,project,running,service,sites" + ) && + row.id === original.id && + row.project === binding.composeProject && + (row.native === null || row.native === "") && + row.service === original.service && + row.number === "1" && + (row.oneoff === "False" || row.oneoff === "false") && + typeof row.running === "boolean" && + row.paused === false && + typeof row.created === "string" && + CREATED.test(row.created) && + Number.isFinite(Date.parse(row.created)) && + Array.isArray(row.networks) && + row.networks.at(-1) === null + ) + ) { + return refuse(); + } + const route = routing.routes.find( + (item) => item.service === original.service + ); + const observedLabels = labels(row.sites); + const wantedLabels = Object.fromEntries( + Object.entries(route?.labels ?? {}).sort(([a], [b]) => a.localeCompare(b)) + ); + if (!equal(observedLabels, wantedLabels)) { + return refuse(); + } + const configured = row.networks.slice(0, -1); + const names = new Set(); + for (const item of configured) { + if ( + !( + isRecord(item) && + keys(item, "id,ip,name") && + typeof item.name === "string" && + !names.has(item.name) && + typeof item.id === "string" && + ID.test(item.id) && + typeof item.ip === "string" + ) + ) { + return refuse(); + } + names.add(item.name); + } + if ( + !( + configured.length === (route ? 2 : 1) && + names.has(`${binding.composeProject}_default`) && + names.has(DEFAULT_INGRESS_NETWORK) === Boolean(route) + ) + ) { + return refuse(); + } + const attached = configured.find( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + if (route && !(isRecord(attached) && attached.id === ingress.networkId)) { + return refuse(); + } + originals.push({ + id: original.id, + service: original.service, + createdAt: row.created, + labels: observedLabels, + }); + stopped &&= !row.running; + if (route && row.running) { + if ( + !( + isRecord(attached) && + typeof attached.ip === "string" && + isIP(attached.ip) === 4 + ) + ) { + return refuse(); + } + expected.push({ + service: route.service, + port: route.port, + protocol: "http", + origins: route.origins, + hostnames: route.origins.map((origin) => new URL(origin).hostname), + dials: [`${attached.ip}:${route.port}`], + }); + } + } + await inventory(probe, binding, routing); + await observeNativeComposeIngress({ expected: ingress, signal }); + if ( + birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ) !== proxyCreatedAt || + birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ) !== networkCreatedAt + ) { + return refuse(); + } + return { + proof: Object.freeze({ + routing_version: 14, + ingress, + proxyCreatedAt, + networkCreatedAt, + originals: Object.freeze( + originals.sort((a, b) => a.service.localeCompare(b.service)) + ), + }), + expected, + stopped, + }; +} +/** Snapshot exact original labels/births and shared ingress. This performs no reservation, effect or repair. */ +export async function inspectLegacyComposeRetainedRouting( + opts: Parameters[0] +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const first = await observe(selected); + await assertNativeComposeProxyAccess({ + binding: first.proof.ingress, + signal: selected.signal, + }); + const second = await observe(selected); + if (!equal(first.proof, second.proof)) { + return refuse(); + } + return first.proof; +} +/** Exact live/stopped dispatch under the saved owner. No hostname, ID or upstream is fabricated. */ +export async function assertLegacyComposeRetainedRoutingState( + opts: Parameters[0] & { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly phase: "active" | "stopped"; + readonly deadline: number; + readonly assertOwner: () => Promise; + } +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const expectedProof = structuredClone(opts.proof), + phase = opts.phase, + deadline = opts.deadline, + assertOwner = opts.assertOwner; + if ( + !["active", "stopped"].includes(phase) || + typeof assertOwner !== "function" || + !Number.isFinite(deadline) || + deadline <= Date.now() + ) { + return refuse(); + } + const remaining = AbortSignal.timeout(Math.ceil(deadline - Date.now())); + const signal = selected.signal + ? AbortSignal.any([selected.signal, remaining]) + : remaining; + const observeExpected = async () => { + await assertOwner(); + const current = await observe({ ...selected, signal }); + if ( + !equal(current.proof, expectedProof) || + (phase === "active" + ? current.expected.length !== selected.routing.routes.length + : !current.stopped) + ) { + return refuse(); + } + await assertOwner(); + return phase === "active" ? current.expected : []; + }; + await observeExpected(); + await assertBoundNativeComposeProxyRoutes({ + binding: expectedProof.ingress, + observeExpected, + absentHostnames: + phase === "stopped" + ? selected.routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + : [], + signal, + deadline, + }); + await observeExpected(); + if (signal.aborted || Date.now() >= deadline) { + return refuse(); + } +} diff --git a/src/lib/native-compose-adoption-runtime.ts b/src/lib/native-compose-adoption-runtime.ts index 54cc585c5..31f287ab5 100644 --- a/src/lib/native-compose-adoption-runtime.ts +++ b/src/lib/native-compose-adoption-runtime.ts @@ -66,7 +66,8 @@ export async function inspectLegacyComposeRuntimeConfig(opts: { opts.composeFile, ...(opts.binding.binding_version === 2 || opts.binding.binding_version === 4 || - opts.binding.binding_version === 6 + opts.binding.binding_version === 6 || + opts.binding.binding_version === 14 ? opts.binding.composeFiles.slice(1) : []), ]; diff --git a/src/lib/native-compose-open.ts b/src/lib/native-compose-open.ts index 1d49de7d8..025dd44ba 100644 --- a/src/lib/native-compose-open.ts +++ b/src/lib/native-compose-open.ts @@ -1,5 +1,7 @@ import { CliUsageError } from "../cli/command.ts"; import { HackCliError } from "./cli-result.ts"; +import { selectLegacyComposeAdoptedRoot } from "./native-compose-adoption-command.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "./native-compose-adoption-generation.ts"; import { openNativeComposeGenerationStore } from "./native-compose-generation.ts"; import { readNativeComposeRouteMetadata } from "./native-compose-route-owner.ts"; import { @@ -25,6 +27,16 @@ function unsupported(): never { }); } +function invalidSavedRouting(error: unknown): never { + if (error instanceof HackCliError || error instanceof CliUsageError) { + throw error; + } + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Saved native routing selection is invalid; values omitted.", + }); +} + function preferredOrigin(opts: { readonly origin: string; readonly alias?: string; @@ -90,12 +102,51 @@ export function resolveNativeComposeOpenOrigin(opts: { /** * Select native inputs before legacy context/registration. Read the immutable saved - * routing report under its generation lease; never compile, decrypt, observe Docker, - * or invent a host from current authored input merely to answer `open`. + * routing report under its generation lease. Retained routing also rechecks its + * original resource owner; neither path compiles, decrypts or invents a host. */ export async function tryNativeComposeOpen( - opts: NativeComposeOpenOptions + input: NativeComposeOpenOptions ): Promise { + const opts = { ...input }; + const adopted = await selectLegacyComposeAdoptedRoot(opts); + if (adopted) { + requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND }); + if (opts.instance !== undefined) { + return unsupported(); + } + try { + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot: adopted, + mode: "saved", + }); + try { + const generation = await store.loadActive(); + if (!generation) { + return unsupported(); + } + return await store.withLease({ + generation, + run: (input) => { + if (!(input.retainedRouting && input.routingResolution)) { + return unsupported(); + } + return Promise.resolve( + resolveNativeComposeOpenOrigin({ + resolution: input.routingResolution, + target: opts.target, + prefer: opts.prefer, + }) + ); + }, + }); + } finally { + await store.close(); + } + } catch (error: unknown) { + return invalidSavedRouting(error); + } + } const selected = await selectNativeComposeProject(opts); if (!selected) { return null; @@ -142,12 +193,6 @@ export async function tryNativeComposeOpen( await store.close(); } } catch (error: unknown) { - if (error instanceof HackCliError || error instanceof CliUsageError) { - throw error; - } - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: "Saved native routing selection is invalid; values omitted.", - }); + return invalidSavedRouting(error); } } diff --git a/src/lib/native-compose-proxy-routes.ts b/src/lib/native-compose-proxy-routes.ts index cac5a1c51..7633b531c 100644 --- a/src/lib/native-compose-proxy-routes.ts +++ b/src/lib/native-compose-proxy-routes.ts @@ -39,6 +39,9 @@ type HostScope = { type ExpectedRoute = NativeComposeProxyRoute & { readonly dials: readonly string[]; }; + +/** Read-only route projection type; supplying it grants no resource or effect authority. */ +export type BoundNativeComposeProxyRoute = ExpectedRoute; const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; async function readActiveProxy(opts: { readonly binding: NativeComposeIngressBinding; @@ -712,3 +715,92 @@ export async function assertNativeComposeProxyRoutes(opts: { refused(); } } + +/** + * Read-only dispatch/TLS check for a separately issued retained-resource owner. + * The callback must obtain current exact original IDs, labels and ingress IPs + * under its source/receipt lease. It is reread after each successful proxy proof; + * no native owner label or caller-provided old IP substitutes for that owner. + */ +export async function assertBoundNativeComposeProxyRoutes(opts: { + readonly binding: NativeComposeIngressBinding; + readonly observeExpected: () => Promise< + readonly BoundNativeComposeProxyRoute[] + >; + readonly absentHostnames: readonly string[]; + readonly signal?: AbortSignal; + readonly deadline: number; +}): Promise { + try { + const binding = Object.freeze({ ...opts.binding }); + const observeExpected = opts.observeExpected; + const absentHostnames = Object.freeze([...opts.absentHostnames]); + const deadline = opts.deadline; + const remaining = deadline - Date.now(); + if ( + !Number.isFinite(remaining) || + remaining <= 0 || + typeof observeExpected !== "function" + ) { + return refused(); + } + const bounded = AbortSignal.timeout(Math.ceil(remaining)); + const signal = opts.signal + ? AbortSignal.any([opts.signal, bounded]) + : bounded; + const readExpected = async () => { + const value = await observeExpected(); + return value.map((route) => + Object.freeze({ + ...route, + hostnames: Object.freeze([...route.hostnames]), + origins: Object.freeze([...route.origins]), + dials: Object.freeze([...route.dials]), + }) + ); + }; + const readActive = async ( + expected: readonly BoundNativeComposeProxyRoute[] + ) => { + const selected = { binding, signal }; + const servers = await readActiveProxy(selected); + const projection = { servers, expected, absentHostnames }; + if (!projectedRoutesMatch(projection, true)) { + return false; + } + const origins = automaticHttpsOrigins(servers, expected); + if (origins.length) { + try { + await verifyAutomaticHttps({ ...selected, origins }); + } catch { + return false; + } + } + return projectedRoutesMatch( + { ...projection, servers: await readActiveProxy(selected) }, + true + ); + }; + while (!signal.aborted && Date.now() < deadline) { + await observeNativeComposeIngress({ expected: binding, signal }); + const expected = await readExpected(); + if (await readActive(expected)) { + const after = await readExpected(); + if (JSON.stringify(expected) !== JSON.stringify(after)) { + return refused(); + } + await observeNativeComposeIngress({ expected: binding, signal }); + if (await readActive(after)) { + if (signal.aborted || Date.now() >= deadline) { + return refused(); + } + return; + } + } + await Bun.sleep(Math.min(500, Math.max(0, deadline - Date.now()))); + } + refused(); + } catch { + refused(); + } +} diff --git a/src/lib/native-compose-route-claims.ts b/src/lib/native-compose-route-claims.ts index b7c1448dc..0c8cbd6ad 100644 --- a/src/lib/native-compose-route-claims.ts +++ b/src/lib/native-compose-route-claims.ts @@ -257,6 +257,16 @@ function snapshotReference( }), }); } + +/** Strict private reference decoder shared with retained-generation receipts. */ +export function parseNativeComposeRouteReference( + value: unknown +): NativeComposeRouteReference { + if (!referenceValid(value)) { + refuse(); + } + return snapshotReference(value); +} function freezeAttempt( attempt: NativeComposeRouteAttempt ): NativeComposeRouteAttempt { @@ -448,6 +458,8 @@ export type NativeComposeRouteClaims = { reopen( reference: NativeComposeRouteReference ): Promise; + /** Read-only: the referenced original claim tokens and inodes remain active. */ + assertHeld(reference: NativeComposeRouteReference): Promise; /** Synchronize uncertain intent before invoking any engine child/effect. */ markEffectsPossible(attempt: NativeComposeRouteAttempt): Promise; /** Only the live armed attempt can complete; reopened uncertainty cannot. */ @@ -490,6 +502,22 @@ export type NativeComposeRouteClaims = { NativeComposeRouteClaims["release"] >[0]["assertAbsent"]; }): Promise; + /** Retained legacy owner only: exact original containers remain stopped. The + * saved generation must prove known child settlement and route absence. This + * marks referenced uncertainty stopped while keeping every claim held. */ + recoverRetainedStopped(opts: { + readonly references: readonly NativeComposeRouteReference[]; + readonly assertStopped: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; + /** Retained rollback handoff only, after exact stopped-original, known-child, + * route-absence and restored-source proofs. Uncertain journals still veto. */ + releaseRetained(opts: { + readonly assertStoppedAndRestored: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; close(): Promise; }; @@ -1058,6 +1086,7 @@ export async function openNativeComposeRouteClaims(opts: { }; const retire = async (options: { readonly verifyOnly?: boolean; + readonly keepClaims?: boolean; readonly keepHostnames?: readonly string[]; readonly references?: readonly NativeComposeRouteReference[]; readonly assertAbsent: Parameters< @@ -1068,6 +1097,7 @@ export async function openNativeComposeRouteClaims(opts: { const assertAbsent = options.assertAbsent; const references = options.references?.map(snapshotReference); const verifyOnly = options.verifyOnly === true; + const keepClaims = options.keepClaims === true; if (typeof assertAbsent !== "function") { refuse(); } @@ -1112,6 +1142,9 @@ export async function openNativeComposeRouteClaims(opts: { ); } } + if (keepClaims) { + return; + } await publish(join(releasesRoot, `${hash(token())}.json`), { version: 1, binding, @@ -1191,6 +1224,28 @@ export async function openNativeComposeRouteClaims(opts: { await activeEntries(records); return result; }), + assertHeld: (reference) => { + const snapshot = snapshotReference(reference); + return guard(async () => { + const records = await journals(); + const record = find(records, snapshot); + const current = await activeEntries(records); + if ( + record.aborted || + record.intent.claims.some((claim) => { + const expected = record.entries?.find((entry) => + equal(entry.claim, claim) + ); + return !( + expected && equal(current.entries.get(claim.hostname), expected) + ); + }) + ) { + refuse(); + } + await check(); + }); + }, markEffectsPossible: (attempt) => guard(async () => { const { reference } = capability(attempt); @@ -1278,6 +1333,17 @@ export async function openNativeComposeRouteClaims(opts: { assertAbsent: options.assertAbsent, }) ), + recoverRetainedStopped: (options) => { + const references = options.references.map(snapshotReference); + const assertAbsent = options.assertStopped; + return guard(() => + retire({ references, assertAbsent, keepClaims: true }) + ); + }, + releaseRetained: (options) => { + const assertAbsent = options.assertStoppedAndRestored; + return guard(() => retire({ assertAbsent })); + }, close: async () => { if (!closed) { closed = true; diff --git a/tests/helpers/retained-routing-adoption.ts b/tests/helpers/retained-routing-adoption.ts new file mode 100644 index 000000000..34c375733 --- /dev/null +++ b/tests/helpers/retained-routing-adoption.ts @@ -0,0 +1,615 @@ +import { spyOn } 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 { isRecord } from "../../src/lib/guards.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../../src/lib/native-compose-adoption-routing-execution.ts"; +import * as engine from "../../src/lib/native-compose-engine-identity.ts"; +import * as ownership from "../../src/lib/native-compose-ownership.ts"; +import { mapLegacyNativeRetainedRouting } from "../../src/lib/native-config-import-plan.ts"; +import type { NativeRoutingResolution } from "../../src/lib/native-routing-plan-protocol.ts"; +import * as shell from "../../src/lib/shell.ts"; +import { restoreEnv } from "./env.ts"; + +export const ROUTING_CANARY = "synthetic-retained-sql-row"; +export const ROUTING_IDS = { + db: "a".repeat(64), + web: "b".repeat(64), + network: "c".repeat(64), + ingress: "d".repeat(64), + proxy: "e".repeat(64), + foreign: "f".repeat(64), +}; +const CREATED = "2026-01-01T01:02:03Z", + HASH = "1".repeat(64), + GROUP = 987_654; +const LABELS = { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", +}; +const LIST_FORMATS: Readonly> = { + container: + '{"id":{{json .ID}},"name":{{json .Names}},"project":{{json (.Label "com.docker.compose.project")}}}', + network: + '{"id":{{json .ID}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', + volume: + '{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', +}; +function refuse(): never { + throw new Error( + "Synthetic retained route transport refused; values omitted." + ); +} +function identity(value: unknown) { + if ( + !( + isRecord(value) && + typeof value.dev === "number" && + typeof value.ino === "number" + ) + ) { + return refuse(); + } + return { dev: value.dev, ino: value.ino }; +} + +/** A timeout or unfinished continuation permanently retains the fixture's global + * doubles and private environment. Late settlement cannot grant teardown. */ +export function retainedRoutingFixtureLifetime(deadline: number) { + let unknown = false; + const pending = new Set>(); + const canRestore = () => { + if (Date.now() >= deadline || pending.size !== 0) { + unknown = true; + } + return !unknown; + }; + return { + retain: () => { + unknown = true; + }, + canRestore, + track: (work: Promise): Promise => { + pending.add(work); + work.then( + () => pending.delete(work), + () => pending.delete(work) + ); + return work; + }, + }; +} + +/** Private-store model only. Docker metadata and child effects are injected; + * this does not qualify Go formatting, compiler semantics, TLS, SQL or a daemon. */ +export async function retainedRoutingFixture( + lifetime: ReturnType +) { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-routing-owner-")) + ); + const root = join(outer, "checkout"), + home = join(outer, "hack-home"); + await chmod(outer, 0o700); + await mkdir(join(root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(root, ".git"), { mode: 0o700 }); + await mkdir(home, { mode: 0o700 }); + const config = JSON.stringify({ + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }); + const compose = JSON.stringify({ + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + db: { image: "synthetic/db:1", volumes: ["data:/data"] }, + web: { + image: "synthetic/web:1", + networks: ["default", "hack-dev"], + labels: LABELS, + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/hack.config.json"), config, { + mode: 0o600, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), compose, { + mode: 0o600, + }); + const candidate = mapLegacyNativeRetainedRouting({ + configText: config, + composeText: compose, + }).candidate; + if (!candidate) { + return refuse(); + } + const compiler = join(outer, "compiler"); + const resolution: NativeRoutingResolution = { + domain: "hack.local", + domain_origin: "default", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "project", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + const plan = { + plan_version: 1, + name: "fixture", + services: { db: {}, web: {} }, + jobs: {}, + routes: { + origin: resolution.project_origin, + aliases: { oauth: { origin: resolution.aliases.oauth } }, + oauth_alias: "oauth", + http: { + web: { + service: "web", + port: 3000, + protocol: "http", + hostname: "project", + }, + }, + }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }; + await writeFile( + compiler, + `#!${process.execPath} +const operation=process.argv[2]; +if(operation==='--protocol') console.log(JSON.stringify({transport_version:1,authored_version:1,plan_version:1,resolve_version:1,local_version:1,env_plan_version:1,routing_plan_version:1})); +else { + const raw=JSON.parse(await Bun.stdin.text()), request=operation==='compile'?{}:raw, project=operation==='compile'?raw:JSON.parse(request.project); + if(JSON.stringify(project)!==${JSON.stringify(JSON.stringify(candidate))})process.exit(97); + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{db:'service',web:'service'},plan:${JSON.stringify(plan)}}; + if(operation!=='compile') {result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:false,resolution_hash:'b'.repeat(64)};if(request.routing_probe===true)result.routing_inputs_required=true;else result.routing_resolution=${JSON.stringify(resolution)};} + if(operation==='plan')result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{db:{},web:{}},warnings:[],diagnostics:[]}; + console.log(JSON.stringify(result)); +} +`, + { mode: 0o700 } + ); + const previousHome = process.env.HACK_HOME; + process.env.HACK_HOME = home; + const model = { + running: false, + foreign: false, + wrongDial: false, + partial: false, + proxyAccess: true, + volumeBirth: CREATED, + webBirth: CREATED, + engine: "synthetic-retained-routing", + sqlRow: ROUTING_CANARY, + }; + const commands: string[][] = [], + effects: string[][] = []; + const hooks: { + afterProbe?: (args: readonly string[]) => Promise; + afterEffect?: () => Promise; + } = {}; + const json = (value: unknown) => JSON.stringify(value); + const names = ["db", "web"] as const; + const service = (id: string | undefined) => + names.find((name) => ROUTING_IDS[name] === id) ?? refuse(); + const probe: ReturnType = async ( + input + ) => { + const args = [...input]; + commands.push(args); + const [kind, action] = args, + format = args[args.indexOf("--format") + 1] ?? "", + id = args.at(-1); + let result: string; + if (kind === "info" && action === "--format") { + result = + format === "{{json .ID}}" + ? json(model.engine) + : json({ id: model.engine, os: "linux" }); + } else if ( + kind === "exec" && + args[1] === ROUTING_IDS.proxy && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) { + if (!model.proxyAccess) { + return refuse(); + } + const routes = model.running + ? [ + { + match: [{ host: LABELS.caddy.split(",") }], + handle: [ + { + handler: "reverse_proxy", + upstreams: [ + { + dial: `${model.wrongDial ? "172.28.0.99" : "172.28.0.3"}:3000`, + }, + ], + }, + ], + terminal: true, + }, + ] + : []; + result = `${json({ + srv0: { listen: [":443"], tls_connection_policies: [{}], routes }, + })}\n200`; + } else if (kind === "compose" && args.at(-2) === "--hash" && id === "*") { + const file = args[args.indexOf("--file") + 1]; + if (!file || (await readFile(file, "utf8")) !== compose) { + return refuse(); + } + result = `db ${HASH}\nweb ${HASH}`; + } else if ( + action === "ls" && + kind === "container" && + format === "{{json .ID}}" && + args.includes("label=com.docker.compose.project=hack-dev-proxy") + ) { + result = json(ROUTING_IDS.proxy); + } else if ( + action === "ls" && + ["container", "network", "volume"].includes(kind ?? "") + ) { + if (args.some((arg) => arg.startsWith("label=io.hack.native-config."))) { + result = ""; + } else if (kind === "container" && format === '{"id":{{json .ID}}}') { + result = [ + ROUTING_IDS.db, + ROUTING_IDS.web, + ROUTING_IDS.proxy, + ...(model.foreign ? [ROUTING_IDS.foreign] : []), + ] + .map((value) => json({ id: value })) + .join("\n"); + } else if ( + kind !== undefined && + format === LIST_FORMATS[kind] && + json(args) === + json([ + kind, + "ls", + ...(kind === "container" ? ["--all"] : []), + ...(kind === "volume" ? [] : ["--no-trunc"]), + "--format", + format, + ]) + ) { + result = ( + kind === "container" + ? names.map((name) => ({ + id: ROUTING_IDS[name], + name: `fixture-${name}-1`, + project: "fixture", + })) + : kind === "network" + ? [ + { + id: ROUTING_IDS.network, + name: "fixture_default", + project: "fixture", + }, + ] + : [ + { + id: "fixture_data", + name: "fixture_data", + project: "fixture", + }, + ] + ) + .map(json) + .join("\n"); + } else { + return refuse(); + } + } else if (kind === "network" && action === "inspect") { + if (id === "hack-dev") { + result = json({ id: ROUTING_IDS.ingress, name: "hack-dev" }); + } else if (id === ROUTING_IDS.ingress && format.includes("created")) { + result = json({ id, created: CREATED }); + } else if (id === ROUTING_IDS.network) { + result = json({ + id, + name: "fixture_default", + project: "fixture", + native: "", + logical: "default", + createdAt: CREATED, + driver: "bridge", + scope: "local", + internal: false, + containers: model.running ? [ROUTING_IDS.db, ROUTING_IDS.web] : [], + }); + } else { + return refuse(); + } + } else if ( + kind === "volume" && + action === "inspect" && + id === "fixture_data" + ) { + result = json({ + id, + name: id, + project: "fixture", + native: "", + storage: "data", + createdAt: model.volumeBirth, + driver: "local", + scope: "local", + mountpoint: "/var/lib/docker/volumes/fixture_data/_data", + options: null, + }); + } else if (kind === "container" && action === "inspect") { + if (format.includes('"sites"') && !format.includes('"created"')) { + result = args + .slice(4) + .map((selected) => + json({ + id: selected, + sites: + selected === ROUTING_IDS.web || selected === ROUTING_IDS.foreign + ? [LABELS.caddy, null] + : [null], + }) + ) + .join("\n"); + } else if (id === ROUTING_IDS.proxy) { + result = format.includes('"created"') + ? json({ id, created: CREATED }) + : json({ + id, + project: "hack-dev-proxy", + service: "caddy", + running: true, + network: ROUTING_IDS.ingress, + ip: "172.28.0.2", + }); + } else { + const name = service(id); + if (format.includes('"sites"')) { + result = json({ + id, + created: name === "web" ? model.webBirth : CREATED, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + paused: false, + sites: + name === "web" + ? [ + ...Object.entries(LABELS).map(([key, value]) => ({ + key, + value, + })), + null, + ] + : [null], + networks: [ + { + name: "fixture_default", + id: ROUTING_IDS.network, + ip: model.running ? "172.27.0.3" : "", + }, + ...(name === "web" + ? [ + { + name: "hack-dev", + id: ROUTING_IDS.ingress, + ip: model.running ? "172.28.0.3" : "", + }, + ] + : []), + null, + ], + }); + } else if (format.includes("config-hash")) { + result = json({ id, hash: HASH }); + } else if (format.includes(".Mounts")) { + result = json({ + id, + name: `/fixture-${name}-1`, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + workingDir: join(root, ".hack"), + configFiles: join(root, ".hack/docker-compose.yml"), + mounts: + name === "db" + ? [ + { + type: "volume", + name: "fixture_data", + source: "/var/lib/docker/volumes/fixture_data/_data", + target: "/data", + rw: true, + }, + ] + : [], + networks: [ + { name: "fixture_default", id: ROUTING_IDS.network }, + ...(name === "web" + ? [{ name: "hack-dev", id: ROUTING_IDS.ingress }] + : []), + ], + }); + } else if (format.includes(".State.Running")) { + result = json({ + id, + running: model.running, + paused: false, + status: model.running ? "running" : "exited", + ...(format.includes("Health") ? { health: "" } : {}), + }); + } else { + return refuse(); + } + } + } else { + return refuse(); + } + await hooks.afterProbe?.(args); + return result; + }; + const originalKill = process.kill; + const spies = [ + spyOn(ownership, "createNativeComposeProbe").mockImplementation( + () => probe + ), + spyOn(engine, "createNativeComposeEngineIdentityObserver").mockReturnValue( + null + ), + spyOn(shell, "run").mockImplementation(async (args, opts = {}) => { + opts.beforeSpawn?.(); + if ( + JSON.stringify(args) !== + json([ + "docker", + "container", + args[2], + ROUTING_IDS.db, + ROUTING_IDS.web, + ]) || + !["start", "restart", "stop"].includes(args[2] ?? "") + ) { + return refuse(); + } + await opts.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + effects.push([...args]); + model.running = args[2] !== "stop"; + await hooks.afterEffect?.(); + return model.partial ? 7 : 0; + }), + spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === -GROUP && signal === 0) { + throw Object.assign(new Error("synthetic absent"), { code: "ESRCH" }); + } + return originalKill.call(process, pid, signal); + }), + ]; + const receiptPath = join( + root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + return { + root, + outer, + home, + compiler, + config, + compose, + model, + resolution, + hooks, + commands, + effects, + receiptPath, + canRestore: lifetime.canRestore, + track: lifetime.track, + receipt: async () => { + const value: unknown = JSON.parse(await readFile(receiptPath, "utf8")); + if (!(isRecord(value) && isRecord(value.checkout))) { + return refuse(); + } + return parseLegacyComposeAdoptionReceipt(value, { + root: identity(value.checkout.root), + project: identity(value.checkout.project), + git: identity(value.checkout.git), + }); + }, + store: () => + openLegacyComposeAdoptedGenerationStore({ + projectRoot: root, + timeoutMs: 15_000, + }), + operation: async ( + store: Awaited< + ReturnType + >, + generation: Parameters[0]["generation"], + operation: "start" | "stop", + opts: { + readonly recover?: boolean; + readonly numeric?: boolean; + readonly preparation?: boolean; + } = {} + ) => { + const deadline = Date.now() + 15_000; + const run = async ( + input: Parameters[0]["run"]>[0] + ) => + opts.numeric + ? 0 + : await runLegacyComposeRetainedRoutingOperation({ + input, + operation, + deadline, + }); + return opts.preparation + ? await store.withPreparationStop({ + generation, + binary: compiler, + deadline, + recover: opts.recover, + run, + }) + : await store.withMutation({ + generation, + operation, + services: [], + binary: compiler, + deadline, + recover: opts.recover, + run, + }); + }, + cleanup: async () => { + if (!lifetime.canRestore()) { + return refuse(); + } + for (const spy of spies) { + spy.mockRestore(); + } + restoreEnv("HACK_HOME", previousHome); + await rm(outer, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/native-compose-adoption-routing-execution.test.ts b/tests/native-compose-adoption-routing-execution.test.ts new file mode 100644 index 000000000..e0ab0fbe7 --- /dev/null +++ b/tests/native-compose-adoption-routing-execution.test.ts @@ -0,0 +1,242 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import type { LegacyComposeVerifiedBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { + consumeLegacyComposeRoutingCompletion, + runLegacyComposeRetainedRoutingOperation, +} from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as shell from "../src/lib/shell.ts"; + +const ID = "a".repeat(64), + OTHER = "b".repeat(64), + GROUP = 987_654; +const spies: { mockRestore(): void }[] = []; +afterEach(() => { + for (const spy of spies.splice(0)) { + spy.mockRestore(); + } +}); +function input(assertFresh: () => Promise = async () => {}) { + // Transport-only boundary: the generation tests separately issue this binding. + const binding = { + binding_version: 14, + projectRoot: "/synthetic/retained-routing", + containers: [{ id: ID }, { id: OTHER }], + } as unknown as LegacyComposeVerifiedBinding; + return { binding, assertFresh, assertActive: () => {} }; +} +function missing(): never { + throw Object.assign(new Error("absent"), { code: "ESRCH" }); +} +function owner(code = 0, afterAdmission?: () => void) { + const calls: { argv: readonly string[]; options: shell.RunOptions }[] = []; + spies.push( + spyOn(shell, "run").mockImplementation(async (argv, options = {}) => { + options.beforeSpawn?.(); + afterAdmission?.(); + calls.push({ argv: [...argv], options }); + await options.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + return code; + }) + ); + return calls; +} +test("known child return plus ESRCH issues only a one-use completion for the original callback", async () => { + const calls = owner(17), + probes: number[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push(pid); + expect(signal).toBe(0); + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options).toMatchObject({ + cwd: selected.binding.projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(calls[0]?.options.forwardSignals).toBeUndefined(); + expect(probes).toEqual([-GROUP]); + for (const invalid of [17, {}, { ...outcome }]) { + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome: invalid, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + } + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: { ...selected }, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toThrow(/uncertain/); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toBe(17); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); +}); +test("wrapper return does not prove peer absence; bounded observation accepts only later ESRCH", async () => { + owner(); + let reads = 0; + spies.push( + spyOn(process, "kill").mockImplementation(() => { + if (++reads < 3) { + return true; + } + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "stop", + deadline, + }); + expect(reads).toBe(3); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toBe(0); +}); +for (const code of ["EPERM", "EIO"]) { + test(`group ${code} remains unknown and cannot issue a completion`, async () => { + owner(); + spies.push( + spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("private"), { code }); + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow(/uncertain/); + }); +} +test("retained peer at the captured deadline refuses without sending any signal", async () => { + const calls = owner(); + const probes: unknown[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push([pid, signal]); + return true; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 80, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toHaveLength(1); + expect(probes.length).toBeGreaterThan(0); + expect( + probes.every( + (value) => JSON.stringify(value) === JSON.stringify([-GROUP, 0]) + ) + ).toBe(true); +}); +test("cancellation during final source admission has zero child effects", async () => { + const calls = owner(), + controller = new AbortController(); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(async () => { + controller.abort(); + }), + operation: "start", + deadline: Date.now() + 1000, + signal: controller.signal, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toEqual([]); +}); +test("child IDs and working directory are captured before the final source await", async () => { + const selected = input(async () => { + Reflect.set(selected.binding, "projectRoot", "/foreign"); + Reflect.set(selected.binding, "containers", [{ id: "c".repeat(64) }]); + }); + const calls = owner(); + spies.push(spyOn(process, "kill").mockImplementation(missing)); + await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options.cwd).toBe("/synthetic/retained-routing"); +}); +test("the synchronous spawn fence refuses a callback revoked after fresh admission", async () => { + const selected = input(); + let active = true; + let child = 0; + selected.assertActive = () => { + if (!active) { + throw new Error("revoked; values omitted"); + } + }; + spies.push( + spyOn(shell, "run").mockImplementation(async (_argv, options = {}) => { + await Promise.resolve(); + active = false; + options.beforeSpawn?.(); + child++; + return 0; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow("revoked"); + expect(child).toBe(0); +}); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts new file mode 100644 index 000000000..cf25b52c7 --- /dev/null +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -0,0 +1,488 @@ +import { test as boundedTest, expect, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as privateState from "../src/lib/native-compose-private-state.ts"; +import { + ROUTING_CANARY, + ROUTING_IDS, + retainedRoutingFixture, + retainedRoutingFixtureLifetime, +} from "./helpers/retained-routing-adoption.ts"; + +let h: Awaited>; +let fixtureActive = false; +let fixtureUncertain = false; +const test = (name: string, run: () => Promise) => + boundedTest( + name, + async () => { + if (fixtureActive || fixtureUncertain) { + fixtureUncertain = true; + throw new Error( + "Prior retained routing fixture lifetime is unknown; values omitted." + ); + } + fixtureActive = true; + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 30_000); + let issued = false; + let failed = false; + let failure: unknown; + try { + h = await retainedRoutingFixture(lifetime); + issued = true; + await run(); + } catch (error: unknown) { + failed = true; + failure = error; + } finally { + if (!lifetime.canRestore()) { + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture lifetime is unknown; values omitted." + ); + } + } else if (issued) { + try { + await h.cleanup(); + fixtureActive = false; + } catch (error: unknown) { + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = error; + } + } + } else { + // Setup did not issue a complete fixture; do not reuse its global context. + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture setup is incomplete; values omitted." + ); + } + } + } + if (failed) { + throw failure; + } + }, + 30_000 + ); +async function prepare() { + const store = await h.store(); + try { + return { store, generation: await store.prepare({ binary: h.compiler }) }; + } catch (error: unknown) { + await store.close(); + throw error; + } +} +async function red(value: Promise) { + try { + await value; + throw new Error("unexpected synthetic success"); + } catch (error: unknown) { + expect(String(error)).toMatch(/values omitted/i); + expect(String(error)).not.toContain(ROUTING_CANARY); + expect(String(error)).not.toContain(h.root); + } +} +test("v14 keeps original resources and data through stop/publication/up/down/up/rollback and saved open", async () => { + const { store, generation } = await prepare(); + try { + expect(generation.report.adoption_generation_version).toBe(14); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + for (const operation of ["start", "stop", "start", "stop"] as const) { + expect(await h.operation(store, active, operation)).toBe(0); + } + await store.withLease({ + generation: active, + run: async (input) => { + expect(input.retainedRouting).toBe(true); + expect(input.routingResolution).toEqual(h.resolution); + expect(JSON.stringify(input)).not.toContain("original.hack"); + }, + }); + await store.rollback(); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + h.effects.every( + (args) => + args.length === 5 && + args[3] === ROUTING_IDS.db && + args[4] === ROUTING_IDS.web + ) + ).toBe(true); + expect( + h.commands.some( + (args) => + args.includes("pull") || + args.includes("build") || + args.includes("create") || + args.includes("rm") + ) + ).toBe(false); + } finally { + await store.close(); + } +}); +test("numeric callback cannot settle a prospective child; explicit stop containment retains original uncertainty", async () => { + const { store, generation } = await prepare(); + try { + await red( + h.operation(store, generation, "stop", { + preparation: true, + numeric: true, + }) + ); + const before = await readFile(h.receiptPath, "utf8"); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + expect(h.effects).toEqual([]); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + await red( + h.operation(store, pending, "stop", { preparation: true, recover: true }) + ); + expect(await readFile(h.receiptPath, "utf8")).toBe(before); + expect(h.effects).toHaveLength(1); + await red(store.publish({ generation: pending, binary: h.compiler })); + } finally { + await store.close(); + } +}); +test("a source read resumed after callback return cannot spawn an original-ID child", async () => { + const { store, generation } = await prepare(); + const entered = Promise.withResolvers(); + const released = Promise.withResolvers(); + let armed = false; + let paused = false; + let late: Promise | undefined; + let restoreRead: (() => void) | undefined; + try { + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (armed && !paused && args[0].includes("/generations/")) { + paused = true; + entered.resolve(); + await released.promise; + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + const deadline = Date.now() + 15_000; + await red( + store.withPreparationStop({ + generation, + binary: h.compiler, + deadline, + run: async (input) => { + armed = true; + late = h.track( + runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + }) + ); + await entered.promise; + return 0; + }, + }) + ); + expect(paused).toBe(true); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + const retained = await readFile(h.receiptPath, "utf8"); + released.resolve(); + if (!late) { + throw new Error("Synthetic delayed operation was not reached"); + } + await red(late); + expect(h.effects).toEqual([]); + expect(await readFile(h.receiptPath, "utf8")).toBe(retained); + } finally { + released.resolve(); + await late?.catch(() => undefined); + if (h.canRestore()) { + restoreRead?.(); + } + await store.close(); + } +}); +test("known nonzero child retains a settled journal and exact explicit stop recovery can clear it", async () => { + const { store, generation } = await prepare(); + try { + h.model.partial = true; + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(7); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 7, + }); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + h.model.partial = false; + expect( + await h.operation(store, pending, "stop", { + preparation: true, + recover: true, + }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 0, + }); + await store.publish({ generation: pending, binary: h.compiler }); + } finally { + await store.close(); + } +}); + +boundedTest( + "fixture teardown stays refused after an unfinished continuation later settles", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + const pending = Promise.withResolvers(); + const work = lifetime.track(pending.promise); + expect(lifetime.canRestore()).toBe(false); + pending.resolve(); + await work; + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "expired fixture lifetime never restores even without pending work", + () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() - 1); + expect(lifetime.canRestore()).toBe(false); + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "known callback settlement permits fixture teardown before its unchanged deadline", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + await lifetime.track(Promise.resolve()); + expect(lifetime.canRestore()).toBe(true); + }, + 1000 +); +test("foreign route writer and missing proxy reader refuse preparation before claims or effects", async () => { + for (const kind of ["foreign", "proxy"]) { + const before = h.commands.length; + h.model.foreign = kind === "foreign"; + h.model.proxyAccess = kind !== "proxy"; + const store = await h.store(); + try { + await red(store.prepare({ binary: h.compiler })); + } finally { + await store.close(); + } + expect( + h.commands + .slice(before) + .some((args) => + kind === "foreign" + ? args[0] === "container" && + args[1] === "inspect" && + args[3]?.includes('"sites"') && + args.includes(ROUTING_IDS.foreign) + : args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) + ).toBe(true); + expect(h.effects).toEqual([]); + } +}); +test("late original birth or upstream drift never clears pending or grants rollback", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + h.hooks.afterEffect = async () => { + h.model.webBirth = "2026-02-01T01:02:03Z"; + }; + await red(h.operation(store, active, "start")); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + await red(store.rollback()); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + await readFile(join(h.root, ".hack/hack.project.json"), "utf8") + ).not.toBe(""); + } finally { + await store.close(); + } +}); +test("same-byte receipt substitution during final routing admission cannot clear pending", async () => { + const { store, generation } = await prepare(); + const saved = `${h.receiptPath}.original`; + let substituted = false; + let finalSave = false; + let restoreRead: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (args[0].endsWith(".receipt")) { + const staged: unknown = JSON.parse(value.text); + if ( + isRecord(staged) && + staged.pendingOperation === null && + isRecord(staged.routingOperation) && + staged.routingOperation.disposition === "settled" + ) { + finalSave = true; + } + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + h.hooks.afterProbe = async (args) => { + if (!(finalSave && !substituted && args[0] === "exec")) { + return; + } + substituted = true; + const bytes = await readFile(h.receiptPath); + await fs.rename(h.receiptPath, saved); + await fs.writeFile(h.receiptPath, bytes, { mode: 0o600, flag: "wx" }); + }; + await red(h.operation(store, active, "start")); + expect(substituted).toBe(true); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + expect(await readFile(h.receiptPath)).toEqual(await readFile(saved)); + expect(h.effects).toHaveLength(1); + } finally { + if (h.canRestore()) { + restoreRead?.(); + h.hooks.afterProbe = undefined; + if (substituted) { + await fs.unlink(h.receiptPath); + await fs.rename(saved, h.receiptPath); + } + } + await store.close(); + } +}); +test("restored routing proof refuses same-byte original inode replacement before claim handoff", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const configPath = join(h.root, ".hack/hack.config.json"); + const nativePath = join(h.root, ".hack/hack.project.json"); + let replaced = false; + h.hooks.afterProbe = async (args) => { + if ( + !replaced && + args[0] === "info" && + (await h.receipt()).publication?.phase === "rolling-back" && + !(await Bun.file(nativePath).exists()) && + (await Bun.file(configPath).exists()) + ) { + const bytes = await readFile(configPath); + await fs.rename(configPath, join(h.outer, "original-config-test")); + await fs.writeFile(configPath, bytes, { mode: 0o600 }); + replaced = true; + } + }; + await red(store.rollback()); + expect(replaced).toBe(true); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + await store.close(); + } +}); +test("interrupted claim handoff retries from durable releasing state after exact source restore", async () => { + const { store, generation } = await prepare(); + let restoreUnlink: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const originalUnlink = fs.unlink; + let interrupted = false; + const unlinkSpy = spyOn(fs, "unlink").mockImplementation(async (path) => { + await originalUnlink(path); + if ( + !interrupted && + String(path).includes("/compose-routing/") && + String(path).includes("/claims/") + ) { + interrupted = true; + throw new Error("Synthetic handoff interruption; values omitted."); + } + }); + restoreUnlink = () => unlinkSpy.mockRestore(); + await red(store.rollback()); + expect(interrupted).toBe(true); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + if (!h.canRestore()) { + throw new Error("Synthetic handoff lifetime is unknown; values omitted."); + } + restoreUnlink(); + restoreUnlink = undefined; + await store.repairPublication({ action: "rollback" }); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect(h.effects).toEqual([]); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + } finally { + if (h.canRestore()) { + restoreUnlink?.(); + } + await store.close(); + } +}); diff --git a/tests/native-compose-adoption-routing-receipt.test.ts b/tests/native-compose-adoption-routing-receipt.test.ts new file mode 100644 index 000000000..ee0752eb4 --- /dev/null +++ b/tests/native-compose-adoption-routing-receipt.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; + +const checkout = { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, +}; +const generation = { + id: "a".repeat(32), + manifest: { dev: 1, ino: 5, hash: "b".repeat(64) }, +}; +const reference = { + attemptId: "c".repeat(32), + generationIdentity: generation.id, + intent: { dev: 1, ino: 6, hash: "d".repeat(64) }, + reservation: { dev: 1, ino: 7, hash: "e".repeat(64) }, +}; +function fixture() { + return { + adoption_receipt_version: 14, + kind: "legacy-compose-adopted", + checkout, + prepared: generation, + publication: { + generation, + phase: "active", + native: { dev: 1, ino: 8, hash: "f".repeat(64) }, + }, + pendingOperation: { + generation, + operation: "start", + services: ["db", "web"], + }, + routingOperation: { + generation, + token: "1".repeat(32), + reference, + disposition: "prospective" as const, + code: null, + }, + routingHandoff: "held", + }; +} +test("required v14 child disposition binds the exact generation, token and route reference", () => { + const value = fixture(), + parsed = parseLegacyComposeAdoptionReceipt(value, checkout); + expect(parsed.adoption_receipt_version).toBe(14); + expect(parsed.routingOperation).toEqual(value.routingOperation); + expect(parsed.routingHandoff).toBe("held"); + expect(Object.isFrozen(parsed.routingOperation?.reference)).toBe(true); + expect( + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 17, + }, + }, + checkout + ).routingOperation?.code + ).toBe(17); +}); +for (const change of [ + { routingOperation: undefined }, + { routingHandoff: undefined }, + { prepared: null }, + { pendingOperation: null }, + { routingOperation: null }, + { routingHandoff: "releasing" }, +]) { + test(`v14 cannot discard required pending authority ${Object.keys(change).join()}`, () => { + expect(() => + parseLegacyComposeAdoptionReceipt({ ...fixture(), ...change }, checkout) + ).toThrow(); + }); +} +test("foreign reference, malformed disposition and uncertain code cannot become settlement", () => { + const value = fixture(); + for (const change of [ + { reference: { ...reference, generationIdentity: "0".repeat(32) } }, + { reference: { ...reference, reservation: undefined } }, + { token: "private" }, + { token: 1 }, + { code: 0 }, + { disposition: "settled", code: null }, + { disposition: "settled", code: 256 }, + { disposition: "settled", code: -1 }, + { disposition: "other" }, + ]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { ...value.routingOperation, ...change }, + }, + checkout + ) + ).toThrow(); + } +}); +test("rollback handoff is allowed only after pending clearance in rollback phases", () => { + const value = fixture(); + for (const phase of ["rolling-back", "rolled-back"]) { + const parsed = parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 0, + }, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ); + expect(parsed.routingHandoff).toBe("releasing"); + } + for (const phase of ["switching", "active"]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: null, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ) + ).toThrow(); + } +}); +test("older receipts do not silently ignore required v14 routing state", () => { + const value = fixture(); + for (const version of [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version }, + checkout + ) + ).toThrow(); + } + const { + routingOperation: _operation, + routingHandoff: _handoff, + ...older + } = value; + expect( + parseLegacyComposeAdoptionReceipt( + { ...older, adoption_receipt_version: 1 }, + checkout + ).adoption_receipt_version + ).toBe(1); +}); diff --git a/tests/native-compose-adoption-routing-resolution.test.ts b/tests/native-compose-adoption-routing-resolution.test.ts new file mode 100644 index 000000000..1a3e48604 --- /dev/null +++ b/tests/native-compose-adoption-routing-resolution.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { legacyComposeRoutingResolutionMatches } from "../src/lib/native-compose-adoption-routing-resolution.ts"; +import { resolveNativeComposeOpenOrigin } from "../src/lib/native-compose-open.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; + +function fixture() { + const routing = mapLegacyComposeRouting({ + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: "alias" }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: "static:1", + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + }, + }, + })?.intent; + if (!routing) { + throw new Error("Synthetic route missing"); + } + const resolution: NativeRoutingResolution = { + domain: "local.test", + domain_origin: "checkout_local", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "primary_local", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + return { routing, resolution }; +} +test("typed-local domain precedence may retain exact served origins and saved open preference", () => { + const selected = fixture(); + expect(legacyComposeRoutingResolutionMatches(selected)).toBe(true); + expect( + resolveNativeComposeOpenOrigin({ resolution: selected.resolution }) + ).toBe("https://original.hack.gy"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + prefer: "dev", + }) + ).toBe("https://original.hack.local"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + target: "web", + }) + ).toBe("https://original.hack.gy"); +}); +for (const change of [ + { branch: "other" }, + { project_origin: "https://renamed.test" }, + { open_origin: "https://renamed.test" }, + { aliases: {} }, + { oauth_alias: null }, + { routes: {} }, +]) { + test(`effective routing refuses changed served selection ${JSON.stringify(change)}`, () => { + const { routing, resolution } = fixture(); + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + }); +} +test("route target, port, transport and aliases cannot drift behind matching apex metadata", () => { + const { routing, resolution } = fixture(), + web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { service: "other" }, + { port: 3001 }, + { protocol: "https" as const }, + { origin: "https://renamed.test" }, + { aliases: {} }, + ]) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, routes: { web: { ...web, ...change } } }, + }) + ).toBe(false); + } +}); diff --git a/tests/native-compose-route-claims.test.ts b/tests/native-compose-route-claims.test.ts index f2fde4194..3ed37da2d 100644 --- a/tests/native-compose-route-claims.test.ts +++ b/tests/native-compose-route-claims.test.ts @@ -180,6 +180,111 @@ test("foreign same-origin collision refuses before caller effect and preserves o expect(await winner.reopen(attempt.reference)).toEqual(attempt); }); +test("retained stopped recovery marks only exact referenced uncertainty and keeps its hostname claimed", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + const original = await Bun.file(claimPath(root)).text(); + await result.markEffectsPossible(attempt); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async (selection) => { + expect(selection).toEqual({ + hostnames: [HOST], + binding: BINDING, + owner: OWNER, + }); + }, + }); + expect((await result.reopen(attempt.reference)).phase).toBe("stopped"); + expect(await Bun.file(claimPath(root)).text()).toBe(original); + await result.assertHeld(attempt.reference); + await expect(acquire(await store(root, OTHER))).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_ROUTE_CONFLICT", + }); + await expect( + result.complete({ attempt, assertTransition: async () => {} }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); +}); +test("retained handoff refuses unknown children and failed restored-source proof without releasing a claim", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await result.markEffectsPossible(attempt); + let callbacks = 0; + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + callbacks++; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + expect(callbacks).toBe(0); + const bytes = await Bun.file(claimPath(root)).text(); + await expect( + result.recoverRetainedStopped({ + references: [], + assertStopped: async () => {}, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + await expect( + result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => { + throw new Error("synthetic stopped proof refused"); + }, + }) + ).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).phase).toBe("armed"); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => {}, + }); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + throw new Error("synthetic restored source refused"); + }, + }) + ).rejects.toThrow(); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); +}); +test("retained rollback handoff is exact, revalidates claim incarnation and is resumable after removal", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await complete(result, attempt); + const bytes = await fs.readFile(claimPath(root)); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + await fs.rename(claimPath(root), `${claimPath(root)}.original`); + await fs.writeFile(claimPath(root), bytes, { mode: 0o600 }); + }, + }) + ).rejects.toThrow(); + await fs.unlink(claimPath(root)); + await fs.rename(`${claimPath(root)}.original`, claimPath(root)); + const callbacks: (readonly string[])[] = []; + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(await Bun.file(claimPath(root)).exists()).toBe(false); + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(callbacks).toEqual([[HOST], []]); + await expect(result.assertHeld(attempt.reference)).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).reference).toEqual( + attempt.reference + ); +}); + test("real simultaneous independent processes have exactly one hostname winner and effect", async () => { const root = await fixture(); const module = new URL( From 1984eae6c2e356147e669c573480fcbff085e0d4 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 01:51:40 -0400 Subject: [PATCH 16/23] test(adoption): verify retained routing lifecycle with isolated ingress --- docs/reference/native-compose-adoption.md | 17 + tests/e2e/run.ts | 2 + .../native-compose-adoption-job-worktrees.ts | 2 +- .../native-compose-adoption-routing-inputs.ts | 132 ++++++ ...tive-compose-adoption-routing-worktrees.ts | 388 +++++++++++++++++ .../native-compose-adoption-worktrees.ts | 144 ++++++- tests/e2e/scenarios/native-config-build.ts | 2 +- tests/e2e/scenarios/native-config-routing.ts | 359 ++-------------- .../native-routing-fixture-ingress.ts | 397 ++++++++++++++++++ ...e-compose-adoption-routing-fixture.test.ts | 334 +++++++++++++++ tests/native-routing-fixture.test.ts | 2 +- 11 files changed, 1427 insertions(+), 352 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts create mode 100644 tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts create mode 100644 tests/e2e/scenarios/native-routing-fixture-ingress.ts create mode 100644 tests/native-compose-adoption-routing-fixture.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 8b03b2ce7..6a1ee7097 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -523,3 +523,20 @@ readiness, source binds, files, branch overrides and custom bridges remain outsi this initial routing family. The owner and private-store model do not prove live TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated ingress lifecycle fixture remains required; global DNS and trust are unchanged. + +The explicitly selected `native-compose-adoption-routing-worktrees` scenario +adds an HTTP service to the original two-worktree PostgreSQL fixture. It requires +both literal HTTPS origins and the existing OAuth alias to serve each checkout's +marker, with no published proxy ports. Saved `open` must select alpha's +checkout-local alias over the authored and primary-local dev preference. It also +checks original SQL/IDs/births, a known partial-stop journal and explicit +recovery, two retained up/down cycles, exact source rollback and claim handoff. +Unknown child or cleanup disposition retains the fixture. The temporary Caddy +owner is shared with `native-config-routing`; stopped user proxies and the +existing `hack-dev` network stay intact. This scenario does not perform OAuth +login, change host DNS/trust, or qualify combined unsupported families. + +With current compiled artifacts, cached fixture images and an exclusively +coordinated Docker lane, select it using the same prerequisites above and +`--only=native-compose-adoption-routing-worktrees`. Its source and pure controls +are separate from a completed live TLS/SQL run. diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 8d72de307..c22879c99 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 { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; +import { nativeComposeAdoptionRoutingWorktreesScenario } from "./scenarios/native-compose-adoption-routing-worktrees.ts"; import { nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, @@ -97,6 +98,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeComposeAdoptionDependencyWorktreesScenario, nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionJobWorktreesScenario, + nativeComposeAdoptionRoutingWorktreesScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, ]; diff --git a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts index 0a70476d8..ec0fd4181 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts @@ -22,7 +22,7 @@ import { createAdoptionFixtureProbe, waitForAdoptionFixtureSql, } from "./native-compose-adoption-worktrees.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 180_000; const FULL_ID = /^[a-f0-9]{64}$/; diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts new file mode 100644 index 000000000..5d41dddaf --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts @@ -0,0 +1,132 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { resolveProjectOauthAliasHost } from "../../../src/lib/project.ts"; + +export type RetainedRoutingFixtureSelection = { + readonly image: string; + readonly devHost: string; + readonly aliasHost: string; + readonly prefer: "alias" | "dev"; + readonly marker: string; +}; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/; +const APP = + "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.RETAINED_ROUTE_MARKER)}})"; +function refuse(): never { + throw new Error("Retained routing fixture input refused; values omitted."); +} + +/** Explicit legacy hosts are authored before the original Compose bootstrap. */ +export function retainedRoutingFixtureSelection(opts: { + readonly image: string; + readonly name: string; + readonly marker: string; + readonly prefer: "alias" | "dev"; +}): RetainedRoutingFixtureSelection { + if (!(IMAGE.test(opts.image) && NAME.test(opts.name) && opts.marker)) { + return refuse(); + } + const devHost = `${opts.name}.hack.local`; + const aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { enabled: true }, + }); + if (!aliasHost || aliasHost === devHost) { + return refuse(); + } + return Object.freeze({ + image: opts.image, + devHost, + aliasHost, + prefer: opts.prefer, + marker: opts.marker, + }); +} + +export function retainedRoutingFixtureConfig( + selection: RetainedRoutingFixtureSelection +) { + return { + dev_host: selection.devHost, + oauth: { enabled: true }, + // Alpha's authored dev preference must lose to its checkout-local alias. + open: { prefer: "dev" as const }, + }; +} +export function retainedRoutingFixtureService( + selection: RetainedRoutingFixtureSelection +) { + return { + image: selection.image, + pull_policy: "never", + entrypoint: ["bun", "-e"], + command: [APP], + environment: { RETAINED_ROUTE_MARKER: selection.marker }, + networks: ["default", "hack-dev"], + labels: { + caddy: `${selection.devHost},${selection.aliasHost}`, + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + caddy_ingress_network: "hack-dev", + }, + }; +} +export function retainedRoutingFixtureOrigins( + selection: RetainedRoutingFixtureSelection +): readonly string[] { + return [`https://${selection.devHost}`, `https://${selection.aliasHost}`]; +} + +/** The checkout layer wins without changing either already served origin. */ +export async function prepareRetainedRoutingFixtureLocals(opts: { + readonly primary: { readonly root: string }; + readonly instances: readonly { + readonly root: string; + readonly routing?: RetainedRoutingFixtureSelection; + }[]; +}) { + await writeFile( + join(opts.primary.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }), + { mode: 0o600 } + ); + for (const instance of opts.instances) { + if (!instance.routing) { + return refuse(); + } + await writeFile( + join(instance.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: instance.routing.prefer }, + }), + { mode: 0o600 } + ); + } +} + +/** Private exact sidecar identities and bytes; no digests enter scenario output. */ +export async function retainedRoutingFixtureLocalSnapshot(opts: { + readonly primary: { readonly root: string }; + readonly instance: { readonly root: string }; +}) { + const rows: unknown[] = []; + for (const checkout of [opts.primary, opts.instance]) { + const path = join(checkout.root, ".hack/hack.local.json"); + const { info, text } = await readPrivate(path, 4096); + rows.push({ + dev: info.dev, + ino: info.ino, + mode: info.mode, + hash: new Bun.CryptoHasher("sha256").update(text).digest("hex"), + }); + } + return JSON.stringify(rows); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts new file mode 100644 index 000000000..b72f209e4 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts @@ -0,0 +1,388 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import type { CliResult, Scenario } from "../harness.ts"; +import { + retainedRoutingFixtureLocalSnapshot, + retainedRoutingFixtureOrigins, +} from "./native-compose-adoption-routing-inputs.ts"; +import { + bootstrapOriginal, + cleanupOwnedAdoptionFixture, + createFixtureRuntime, + prepareFixtureInputs, + runWithFixtureCleanup, +} from "./native-compose-adoption-worktrees.ts"; +import { nativeRoutedDownClaimSnapshot } from "./native-config-routed-down-hooks.ts"; +import { prepareNativeRoutingFixtureIngress } from "./native-routing-fixture-ingress.ts"; + +type Runtime = ReturnType; +type Instance = Runtime["first"]; +type Ingress = Awaited>; +const SERVICES = ["db", "web", "worker"]; +function refuse(): never { + throw new Error("Retained routing lifecycle check refused; values omitted."); +} +function passed(result: CliResult) { + if (result.timedOut || result.exitCode !== 0) { + return refuse(); + } + return result; +} +function object(text: string) { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return refuse(); + } + if (!isRecord(value)) { + return refuse(); + } + return value; +} +function receiptPath(instance: Instance) { + return join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); +} +async function receipt(instance: Instance) { + const value = object( + (await readPrivate(receiptPath(instance), 128 * 1024)).text + ); + if (!value || value.adoption_receipt_version !== 14) { + return refuse(); + } + return value; +} +function originalIds(h: Runtime, instance: Instance): readonly string[] { + return SERVICES.map((service) => h.container(instance, service)); +} + +/** Closed forwarding control. It performs exactly one known partial stop, then + * the real CLI's routing process owner records the known nonzero disposition. */ +export function retainedRoutingPartialStopScript(opts: { + readonly engine: string; + readonly engineId: string; + readonly receipt: string; + readonly ids: readonly string[]; + readonly stopId: string; + readonly marker: string; +}): string { + if ( + opts.ids.length !== 3 || + new Set(opts.ids).size !== 3 || + !opts.ids.includes(opts.stopId) || + opts.ids.some((id) => !/^[a-f0-9]{64}$/.test(id)) + ) { + return refuse(); + } + return `#!${process.execPath} +import { readPrivate } from ${JSON.stringify(new URL("../../../src/lib/native-compose-private-state.ts", import.meta.url).href)}; +import { writeFile } from 'node:fs/promises'; +const args=process.argv.slice(2), engine=${JSON.stringify(opts.engine)}; +if(args[0]==='container' && args[1]==='stop') { + if(JSON.stringify(args)!==JSON.stringify(['container','stop',...${JSON.stringify(opts.ids)}])) process.exit(99); + let value;try{value=JSON.parse((await readPrivate(${JSON.stringify(opts.receipt)},131072)).text)}catch{process.exit(98)} + if(value.adoption_receipt_version!==14 || value.pendingOperation?.operation!=='stop' || JSON.stringify([...value.pendingOperation.services].sort())!==JSON.stringify(${JSON.stringify(SERVICES)}) || value.routingOperation?.disposition!=='prospective' || value.routingOperation?.code!==null || value.routingHandoff!=='held') process.exit(98); + const observed=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'}); + const observedText=await new Response(observed.stdout).text(); + if(await observed.exited!==0 || observedText.trim()!==${JSON.stringify(opts.engineId)}) process.exit(97); + const child=Bun.spawn([engine,'container','stop',${JSON.stringify(opts.stopId)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); + if(await child.exited!==0) process.exit(96); + await writeFile(${JSON.stringify(opts.marker)},'known-routing-partial-stop',{flag:'wx',mode:0o600}); + process.exit(71); +} +const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited); +`; +} + +async function partialStop(h: Runtime) { + const root = join(h.ctx.tempRoot, "retained-routing-partial-stop"); + await mkdir(root, { mode: 0o700 }); + const marker = join(root, "known-stop"); + const shim = join(root, "docker"); + await writeFile( + shim, + retainedRoutingPartialStopScript({ + engine: h.engine, + engineId: h.engineId, + receipt: receiptPath(h.first), + ids: originalIds(h, h.first), + stopId: h.container(h.first, "db"), + marker, + }), + { mode: 0o700 } + ); + await chmod(shim, 0o700); + const result = await h.cli(h.first, ["config", "adopt", "--stop", "--json"], { + PATH: `${root}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }); + if ( + result.timedOut || + result.exitCode !== 71 || + (await readPrivate(marker, 128)).text !== "known-routing-partial-stop" + ) { + return refuse(); + } + const saved = await receipt(h.first); + if ( + !isRecord(saved.pendingOperation) || + saved.pendingOperation.operation !== "stop" || + !isRecord(saved.routingOperation) || + saved.routingOperation.disposition !== "settled" || + saved.routingOperation.code !== 71 || + saved.routingHandoff !== "held" + ) { + return refuse(); + } + const pendingBytes = (await readPrivate(receiptPath(h.first), 128 * 1024)) + .text; + const states = async () => + JSON.stringify( + await Promise.all( + originalIds(h, h.first).map(async (id) => ({ + id, + running: await h.probe([ + "container", + "inspect", + "--format", + "{{.State.Running}}", + id, + ]), + })) + ) + ); + const before = await states(); + const blocked = await h.cli(h.first, ["up", "--detach", "--json"]); + if ( + blocked.timedOut || + blocked.exitCode !== 1 || + !blocked.combined.includes("E_CONFIG_INVALID") || + (await readPrivate(receiptPath(h.first), 128 * 1024)).text !== + pendingBytes || + (await states()) !== before + ) { + return refuse(); + } + passed( + await h.cli(h.first, ["config", "adopt", "--recover", "--stop", "--json"]) + ); + await h.assertStopped(h.first); +} + +async function savedOpen(h: Runtime, instance: Instance) { + const selected = instance.routing; + if (!selected) { + return refuse(); + } + const automatic = + selected.prefer === "alias" ? selected.aliasHost : selected.devHost; + if ( + object(passed(await h.cli(instance, ["open", "--json"])).stdout).url !== + `https://${automatic}` || + object( + passed(await h.cli(instance, ["open", "--prefer", "dev", "--json"])) + .stdout + ).url !== `https://${selected.devHost}` + ) { + return refuse(); + } + // Alpha's authored and primary-local dev selections differ from its saved + // checkout-local alias, so this public open result proves that precedence. +} +async function routes(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + for (const origin of retainedRoutingFixtureOrigins(instance.routing)) { + await ingress.tls(origin, instance.routing.marker); + } + await ingress.tls(`https://${ingress.canaryHost}`, ingress.canaryMarker); + await ingress.preservedUnchanged(); +} +async function absent(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + const hosts = retainedRoutingFixtureOrigins(instance.routing).map( + (origin) => new URL(origin).hostname + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const matched = nativeComposeProxyRoutesMatch({ + servers: await ingress.admin(), + expected: [], + absentHostnames: hosts, + }); + if (matched && Date.now() < deadline) { + return; + } + await Bun.sleep(250); + } + return refuse(); +} +async function originalBaseline( + h: Runtime, + ingress: Ingress, + instance: Instance, + source = true +) { + await h.waitReady(instance); + await h.check(instance, source); + await routes(ingress, instance); +} +async function roundTrip( + h: Runtime, + ingress: Ingress, + instance: Instance, + sibling: Instance, + prepared: boolean +) { + if (!prepared) { + passed(await h.cli(instance, ["config", "adopt", "--stop", "--json"])); + await h.assertStopped(instance); + } + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + for (let index = 0; index < 2; index++) { + passed(await h.cli(instance, ["up", "--detach", "--json"])); + await originalBaseline(h, ingress, instance, false); + await savedOpen(h, instance); + if ( + passed( + await h.cli(instance, [ + "exec", + "db", + "--", + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + "SELECT value FROM marker WHERE id=1", + ]) + ).stdout.trim() !== instance.marker + ) { + return refuse(); + } + await originalBaseline(h, ingress, sibling); + passed(await h.cli(instance, ["down", "--json"])); + await h.assertStopped(instance); + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + } + passed(await h.cli(instance, ["config", "adopt", "--rollback", "--json"])); + const rolledBack = await receipt(instance); + if ( + !isRecord(rolledBack.publication) || + rolledBack.publication.phase !== "rolled-back" + ) { + return refuse(); + } + await h.effect(["container", "start", ...originalIds(h, instance)]); + await originalBaseline(h, ingress, instance); + await originalBaseline(h, ingress, sibling); +} + +/** Same old instances keep literal browser origins, SQL, resources and local + * precedence. Only the existing temporary ingress owner may serve this fixture. */ +export const nativeComposeAdoptionRoutingWorktreesScenario: Scenario = { + name: "native-compose-adoption-routing-worktrees", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "original routed linked checkouts retain TLS aliases, saved open, SQL and recovery/rollback", + run: async (ctx) => { + const h = createFixtureRuntime( + await prepareFixtureInputs(ctx, { routing: true }) + ); + const ingress = await prepareNativeRoutingFixtureIngress({ + ctx, + docker: h.probe, + }); + if ( + h.first.routing?.image !== ingress.bunImage || + h.second.routing?.image !== ingress.bunImage + ) { + return refuse(); + } + const localPins = new Map(); + let claimsRoot: string | null = null; + let complete = false; + await runWithFixtureCleanup({ + run: async () => { + const binding = await ingress.start(); + claimsRoot = join( + ctx.hackHome, + "compose-routing", + createHash("sha256").update(binding.engineId).digest("hex"), + "claims" + ); + for (const instance of [h.first, h.second]) { + localPins.set( + instance, + await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + }) + ); + await bootstrapOriginal(h, instance); + await originalBaseline(h, ingress, instance); + } + await partialStop(h); + await roundTrip(h, ingress, h.first, h.second, true); + await roundTrip(h, ingress, h.second, h.first, false); + for (const instance of [h.first, h.second]) { + if ( + (await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + })) !== localPins.get(instance) + ) { + return refuse(); + } + } + if ((await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "") { + return refuse(); + } + complete = true; + ctx.log( + "original literal TLS/OAuth origins, checkout-local saved open, SQL/IDs and known partial-stop recovery/rollback verified" + ); + }, + cleanup: async () => { + if ( + !(complete && claimsRoot) || + (await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "" + ) { + return refuse(); + } + // The rolled-back original workloads are stopped and their exact routes + // disappear before any project cleanup or temporary ingress retirement. + for (const instance of [h.first, h.second]) { + await h.check(instance); + await h.effect(["container", "stop", ...originalIds(h, instance)]); + await h.assertStopped(instance); + await absent(ingress, instance); + } + await cleanupOwnedAdoptionFixture({ + ...h, + instances: [h.first, h.second], + }); + await ingress.cleanup(); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Retained routing fixture ownership or cleanup is uncertain" + ), + }); + }, +}; diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index bdc73146d..e67a8cb77 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -72,6 +72,14 @@ import { prepareManagedAdoptionFixtureSources, } from "./native-compose-adoption-managed-inputs.ts"; +import { + prepareRetainedRoutingFixtureLocals, + type RetainedRoutingFixtureSelection, + retainedRoutingFixtureConfig, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./native-compose-adoption-routing-inputs.ts"; + const TIMEOUT = 180_000; const PROJECT_LABEL = "com.docker.compose.project"; const NATIVE_PREFIX = "io.hack.native-config."; @@ -125,6 +133,7 @@ type Instance = { readonly ownedNetworks?: true; readonly dependency?: "service_started" | "service_healthy"; readonly basicBuild?: RetainedBuildFixtureMode; + readonly routing?: RetainedRoutingFixtureSelection; }; type Observation = { readonly id: string; @@ -471,23 +480,43 @@ function validateOwnedObservation(opts: { typeof row.id !== "string" || !ID.test(row.id) || typeof row.service !== "string" || - !["db", "worker"].includes(row.service) || + !( + opts.instance.routing ? ["db", "worker", "web"] : ["db", "worker"] + ).includes(row.service) || row.name !== `/${opts.instance.name}-${row.service}-1` || row.workingDir !== join(opts.instance.root, ".hack") || row.configFiles !== fixtureComposeFiles(opts.instance).join(",") || JSON.stringify(row.mounts) !== - JSON.stringify([ - { - type: "volume", - name: `${opts.instance.name}_data`, - target: "/var/lib/postgresql/data", - rw: row.service === "db", - }, - ]) + JSON.stringify( + row.service === "web" && opts.instance.routing + ? [] + : [ + { + type: "volume", + name: `${opts.instance.name}_data`, + target: "/var/lib/postgresql/data", + rw: row.service === "db", + }, + ] + ) ) { refused(); } - return { id: row.id, service: row.service }; + if ( + opts.instance.routing && + !( + typeof row.createdAt === "string" && + CREATED.test(row.createdAt) && + Number.isFinite(Date.parse(row.createdAt)) + ) + ) { + refused(); + } + return { + id: row.id, + service: row.service, + ...(opts.instance.routing ? { createdAt: String(row.createdAt) } : {}), + }; } if ( typeof row.createdAt !== "string" || @@ -559,6 +588,9 @@ async function writeLegacy(instance: Instance, image: string) { join(instance.root, ".hack/hack.config.json"), JSON.stringify({ name: instance.name, + ...(instance.routing + ? retainedRoutingFixtureConfig(instance.routing) + : {}), worktree: { auto_branch: false, inherit_local: true }, ...(instance.sourceMode ? { env: { default_overlay: "qa" } } : {}), }) @@ -568,6 +600,9 @@ async function writeLegacy(instance: Instance, image: string) { JSON.stringify({ name: instance.name, services: { + ...(instance.routing + ? { web: retainedRoutingFixtureService(instance.routing) } + : {}), db: { ...(instance.basicBuild ? { build: retainedBuildFixtureDefinition(instance.basicBuild) } @@ -625,6 +660,9 @@ async function writeLegacy(instance: Instance, image: string) { }, }, volumes: { data: { name: `${instance.name}_data` } }, + ...(instance.routing + ? { networks: { "hack-dev": { external: true } } } + : {}), ...(instance.ownedNetworks ? { networks: { @@ -638,9 +676,9 @@ async function writeLegacy(instance: Instance, image: string) { }) ); } -function formats(kind: Kind): string { +function formats(kind: Kind, routing = false): string { if (kind === "container") { - return `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; + return `{"id":{{json .Id}},${routing ? '"createdAt":{{json .Created}},' : ""}"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; } if (kind === "network") { return `{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"project":{{json (index .Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"logical":{{json (index .Labels "com.docker.compose.network")}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}}}`; @@ -691,7 +729,7 @@ function authoredFixtureFeatures(opts: { }; } -async function prepareFixtureInputs( +export async function prepareFixtureInputs( ctx: ScenarioContext, options: { readonly generated?: boolean; @@ -701,6 +739,7 @@ async function prepareFixtureInputs( readonly ownedNetworks?: boolean; readonly dependencies?: boolean; readonly basicBuild?: boolean; + readonly routing?: boolean; } = {} ) { const { @@ -711,7 +750,20 @@ async function prepareFixtureInputs( ownedNetworks = false, dependencies = false, basicBuild = false, + routing = false, } = options; + if ( + routing && + (generated || + typedLocal || + stringArgv || + ownedNetwork || + ownedNetworks || + dependencies || + basicBuild) + ) { + refused(); + } if ( basicBuild && (generated || @@ -749,6 +801,18 @@ async function prepareFixtureInputs( ctx.skip("Docker executable unavailable"); } const engineId = await probe(["info", "--format", "{{json .ID}}"]); + const routingImage = routing + ? await probe([ + "image", + "inspect", + "oven/bun:1.4.2-slim", + "--format", + "{{.Id}}", + ]) + : null; + if (routingImage !== null && !IMAGE.test(routingImage)) { + refused(); + } const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: false, @@ -757,6 +821,16 @@ async function prepareFixtureInputs( root: fixture.root, name: `${fixture.name}-main`, marker: "unused-primary", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-main`, + marker: "unused-primary", + prefer: "alias", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -798,6 +872,16 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-alpha" }), name: `${fixture.name}-alpha`, marker: "alpha-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-alpha`, + marker: "alpha-existing-http-marker", + prefer: "alias", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -811,6 +895,16 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-beta" }), name: `${fixture.name}-beta`, marker: "beta-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-beta`, + marker: "beta-existing-http-marker", + prefer: "dev", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -849,6 +943,13 @@ async function prepareFixtureInputs( }); } + if (routing) { + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [first, second], + }); + } + return { ctx, engine, @@ -863,7 +964,7 @@ async function prepareFixtureInputs( }; } -function createFixtureRuntime( +export function createFixtureRuntime( opts: Awaited> ) { const { @@ -902,7 +1003,13 @@ function createFixtureRuntime( instance, kind, row: object( - await probe([kind, "inspect", "--format", formats(kind), id]) + await probe([ + kind, + "inspect", + "--format", + formats(kind, instance.routing !== undefined), + id, + ]) ), }); const list = async (instance: Instance, kind: Kind) => @@ -1789,6 +1896,9 @@ async function requireFixtureNamesAbsent( ...fixtureNetworkNames(instance).map((name) => ["network", name] as const), ["container", `${instance.name}-db-1`], ["container", `${instance.name}-worker-1`], + ...(instance.routing + ? [["container", `${instance.name}-web-1`] as const] + : []), ] as const) { if ( ( @@ -1808,7 +1918,7 @@ async function requireFixtureNamesAbsent( } } } -async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { +export async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { const { engine, fixtureRoot, @@ -1881,7 +1991,7 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { }); successful(started); if ( - captured.container.length !== 2 || + captured.container.length !== (instance.routing ? 3 : 2) || captured.volume.length !== 1 || captured.network.length !== (instance.ownedNetworks ? 2 : 1) ) { diff --git a/tests/e2e/scenarios/native-config-build.ts b/tests/e2e/scenarios/native-config-build.ts index 45269491f..ef16cf21c 100644 --- a/tests/e2e/scenarios/native-config-build.ts +++ b/tests/e2e/scenarios/native-config-build.ts @@ -14,7 +14,7 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 120_000; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; diff --git a/tests/e2e/scenarios/native-config-routing.ts b/tests/e2e/scenarios/native-config-routing.ts index 089415317..bf21fcbfc 100644 --- a/tests/e2e/scenarios/native-config-routing.ts +++ b/tests/e2e/scenarios/native-config-routing.ts @@ -1,11 +1,9 @@ -import { createHash, randomBytes, X509Certificate } from "node:crypto"; +import { createHash } from "node:crypto"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import type { Project } from "../../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "../../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../../src/lib/native-compose-generation.ts"; -import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; -import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; import { type NativeRoutingResolution, parseNativeRoutingResolution, @@ -33,45 +31,20 @@ import { ROUTED_RUN_LITERAL, } from "./native-config-routed-run.ts"; +import { + NATIVE_ROUTING_FIXTURE_TMPFS as PRIVATE_TMPFS, + prepareNativeRoutingFixtureIngress, +} from "./native-routing-fixture-ingress.ts"; + const TIMEOUT = 180_000; -const OBSERVATION_WINDOW = 30_000; const OBJECT_ID = /^[a-f0-9]{64}$/; -const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; const PROJECT_LABEL = "com.docker.compose.project"; -const SERVICE_LABEL = "com.docker.compose.service"; const OWNER_LABEL = "io.hack.native-config.owner"; const INSTANCE_LABEL = "io.hack.native-config.instance"; const STORAGE_LABEL = "io.hack.native-config.storage"; -const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; -const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; -const PROXY_PROJECT = "hack-dev-proxy"; -const PROXY_SERVICE = "caddy"; -const NETWORK = "hack-dev"; -const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; -const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; -const PRIVATE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; const APP = "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.BRANCH_MARKER)}})"; -const PRESERVED_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; -const PROXY_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; - -/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ -export function proxyHasNoPublishedPorts( - info: Readonly> -): boolean { - return ( - info.publishAll === false && - (info.ports === null || - (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && - (info.runtimePorts === null || - (isRecord(info.runtimePorts) && - Object.values(info.runtimePorts).every((value) => value === null))) - ); -} - type Docker = (args: readonly string[]) => Promise; type Runtime = { readonly composeProject: string; @@ -362,58 +335,19 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result.stdout.trim(); }; - const selectors = [ - "--filter", - `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--filter", - `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, - ]; - // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, - // start, rename or remove them merely to make this scenario runnable. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse native routing fixture while any global Caddy selector is running", - }); - const preserved = ids( - await docker(["ps", "--no-trunc", "-aq", ...selectors]) - ); - const preservedSnapshots = new Map(); - for (const id of preserved) { - const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); - expect({ - that: object(text).running === false, - message: "Pre-existing proxy must be stopped", - }); - preservedSnapshots.set(id, text); - } - expect({ - that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", - message: "Native routing fixture requires a Linux Docker daemon", - }); - await docker(["compose", "version"]); - const networkId = await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ]); - expect({ - that: OBJECT_ID.test(networkId), - message: "Existing hack-dev network identity is required", - }); - const image = async (tag: string): Promise => { - const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); - expect({ - that: IMAGE_ID.test(id), - message: - "Fixture images must already be cached; never pull during acceptance", - }); - return id; - }; - const bunImage = await image("oven/bun:1.4.2-slim"); - const caddyImage = await image(CADDY_IMAGE); + const ingress = await prepareNativeRoutingFixtureIngress({ ctx, docker }); + const { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + admin, + tls, + absent, + preservedUnchanged, + } = ingress; const nativeInventory = async (): Promise => ids( await docker([ @@ -425,12 +359,7 @@ export const nativeConfigRoutingScenario: Scenario = { ]) ).join("\n"); const nativeBefore = await nativeInventory(); - const token = randomBytes(16).toString("hex"); - const proxyName = `e2e-native-routing-proxy-${token}`; - const canaryHost = `canary-${token}.test`; - const canaryMarker = `proxy-canary-${token}`; const privateRoot = await realpath(ctx.tempRoot); - let proxyId: string | null = null; let claimsRoot: string | null = null; const attempted = new Set(); const successfulStarts = new Set(); @@ -469,163 +398,6 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result; }; - const currentProxy = (): string => { - if (!(proxyId && OBJECT_ID.test(proxyId))) { - throw new Error("Exact owned fixture proxy ID is unavailable"); - } - return proxyId; - }; - const preservedUnchanged = async (): Promise => { - expect({ - that: - (await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ])) === networkId, - message: "External hack-dev network must retain its exact identity", - }); - for (const [id, before] of preservedSnapshots) { - expect({ - that: - (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === - before, - message: "Stopped user Caddy selectors must remain unchanged", - }); - } - }; - const proxyOwned = async (): Promise => { - const info = object( - await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) - ); - expect({ - that: - info.id === currentProxy() && - info.name === `/${proxyName}` && - info.owner === token && - info.project === PROXY_PROJECT && - info.service === PROXY_SERVICE && - info.networkMode === networkId && - (info.running === false || info.network === networkId) && - proxyHasNoPublishedPorts(info) && - Array.isArray(info.mounts) && - info.mounts.length === 1 && - info.mounts.every( - (mount: unknown) => - isRecord(mount) && - mount.Type === "bind" && - mount.Destination === "/var/run/docker.sock" && - mount.Source === "/var/run/docker.sock" && - mount.RW === false - ) && - isRecord(info.tmpfs) && - Object.keys(info.tmpfs).length === 2 && - info.tmpfs["/data"] === PRIVATE_TMPFS && - info.tmpfs["/config"] === PRIVATE_TMPFS, - message: - "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", - }); - }; - const admin = async (): Promise => { - await proxyOwned(); - const text = await docker([ - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=http", - "--max-time", - "10", - "--max-redirs", - "0", - "--write-out", - "\n%{http_code}", - "--url", - ADMIN_URL, - ]); - expect({ - that: text.endsWith("\n200"), - message: - "Read-only live Caddy configuration probe must return HTTP 200", - }); - return JSON.parse(text.slice(0, -4)); - }; - const absent = async (hosts: readonly string[]): Promise => { - expect({ - that: nativeComposeProxyRoutesMatch({ - servers: await admin(), - expected: [], - absentHostnames: hosts, - }), - message: - "Retired exact fixture origins must be absent from active Caddy routing", - }); - }; - const tls = async (origin: string, marker: string): Promise => { - const url = new URL(origin); - expect({ - that: - url.protocol === "https:" && url.port === "" && url.pathname === "/", - message: "Fixture TLS probes require exact standard HTTPS origins", - }); - const deadline = Date.now() + OBSERVATION_WINDOW; - while (Date.now() < deadline) { - await proxyOwned(); - const result = await runCommand({ - argv: [ - "docker", - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=https", - "--max-redirs", - "0", - "--connect-timeout", - "2", - "--max-time", - "5", - "--cacert", - ROOT_CA, - "--resolve", - `${url.hostname}:443:127.0.0.1`, - "--url", - `${origin}/`, - ], - cwd: ctx.tempRoot, - timeoutMs: TIMEOUT, - }); - if ( - result.exitCode === 0 && - !result.timedOut && - result.stdout === marker - ) { - return; - } - await Bun.sleep(250); - } - throw new Error( - "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" - ); - }; const plan = async (root: string): Promise => { const payload = object( (await cli(root, ["config", "plan", "--json"])).stdout @@ -801,99 +573,17 @@ export const nativeConfigRoutingScenario: Scenario = { "Native hostname claims must be absent before fixture ingress removal", }); } - if (proxyId) { - await proxyOwned(); - await docker(["container", "stop", currentProxy()]); - await proxyOwned(); - await docker(["container", "rm", currentProxy()]); - proxyId = null; - } - expect({ - that: - (await docker([ - "ps", - "--no-trunc", - "-aq", - "--filter", - `label=${FIXTURE_LABEL}=${token}`, - ])) === "", - message: - "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", - }); - await preservedUnchanged(); + await ingress.cleanup(); }; await runWithOwnedCleanup({ run: async () => { - // Repeat ingress absence at the only fixture-global creation boundary. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse a newly appeared running global Caddy before fixture creation", - }); - proxyId = await docker([ - "create", - "--pull=never", - "--name", - proxyName, - "--network", - networkId, - "--label", - `${FIXTURE_LABEL}=${token}`, - "--label", - `${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--label", - `${SERVICE_LABEL}=${PROXY_SERVICE}`, - "--label", - `caddy=https://${canaryHost}`, - "--label", - `caddy.respond=${canaryMarker} 200`, - "--label", - "caddy.tls=internal", - "--env", - `CADDY_INGRESS_NETWORKS=${NETWORK}`, - "--mount", - "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", - // Caddy writes private root-owned files. Keep these in the disposable - // container so Linux cleanup never needs host chown or sudo. - "--tmpfs", - `/data:${PRIVATE_TMPFS}`, - "--tmpfs", - `/config:${PRIVATE_TMPFS}`, - caddyImage, - "docker-proxy", - "--polling-interval", - "1s", - ]); - await proxyOwned(); - await docker(["container", "start", currentProxy()]); - await tls(`https://${canaryHost}`, canaryMarker); - const binding = await observeNativeComposeIngress(); - expect({ - that: - binding.proxyId === currentProxy() && - binding.networkId === networkId, - message: - "Product ingress observer must select exactly the new fixture proxy/network", - }); + const binding = await ingress.start(); claimsRoot = join( ctx.hackHome, "compose-routing", createHash("sha256").update(binding.engineId).digest("hex"), "claims" ); - await admin(); - const certificate = new X509Certificate( - await docker(["exec", currentProxy(), "cat", ROOT_CA]) - ); - expect({ - that: - certificate.ca && - certificate.verify(certificate.publicKey) && - Date.parse(certificate.validFrom) <= Date.now() && - Date.parse(certificate.validTo) > Date.now(), - message: - "Only the current valid self-signed fixture CA may validate routed HTTPS", - }); stage( "isolated Caddy has no host ports and serves a verified TLS canary" ); @@ -1135,7 +825,12 @@ export const nativeConfigRoutingScenario: Scenario = { JSON.stringify( { version: 1, - proxy: { id: proxyId, name: proxyName, token, networkId }, + proxy: { + id: ingress.proxyId, + name: proxyName, + token, + networkId, + }, privateTmpfs: { "/data": PRIVATE_TMPFS, "/config": PRIVATE_TMPFS, diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts new file mode 100644 index 000000000..9da078c47 --- /dev/null +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -0,0 +1,397 @@ +import { randomBytes, X509Certificate } from "node:crypto"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import { expect, runCommand, type ScenarioContext } from "../harness.ts"; + +const TIMEOUT = 180_000; +const OBSERVATION_WINDOW = 30_000; +const OBJECT_ID = /^[a-f0-9]{64}$/; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const PROJECT_LABEL = "com.docker.compose.project"; +const SERVICE_LABEL = "com.docker.compose.service"; +const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; +const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; +const PROXY_PROJECT = "hack-dev-proxy"; +const PROXY_SERVICE = "caddy"; +const NETWORK = "hack-dev"; +const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; +const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; +export const NATIVE_ROUTING_FIXTURE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; +const PRIVATE_TMPFS = NATIVE_ROUTING_FIXTURE_TMPFS; +const PRESERVED_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; +const PROXY_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; + +/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ +export function proxyHasNoPublishedPorts( + info: Readonly> +): boolean { + return ( + info.publishAll === false && + (info.ports === null || + (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && + (info.runtimePorts === null || + (isRecord(info.runtimePorts) && + Object.values(info.runtimePorts).every((value) => value === null))) + ); +} + +function parsed(text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid fixture JSON response; values omitted"); + } +} + +function object(text: string): Record { + const value = parsed(text); + if (!isRecord(value)) { + throw new Error("Expected a complete JSON object; values omitted"); + } + return value; +} + +function ids(text: string): readonly string[] { + const found = text.split(/\s+/).filter(Boolean).sort(); + expect({ + that: + found.every((id) => OBJECT_ID.test(id)) && + new Set(found).size === found.length, + message: "Docker must return unique complete fixture resource IDs", + }); + return found; +} + +/** Shared same-engine fixture ingress: no host ports, DNS or trust writes. */ +export async function prepareNativeRoutingFixtureIngress(opts: { + readonly ctx: ScenarioContext; + readonly docker: (args: readonly string[]) => Promise; +}) { + const { ctx, docker } = opts; + const selectors = [ + "--filter", + `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--filter", + `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, + ]; + // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, + // start, rename or remove them merely to make this scenario runnable. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse native routing fixture while any global Caddy selector is running", + }); + const preserved = ids( + await docker(["ps", "--no-trunc", "-aq", ...selectors]) + ); + const preservedSnapshots = new Map(); + for (const id of preserved) { + const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); + expect({ + that: object(text).running === false, + message: "Pre-existing proxy must be stopped", + }); + preservedSnapshots.set(id, text); + } + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "Native routing fixture requires a Linux Docker daemon", + }); + await docker(["compose", "version"]); + const networkId = await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ]); + expect({ + that: OBJECT_ID.test(networkId), + message: "Existing hack-dev network identity is required", + }); + const image = async (tag: string): Promise => { + const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); + expect({ + that: IMAGE_ID.test(id), + message: + "Fixture images must already be cached; never pull during acceptance", + }); + return id; + }; + const bunImage = await image("oven/bun:1.4.2-slim"); + const caddyImage = await image(CADDY_IMAGE); + const token = randomBytes(16).toString("hex"); + const proxyName = `e2e-native-routing-proxy-${token}`; + const canaryHost = `canary-${token}.test`; + const canaryMarker = `proxy-canary-${token}`; + let proxyId: string | null = null; + let creationAttempted = false; + const currentProxy = (): string => { + if (!(proxyId && OBJECT_ID.test(proxyId))) { + throw new Error("Exact owned fixture proxy ID is unavailable"); + } + return proxyId; + }; + const preservedUnchanged = async (): Promise => { + expect({ + that: + (await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ])) === networkId, + message: "External hack-dev network must retain its exact identity", + }); + for (const [id, before] of preservedSnapshots) { + expect({ + that: + (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === + before, + message: "Stopped user Caddy selectors must remain unchanged", + }); + } + }; + const proxyOwned = async (): Promise => { + const info = object( + await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) + ); + expect({ + that: + info.id === currentProxy() && + info.name === `/${proxyName}` && + info.owner === token && + info.project === PROXY_PROJECT && + info.service === PROXY_SERVICE && + info.networkMode === networkId && + (info.running === false || info.network === networkId) && + proxyHasNoPublishedPorts(info) && + Array.isArray(info.mounts) && + info.mounts.length === 1 && + info.mounts.every( + (mount: unknown) => + isRecord(mount) && + mount.Type === "bind" && + mount.Destination === "/var/run/docker.sock" && + mount.Source === "/var/run/docker.sock" && + mount.RW === false + ) && + isRecord(info.tmpfs) && + Object.keys(info.tmpfs).length === 2 && + info.tmpfs["/data"] === PRIVATE_TMPFS && + info.tmpfs["/config"] === PRIVATE_TMPFS, + message: + "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", + }); + }; + const admin = async (): Promise => { + await proxyOwned(); + const text = await docker([ + "exec", + currentProxy(), + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=http", + "--max-time", + "10", + "--max-redirs", + "0", + "--write-out", + "\n%{http_code}", + "--url", + ADMIN_URL, + ]); + expect({ + that: text.endsWith("\n200"), + message: "Read-only live Caddy configuration probe must return HTTP 200", + }); + return parsed(text.slice(0, -4)); + }; + const absent = async (hosts: readonly string[]): Promise => { + expect({ + that: nativeComposeProxyRoutesMatch({ + servers: await admin(), + expected: [], + absentHostnames: hosts, + }), + message: + "Retired exact fixture origins must be absent from active Caddy routing", + }); + }; + const tls = async (origin: string, marker: string): Promise => { + const url = new URL(origin); + expect({ + that: + url.protocol === "https:" && url.port === "" && url.pathname === "/", + message: "Fixture TLS probes require exact standard HTTPS origins", + }); + const deadline = Date.now() + OBSERVATION_WINDOW; + while (Date.now() < deadline) { + await proxyOwned(); + const result = await runCommand({ + argv: [ + "docker", + "exec", + currentProxy(), + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + ROOT_CA, + "--resolve", + `${url.hostname}:443:127.0.0.1`, + "--url", + `${origin}/`, + ], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + if ( + result.exitCode === 0 && + !result.timedOut && + result.stdout === marker + ) { + return; + } + await Bun.sleep(250); + } + throw new Error( + "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" + ); + }; + const start = async () => { + if (creationAttempted) { + throw new Error("Fixture ingress may be created only once"); + } + creationAttempted = true; + // Repeat ingress absence at the only fixture-global creation boundary. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse a newly appeared running global Caddy before fixture creation", + }); + proxyId = await docker([ + "create", + "--pull=never", + "--name", + proxyName, + "--network", + networkId, + "--label", + `${FIXTURE_LABEL}=${token}`, + "--label", + `${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--label", + `${SERVICE_LABEL}=${PROXY_SERVICE}`, + "--label", + `caddy=https://${canaryHost}`, + "--label", + `caddy.respond=${canaryMarker} 200`, + "--label", + "caddy.tls=internal", + "--env", + `CADDY_INGRESS_NETWORKS=${NETWORK}`, + "--mount", + "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", + // Caddy writes private root-owned files. Keep these in the disposable + // container so Linux cleanup never needs host chown or sudo. + "--tmpfs", + `/data:${PRIVATE_TMPFS}`, + "--tmpfs", + `/config:${PRIVATE_TMPFS}`, + caddyImage, + "docker-proxy", + "--polling-interval", + "1s", + ]); + await proxyOwned(); + await docker(["container", "start", currentProxy()]); + await tls(`https://${canaryHost}`, canaryMarker); + const binding = await observeNativeComposeIngress(); + expect({ + that: + binding.proxyId === currentProxy() && binding.networkId === networkId, + message: + "Product ingress observer must select exactly the new fixture proxy/network", + }); + await admin(); + const certificate = new X509Certificate( + await docker(["exec", currentProxy(), "cat", ROOT_CA]) + ); + expect({ + that: + certificate.ca && + certificate.verify(certificate.publicKey) && + Date.parse(certificate.validFrom) <= Date.now() && + Date.parse(certificate.validTo) > Date.now(), + message: + "Only the current valid self-signed fixture CA may validate routed HTTPS", + }); + return binding; + }; + const cleanup = async () => { + if (proxyId) { + await proxyOwned(); + await docker(["container", "stop", currentProxy()]); + await proxyOwned(); + await docker(["container", "rm", currentProxy()]); + proxyId = null; + } + expect({ + that: + (await docker([ + "ps", + "--no-trunc", + "-aq", + "--filter", + `label=${FIXTURE_LABEL}=${token}`, + ])) === "", + message: + "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", + }); + await preservedUnchanged(); + }; + return { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + get proxyId() { + return proxyId; + }, + start, + admin, + tls, + absent, + preservedUnchanged, + cleanup, + }; +} diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts new file mode 100644 index 000000000..a70b1dec2 --- /dev/null +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -0,0 +1,334 @@ +import { expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { mapLegacyNativeRetainedRouting } from "../src/lib/native-config-import-plan.ts"; +import { + prepareRetainedRoutingFixtureLocals, + retainedRoutingFixtureConfig, + retainedRoutingFixtureOrigins, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./e2e/scenarios/native-compose-adoption-routing-inputs.ts"; +import { + nativeComposeAdoptionRoutingWorktreesScenario, + retainedRoutingPartialStopScript, +} from "./e2e/scenarios/native-compose-adoption-routing-worktrees.ts"; +import { ownedAdoptionFixtureObservation } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; + +const IMAGE = `sha256:${"a".repeat(64)}`; +const IDS = ["b".repeat(64), "c".repeat(64), "d".repeat(64)] as const; +function selection(prefer: "alias" | "dev" = "alias") { + return retainedRoutingFixtureSelection({ + image: IMAGE, + name: "retained-origin", + marker: "synthetic-http-marker", + prefer, + }); +} +test("routing acceptance is explicitly selected and retains uncertain source/resource evidence", () => { + expect( + nativeComposeAdoptionRoutingWorktreesScenario.requiresExplicitSelection + ).toBe(true); + expect( + nativeComposeAdoptionRoutingWorktreesScenario.preserveFixtureOnFailure + ).toBe(true); +}); +test("maintained legacy HTTP fixture has a real apex, existing alias and lossless defined candidate", () => { + const route = selection(); + const result = mapLegacyNativeRetainedRouting({ + configText: JSON.stringify({ + name: "retained-origin", + ...retainedRoutingFixtureConfig(route), + worktree: { auto_branch: false, inherit_local: true }, + }), + composeText: JSON.stringify({ + name: "retained-origin", + services: { + web: retainedRoutingFixtureService(route), + db: { image: IMAGE, volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + networks: { "hack-dev": { external: true } }, + }), + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toBeDefined(); + expect(retainedRoutingFixtureOrigins(route)).toEqual([ + "https://retained-origin.hack.local", + "https://retained-origin.hack.gy", + ]); + expect(retainedRoutingFixtureConfig(selection("dev")).open.prefer).toBe( + "dev" + ); + expect(retainedRoutingFixtureConfig(route).open.prefer).toBe("dev"); + expect(retainedRoutingFixtureService(route).image).toBe(IMAGE); +}); +test("alpha checkout alias differs from authored and primary-local dev selections", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-locals-")); + try { + const primary = { root: join(root, "primary") }; + const instance = { root: join(root, "checkout"), routing: selection() }; + await mkdir(join(primary.root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(instance.root, ".hack"), { recursive: true, mode: 0o700 }); + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [instance], + }); + expect(retainedRoutingFixtureConfig(instance.routing).open.prefer).toBe( + "dev" + ); + expect( + JSON.parse( + await readFile(join(primary.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }); + expect( + JSON.parse( + await readFile(join(instance.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: "alias" }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +test("new routed web observation requires exact birth, zero mounts and original service/name scope", () => { + const instance = { + root: "/synthetic/retained", + name: "retained-origin", + marker: "synthetic-sql", + routing: selection(), + }; + const row = { + id: IDS[1], + createdAt: "2026-10-09T01:02:03Z", + project: instance.name, + nativeNames: [], + name: `/${instance.name}-web-1`, + service: "web", + workingDir: `${instance.root}/.hack`, + configFiles: `${instance.root}/.hack/docker-compose.yml`, + mounts: [], + }; + expect( + ownedAdoptionFixtureObservation({ instance, kind: "container", row }) + ).toEqual({ id: IDS[1], service: "web", createdAt: row.createdAt }); + for (const change of [ + { createdAt: null }, + { mounts: [{ type: "volume" }] }, + { nativeNames: ["io.hack.native-config.owner"] }, + { service: "other" }, + ]) { + expect(() => + ownedAdoptionFixtureObservation({ + instance, + kind: "container", + row: { ...row, ...change }, + }) + ).toThrow(); + } +}); + +/** Closed child ports, but the emitted marker uses the actual exclusive writer. */ +async function replay( + state: unknown, + args: readonly string[], + existingMarker = false +) { + const root = await mkdtemp(join(tmpdir(), "retained-routing-marker-")); + await chmod(root, 0o700); + const markerPath = join(root, "marker"); + try { + if (existingMarker) { + await writeFile(markerPath, "existing-private-marker", { + flag: "wx", + mode: 0o600, + }); + } + const requests: string[][] = []; + let writeError: string | null = null; + const source = retainedRoutingPartialStopScript({ + engine: "/synthetic/docker", + engineId: '"synthetic-daemon"', + receipt: "/synthetic/receipt", + ids: IDS, + stopId: IDS[0], + marker: markerPath, + }); + const exit = { code: -1 }; + const readReceipt = async (path: string, limit: number) => { + if (path !== "/synthetic/receipt" || limit !== 131_072) { + throw new Error("unexpected read"); + } + return { text: JSON.stringify(state) }; + }; + const fixtureProcess = { + argv: ["bun", "shim", ...args], + exit: (code: number): never => { + exit.code = code; + throw exit; + }, + }; + const fixtureBun = { + spawn: (argv: string[], _options: unknown) => { + requests.push(argv); + if ( + JSON.stringify(argv) === + JSON.stringify([ + "/synthetic/docker", + "info", + "--format", + "{{json .ID}}", + ]) + ) { + return { + exited: Promise.resolve(0), + stdout: new Response('"synthetic-daemon"\n').body, + }; + } + if ( + JSON.stringify(argv) === + JSON.stringify(["/synthetic/docker", "container", "stop", IDS[0]]) + ) { + return { exited: Promise.resolve(0) }; + } + throw new Error("unexpected effect"); + }, + }; + const AsyncFunction: new ( + ...args: string[] + ) => (...ports: unknown[]) => Promise = Object.getPrototypeOf( + async () => undefined + ).constructor; + try { + const lines = source.split("\n"); + if ( + !( + lines[1]?.startsWith("import { readPrivate } from ") && + lines[1].endsWith(";") + ) || + lines[2] !== "import { writeFile } from 'node:fs/promises';" + ) { + throw new Error("emitted bounded reader import missing"); + } + await new AsyncFunction( + "Bun", + "process", + "readPrivate", + "writeFile", + lines.slice(3).join("\n") + )(fixtureBun, fixtureProcess, readReceipt, writeFile); + throw new Error("emitted forwarder did not exit"); + } catch (error: unknown) { + if ( + existingMarker && + typeof error === "object" && + error !== null && + "code" in error && + error.code === "EEXIST" + ) { + writeError = "EEXIST"; + } else if (error !== exit) { + throw error; + } + } + let marker: string | null = null; + let markerMode: number | null = null; + try { + const info = await lstat(markerPath); + markerMode = info.mode & 0o777; + marker = (await readPrivate(markerPath, 128)).text; + } catch (error: unknown) { + if ( + !( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + return { code: exit.code, requests, marker, markerMode, writeError }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} +const PENDING = { + adoption_receipt_version: 14, + pendingOperation: { operation: "stop", services: ["db", "web", "worker"] }, + routingOperation: { disposition: "prospective", code: null }, + routingHandoff: "held", +}; +test("emitted partial-stop control requires prospective authority before one original-ID stop and exact marker", async () => { + const result = await replay(PENDING, ["container", "stop", ...IDS]); + expect(result).toEqual({ + code: 71, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "known-routing-partial-stop", + markerMode: 0o600, + writeError: null, + }); +}); +test("emitted partial-stop marker refuses an existing private leaf without overwriting it", async () => { + expect(await replay(PENDING, ["container", "stop", ...IDS], true)).toEqual({ + code: -1, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "existing-private-marker", + markerMode: 0o600, + writeError: "EEXIST", + }); +}); +test("emitted partial-stop control refuses stale journals, incomplete selection and foreign argv without effects", async () => { + for (const state of [ + { ...PENDING, adoption_receipt_version: 11 }, + { ...PENDING, routingOperation: { disposition: "settled", code: 0 } }, + { + ...PENDING, + pendingOperation: { + operation: "start", + services: ["db", "web", "worker"], + }, + }, + { ...PENDING, routingHandoff: "releasing" }, + ]) { + expect(await replay(state, ["container", "stop", ...IDS])).toEqual({ + code: 98, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); + } + expect(await replay(PENDING, ["container", "stop", IDS[0]])).toEqual({ + code: 99, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); +}); diff --git a/tests/native-routing-fixture.test.ts b/tests/native-routing-fixture.test.ts index ced9c955b..7eb9c1650 100644 --- a/tests/native-routing-fixture.test.ts +++ b/tests/native-routing-fixture.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-routing-fixture-ingress.ts"; test("unpublished exposed and stopped ports are safe for the isolated proxy", () => { for (const runtimePorts of [null, {}, { "80/tcp": null, "443/tcp": null }]) { From d794bdfe2a5a0a705beb32496994cfc2a11ad850 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 02:22:21 -0400 Subject: [PATCH 17/23] fix(native-compose): coalesce read-only retained routing proofs Reuse immutable resource binding only inside one private observational phase. Keep complete entry/final bindings, fresh route observations, source and receipt checks, and scoped callback revocation. --- docs/reference/native-compose-adoption.md | 8 + src/lib/native-compose-adoption-generation.ts | 148 ++++++++++----- ...ompose-adoption-routing-generation.test.ts | 169 ++++++++++++++++++ 3 files changed, 285 insertions(+), 40 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index f3c167e86..c4954d26f 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -522,6 +522,14 @@ record; only its one-use known-return and process-group-absence proof settles th record. Explicit recovery can contain an uncertain child but cannot clear its uncertainty merely because containers are stopped. +Within one read-only dispatch proof, a private owner-issued context reuses its +entry resource observation while checking source, receipt, claims and lease +authority throughout. Routing, ingress and foreign-site observations remain +fresh. A complete resource/runtime observation brackets the proof, including +final volume and inventory rereads. The context is revoked before return and +cannot cross a lifecycle effect, publication or another observation phase. +This reduces nested inspection calls; it is not a measured runtime or CPU claim. + Rollback requires restored source bytes, stopped original resources and absent proxy dispatch before handing hostname claims back to the restored legacy source. It retains a durable handoff state across interrupted claim removal. Builds, jobs, diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 828680bbe..686902663 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -492,6 +492,18 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +type RetainedRouteObservation = { + readonly binding: LegacyComposeVerifiedBinding; + readonly runtimeConfig: unknown; + readonly assertActive: () => void; + readonly assertManifest: (meta: SavedManifest) => void; +}; +// Only the private read-only routing proof issues this context. Revoked entries +// remain in the WeakMap so a late continuation refuses instead of reacquiring. +const retainedRouteObservations = new WeakMap< + Context, + RetainedRouteObservation +>(); async function assertRetainedBuildSource(opts: { readonly ctx: Context; readonly meta: SavedManifest; @@ -665,25 +677,65 @@ function requireRetainedClaimContext(opts: { } async function assertRetainedRouteState( ctx: Context, - loaded: { readonly inputs: PrivateInputs }, + loaded: Awaited>, phase: "active" | "stopped", deadline: number, - assertOwner: () => Promise + assertOwner: ( + current: Context + ) => Promise>> ): Promise { const binding = loaded.inputs.binding; - if (binding.binding_version !== 14) { + if (binding.binding_version !== 14 || retainedRouteObservations.has(ctx)) { refuse(); } - await assertLegacyComposeRetainedRoutingState({ - binding, - routing: retainedRoutingIntent(loaded.inputs), - proof: binding.routing, - phase, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - deadline, - assertOwner, + const first = await assertOwner(ctx); + if ( + first.manifest.id !== loaded.manifest.id || + JSON.stringify(first.inputs.binding) !== JSON.stringify(binding) + ) { + refuse(); + } + const observedManifest = JSON.stringify(first.manifest); + const current: Context = { ...ctx }; + let active = true; + const assertActive = () => { + cancelled(current.signal); + if (!(active && Number.isFinite(deadline)) || Date.now() >= deadline) { + refuse(); + } + }; + retainedRouteObservations.set(current, { + binding: first.inputs.binding, + runtimeConfig: first.manifest.runtimeConfig, + assertActive, + assertManifest: (meta) => { + assertActive(); + if (JSON.stringify(meta) !== observedManifest) { + refuse(); + } + }, }); + try { + await assertLegacyComposeRetainedRoutingState({ + binding, + routing: retainedRoutingIntent(loaded.inputs), + proof: binding.routing, + phase, + signal: current.signal, + timeoutMs: current.timeoutMs, + deadline, + assertOwner: async () => { + assertActive(); + await assertOwner(current); + assertActive(); + }, + }); + } finally { + active = false; + } + // The original context performs a complete fresh binding/runtime proof again, + // including foreign resources, final volumes and inventories, before success. + await assertOwner(ctx); } function selectedMapper(routing: boolean, basic: boolean) { if (routing) { @@ -711,6 +763,8 @@ async function readInputs( readonly manifest: Manifest; readonly inputs: Readonly; }> { + const observation = retainedRouteObservations.get(ctx); + observation?.assertActive(); await ctx.check(); const generationRoot = join(ctx.generationsRoot, selected.id); const held = await holdDirectory(generationRoot, true); @@ -723,6 +777,7 @@ async function readInputs( refuse(); } const meta = manifest(saved.value, ctx.root, selected.id); + observation?.assertManifest(meta); const configText = await readArtifact( join(generationRoot, "legacy-config.json"), meta.files.config @@ -800,13 +855,15 @@ async function readInputs( refuse(); } } - const observed = await inspectLegacyComposeAdoptionResources({ - root: ctx.root, - intent: planned.intent, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - composeFiles: projection?.composeFiles, - }); + const observed = observation + ? observation.binding + : await inspectLegacyComposeAdoptionResources({ + root: ctx.root, + intent: planned.intent, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + composeFiles: projection?.composeFiles, + }); if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } @@ -842,12 +899,14 @@ async function readInputs( await claims.close(); } } - const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ - binding: observed, - composeFile: join(generationRoot, "legacy-compose.yml"), - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); + const runtimeConfig = observation + ? observation.runtimeConfig + : await inspectLegacyComposeRuntimeConfig({ + binding: observed, + composeFile: join(generationRoot, "legacy-compose.yml"), + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); if (JSON.stringify(meta.runtimeConfig) !== JSON.stringify(runtimeConfig)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } @@ -887,6 +946,7 @@ async function readInputs( } await assertBuildSource(); await ctx.check(); + observation?.assertManifest(meta); freezeImportValue(observed); return { manifest: { ...meta, binding: observed }, @@ -1869,18 +1929,25 @@ async function assertPublicationRoutingStopped( ) { refuse("E_LEGACY_ADOPTION_BUSY"); } - await assertRetainedRouteState(ctx, loaded, "stopped", deadline, async () => { - await readInputs(ctx, state.prepared ?? refuse()); - await requireStopped(ctx, loaded.inputs.binding); - if (restored) { - await requireRestoredRoutingSourceInputs(ctx, loaded); - } - await requireReceiptSnapshot(ctx, state); - cancelled(ctx.signal); - if (Date.now() >= deadline) { - refuse(); + await assertRetainedRouteState( + ctx, + loaded, + "stopped", + deadline, + async (current) => { + const fresh = await readInputs(current, state.prepared ?? refuse()); + await requireStopped(current, fresh.inputs.binding); + if (restored) { + await requireRestoredRoutingSourceInputs(current, fresh); + } + await requireReceiptSnapshot(current, state); + cancelled(current.signal); + if (Date.now() >= deadline) { + refuse(); + } + return fresh; } - }); + ); } type MutationOptions = Parameters< @@ -2440,11 +2507,12 @@ async function mutateRetainedContainersWithinBudget( loaded, captured.operation === "stop" ? "stopped" : "active", captured.deadline ?? 0, - async () => { - const fresh = await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, fresh); - await requireReceiptSnapshot(ctx, state); + async (current) => { + const fresh = await readInputs(current, owned); + await requireMutationInputs(current, activePublication, fresh); + await requireReceiptSnapshot(current, state); requireMutationDeadline(captured, retainedPlan); + return fresh; } ); await requireReceiptSnapshot(ctx, state); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts index d8927a965..fad4583aa 100644 --- a/tests/native-compose-adoption-routing-generation.test.ts +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -182,6 +182,12 @@ test( args.includes("rm") ) ).toBe(false); + expect(h.commands.length).toBeLessThanOrEqual(9000); + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBeLessThanOrEqual(136); phase("assertions-complete"); } finally { await store.close(); @@ -192,6 +198,169 @@ test( }, { timeoutMs: 60_000 } ); +test("read-only routing phase refuses volume birth drift at its complete final binding", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + expect( + h.commands.filter((args) => args[0] === "volume" && args[1] === "inspect") + .length + ).toBeGreaterThan(changedAtVolumeRead ?? Number.POSITIVE_INFINITY); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("read-only routing phase still inspects a newly introduced foreign site writer", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let introduced = false; + let observed = false; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + introduced = true; + h.model.foreign = true; + } else if ( + introduced && + args[0] === "container" && + args[1] === "inspect" && + args.includes(ROUTING_IDS.foreign) + ) { + observed = true; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(introduced).toBe(true); + expect(windowVolumeReads).toBe(4); + expect(observed).toBe(true); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("completed routing observation cannot carry a resource proof into a later operation", async () => { + const { store, generation } = await prepare(); + try { + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation).toBeNull(); + } finally { + await store.close(); + } +}); +for (const changed of ["source", "receipt"] as const) { + test(`read-only routing phase rechecks ${changed} authority inside its observation window`, async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + const path = + changed === "source" + ? join(h.root, ".hack/hack.config.json") + : h.receiptPath; + const bytes = await readFile(path); + await fs.rename(path, join(h.outer, `replaced-${changed}`)); + await fs.writeFile(path, bytes, { mode: 0o600, flag: "wx" }); + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + if (changedAtVolumeRead === undefined) { + throw new Error("Synthetic authority replacement was not reached"); + } + // A cheap inner authority check must refuse before the final resource scan. + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBe(changedAtVolumeRead); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } + }); +} test("numeric callback cannot settle a prospective child; explicit stop containment retains original uncertainty", async () => { const { store, generation } = await prepare(); try { From 3dd016b96b64af0e4dca63706b4fb3a437b06781 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 03:44:54 -0400 Subject: [PATCH 18/23] test(native-compose): retain routed TLS failure evidence --- .../native-compose-adoption-routing-inputs.ts | 2 +- .../native-routing-fixture-ingress.ts | 138 ++++++++++++++---- ...e-compose-adoption-routing-fixture.test.ts | 10 ++ tests/native-routing-fixture.test.ts | 96 +++++++++++- 4 files changed, 212 insertions(+), 34 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts index 5d41dddaf..c7a9af923 100644 --- a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts @@ -66,7 +66,7 @@ export function retainedRoutingFixtureService( environment: { RETAINED_ROUTE_MARKER: selection.marker }, networks: ["default", "hack-dev"], labels: { - caddy: `${selection.devHost},${selection.aliasHost}`, + caddy: `${selection.devHost}, ${selection.aliasHost}`, "caddy.reverse_proxy": "{{upstreams 3000}}", "caddy.tls": "internal", caddy_ingress_network: "hack-dev", diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts index 9da078c47..4cf54c631 100644 --- a/tests/e2e/scenarios/native-routing-fixture-ingress.ts +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -1,10 +1,13 @@ import { randomBytes, X509Certificate } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; import { isRecord } from "../../../src/lib/guards.ts"; import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; -import { expect, runCommand, type ScenarioContext } from "../harness.ts"; +import { findExecutableInPath } from "../../../src/lib/shell.ts"; +import { expect, type ScenarioContext } from "../harness.ts"; +import { runNativeNetworkFixtureCommand } from "../native-config-networks-acceptance.ts"; -const TIMEOUT = 180_000; const OBSERVATION_WINDOW = 30_000; const OBJECT_ID = /^[a-f0-9]{64}$/; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; @@ -65,12 +68,95 @@ function ids(text: string): readonly string[] { return found; } +/** Each exact TLS attempt retains private bounded streams even on failure. */ +export async function runNativeRoutingFixtureTlsAttempt(opts: { + readonly docker: string; + readonly proxyId: string; + readonly origin: string; + readonly cwd: string; + readonly captures: string; + readonly env: Readonly>; + readonly timeoutMs: number; +}) { + const url = new URL(opts.origin); + expect({ + that: + isAbsolute(opts.docker) && + OBJECT_ID.test(opts.proxyId) && + url.protocol === "https:" && + url.port === "" && + url.pathname === "/" && + !url.username && + !url.password && + !url.search && + !url.hash && + opts.timeoutMs > 0 && + opts.timeoutMs <= OBSERVATION_WINDOW, + message: "Fixture TLS capture admission refused; values omitted", + }); + const result = await runNativeNetworkFixtureCommand({ + argv: [ + opts.docker, + "exec", + opts.proxyId, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + ROOT_CA, + "--resolve", + `${url.hostname}:443:127.0.0.1`, + "--url", + `${opts.origin}/`, + ], + cwd: opts.cwd, + env: opts.env, + captures: opts.captures, + timeoutMs: opts.timeoutMs, + outputLimit: 64 * 1024, + }); + await writeFile( + join(opts.captures, "attempt.json"), + `${JSON.stringify({ + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + })}\n`, + { flag: "wx", mode: 0o600 } + ); + return result; +} + /** Shared same-engine fixture ingress: no host ports, DNS or trust writes. */ export async function prepareNativeRoutingFixtureIngress(opts: { readonly ctx: ScenarioContext; readonly docker: (args: readonly string[]) => Promise; }) { const { ctx, docker } = opts; + const executable = findExecutableInPath("docker"); + const tlsEnv = Object.freeze({ ...process.env }) as Readonly< + Record + >; + let tlsAttempt = 0; + expect({ + that: Boolean(executable), + message: "Fixed fixture Docker executable is required", + }); const selectors = [ "--filter", `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, @@ -240,37 +326,25 @@ export async function prepareNativeRoutingFixtureIngress(opts: { const deadline = Date.now() + OBSERVATION_WINDOW; while (Date.now() < deadline) { await proxyOwned(); - const result = await runCommand({ - argv: [ - "docker", - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=https", - "--max-redirs", - "0", - "--connect-timeout", - "2", - "--max-time", - "5", - "--cacert", - ROOT_CA, - "--resolve", - `${url.hostname}:443:127.0.0.1`, - "--url", - `${origin}/`, - ], + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + if (!executable) { + throw new Error("Fixed fixture Docker executable is required"); + } + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: currentProxy(), + origin, cwd: ctx.tempRoot, - timeoutMs: TIMEOUT, + env: tlsEnv, + captures: join( + ctx.tempRoot, + "routing-tls-attempts", + String(tlsAttempt++) + ), + timeoutMs: Math.min(OBSERVATION_WINDOW, remaining), }); if ( result.exitCode === 0 && diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts index a70b1dec2..a7877ea02 100644 --- a/tests/native-compose-adoption-routing-fixture.test.ts +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -73,6 +73,16 @@ test("maintained legacy HTTP fixture has a real apex, existing alias and lossles expect(retainedRoutingFixtureConfig(route).open.prefer).toBe("dev"); expect(retainedRoutingFixtureService(route).image).toBe(IMAGE); }); +test("served legacy Caddy origins use separate address tokens", () => { + const route = selection(); + const label = retainedRoutingFixtureService(route).labels.caddy; + const expected = [route.devHost, route.aliasHost]; + expect(label).toBe(expected.join(", ")); + expect(label.split(/,\s+/)).toEqual(expected); + // The retained actual RED used this one-token spelling and Caddy rejected it. + expect(expected.join(",").split(/,\s+/)).not.toEqual(expected); +}); + test("alpha checkout alias differs from authored and primary-local dev selections", async () => { const root = await mkdtemp(join(tmpdir(), "retained-routing-locals-")); try { diff --git a/tests/native-routing-fixture.test.ts b/tests/native-routing-fixture.test.ts index 7eb9c1650..d752d6143 100644 --- a/tests/native-routing-fixture.test.ts +++ b/tests/native-routing-fixture.test.ts @@ -1,5 +1,19 @@ import { expect, test } from "bun:test"; -import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-routing-fixture-ingress.ts"; +import { + chmod, + lstat, + mkdtemp, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { + proxyHasNoPublishedPorts, + runNativeRoutingFixtureTlsAttempt, +} from "./e2e/scenarios/native-routing-fixture-ingress.ts"; test("unpublished exposed and stopped ports are safe for the isolated proxy", () => { for (const runtimePorts of [null, {}, { "80/tcp": null, "443/tcp": null }]) { @@ -38,3 +52,83 @@ test("missing, malformed and explicit published port facts refuse", () => { expect(proxyHasNoPublishedPorts(facts)).toBe(false); } }); + +test("a failed exact TLS attempt retains bounded private stderr and its exit disposition", async () => { + const root = await mkdtemp(join(tmpdir(), "routing-tls-capture-")); + let settled = false; + try { + const executable = join(root, "docker"); + const id = "a".repeat(64); + const expected = [ + "exec", + id, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + "/data/caddy/pki/authorities/local/root.crt", + "--resolve", + "retained-origin.test:443:127.0.0.1", + "--url", + "https://retained-origin.test/", + ]; + await writeFile( + executable, + `#!${process.execPath}\nif(JSON.stringify(process.argv.slice(2))!==${JSON.stringify(JSON.stringify(expected))})process.exit(97);console.error("fixed synthetic TLS alert");process.exit(35);\n`, + { flag: "wx", mode: 0o700 } + ); + await chmod(executable, 0o700); + const captures = join(root, "captures"); + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: id, + origin: "https://retained-origin.test", + cwd: root, + captures, + env: { PATH: "/usr/bin:/bin" }, + timeoutMs: 2000, + }); + settled = true; + expect(result.exitCode).toBe(35); + expect(result.timedOut).toBe(false); + expect(result.stderr).toBe("fixed synthetic TLS alert\n"); + const leaves = await readdir(captures); + expect(leaves.length).toBe(3); + for (const leaf of leaves) { + expect((await lstat(join(captures, leaf))).mode & 0o777).toBe(0o600); + } + const receipt = await readPrivate(join(captures, "attempt.json"), 1024); + expect(JSON.parse(receipt.text)).toEqual({ + exitCode: 35, + timedOut: false, + stdoutBytes: 0, + stderrBytes: 26, + }); + const stderr = leaves.find((leaf) => leaf.endsWith(".stderr")); + expect(stderr).toBeDefined(); + if (!stderr) { + throw new Error("Missing private TLS stderr capture"); + } + expect((await readPrivate(join(captures, stderr), 1024)).text).toBe( + "fixed synthetic TLS alert\n" + ); + } finally { + if (settled) { + await rm(root, { recursive: true, force: true }); + } + } +}); From e50868ecef1dafcc8564f86c45364fc8bc3ad9a2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 05:08:26 -0400 Subject: [PATCH 19/23] fix: preserve retained routing local open preference --- ...ive-compose-adoption-routing-resolution.ts | 21 ++++++- ...tive-compose-adoption-routing-worktrees.ts | 29 ++++++++- ...e-compose-adoption-routing-fixture.test.ts | 37 ++++++++++++ ...ompose-adoption-routing-resolution.test.ts | 60 ++++++++++++++++++- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/lib/native-compose-adoption-routing-resolution.ts b/src/lib/native-compose-adoption-routing-resolution.ts index 5c9a197de..e44d4a8b7 100644 --- a/src/lib/native-compose-adoption-routing-resolution.ts +++ b/src/lib/native-compose-adoption-routing-resolution.ts @@ -1,7 +1,8 @@ import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; -/** Effective typed-local precedence may qualify only the already served origins. */ +/** Effective typed-local preference selects among the already served origins. + * It may differ from the raw project preference without changing any route. */ export function legacyComposeRoutingResolutionMatches(opts: { readonly routing: LegacyComposeRoutingIntent; readonly resolution: NativeRoutingResolution | null | undefined; @@ -10,11 +11,25 @@ export function legacyComposeRoutingResolutionMatches(opts: { if ( !resolution || resolution.branch !== undefined || - resolution.project_origin !== `https://${routing.devHost}` || - resolution.open_origin !== routing.openOrigin + resolution.project_origin !== `https://${routing.devHost}` ) { return false; } + const devOrigin = `https://${routing.devHost}`; + const aliasOrigin = routing.aliasHost ? `https://${routing.aliasHost}` : null; + let openOrigin: string; + if (resolution.open_preference === "dev") { + openOrigin = devOrigin; + } else if (resolution.open_preference === "auto") { + openOrigin = aliasOrigin ?? devOrigin; + } else if (resolution.open_preference === "alias" && aliasOrigin) { + openOrigin = aliasOrigin; + } else { + return false; + } + if (resolution.open_origin !== openOrigin) { + return false; + } const aliases = routing.aliasHost ? { oauth: `https://${routing.aliasHost}` } : {}; diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts index b72f209e4..40c539595 100644 --- a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "../../../src/lib/guards.ts"; -import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { + readPrivate, + writeExclusive, +} from "../../../src/lib/native-compose-private-state.ts"; import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; import type { CliResult, Scenario } from "../harness.ts"; import { @@ -122,6 +125,7 @@ async function partialStop(h: Runtime) { const result = await h.cli(h.first, ["config", "adopt", "--stop", "--json"], { PATH: `${root}:${process.env.PATH ?? "/usr/bin:/bin"}`, }); + await retainRoutingAdoptStopResult({ root, result }); if ( result.timedOut || result.exitCode !== 71 || @@ -175,6 +179,29 @@ async function partialStop(h: Runtime) { await h.assertStopped(h.first); } +/** Preserve the known CLI return before the fixture's stop oracle can refuse. + * These private streams are evidence only; they grant no cleanup authority. */ +export async function retainRoutingAdoptStopResult(opts: { + readonly root: string; + readonly result: CliResult; +}): Promise { + const { exitCode, timedOut, stdout, stderr } = opts.result; + if ( + !Number.isInteger(exitCode) || + exitCode < 0 || + exitCode > 255 || + typeof timedOut !== "boolean" || + Buffer.byteLength(stdout) > 64 * 1024 || + Buffer.byteLength(stderr) > 64 * 1024 + ) { + return refuse(); + } + await writeExclusive( + join(opts.root, "adopt-stop-result.json"), + `${JSON.stringify({ phase: "adopt-stop-return", exitCode, timedOut, stdout, stderr })}\n` + ); +} + async function savedOpen(h: Runtime, instance: Instance) { const selected = instance.routing; if (!selected) { diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts index a7877ea02..780177a7f 100644 --- a/tests/native-compose-adoption-routing-fixture.test.ts +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -22,11 +22,48 @@ import { import { nativeComposeAdoptionRoutingWorktreesScenario, retainedRoutingPartialStopScript, + retainRoutingAdoptStopResult, } from "./e2e/scenarios/native-compose-adoption-routing-worktrees.ts"; import { ownedAdoptionFixtureObservation } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; const IMAGE = `sha256:${"a".repeat(64)}`; const IDS = ["b".repeat(64), "c".repeat(64), "d".repeat(64)] as const; +test("first adoption refusal preserves bounded private result before the stop oracle", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-result-")); + try { + const result = { + command: "not-persisted", + exitCode: 1, + timedOut: false, + stdout: '{"ok":false,"error":{"code":"E_CONFIG_INVALID"}}', + stderr: "synthetic refusal detail", + combined: "not-persisted", + durationMs: 1, + }; + const path = join(root, "adopt-stop-result.json"); + await expect( + retainRoutingAdoptStopResult({ + root, + result: { ...result, stdout: "x".repeat(64 * 1024 + 1) }, + }) + ).rejects.toThrow(); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + await retainRoutingAdoptStopResult({ root, result }); + expect(JSON.parse((await readPrivate(path, 256 * 1024)).text)).toEqual({ + phase: "adopt-stop-return", + exitCode: 1, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + }); + expect((await lstat(path)).mode & 0o777).toBe(0o600); + await expect( + retainRoutingAdoptStopResult({ root, result }) + ).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); function selection(prefer: "alias" | "dev" = "alias") { return retainedRoutingFixtureSelection({ image: IMAGE, diff --git a/tests/native-compose-adoption-routing-resolution.test.ts b/tests/native-compose-adoption-routing-resolution.test.ts index 1a3e48604..f2abc04a3 100644 --- a/tests/native-compose-adoption-routing-resolution.test.ts +++ b/tests/native-compose-adoption-routing-resolution.test.ts @@ -4,13 +4,13 @@ import { resolveNativeComposeOpenOrigin } from "../src/lib/native-compose-open.t import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; -function fixture() { +function fixture(authoredPreference: "auto" | "alias" | "dev" = "alias") { const routing = mapLegacyComposeRouting({ config: { name: "fixture", dev_host: "original.hack.local", oauth: { enabled: true }, - open: { prefer: "alias" }, + open: { prefer: authoredPreference }, }, compose: { name: "fixture", @@ -71,6 +71,62 @@ test("typed-local domain precedence may retain exact served origins and saved op }) ).toBe("https://original.hack.gy"); }); +test("effective checkout preference can differ from raw project preference without changing served origins", () => { + for (const [authored, effective, expected] of [ + ["dev", "alias", "https://original.hack.gy"], + ["alias", "dev", "https://original.hack.local"], + ["dev", "auto", "https://original.hack.gy"], + ] as const) { + const { routing, resolution } = fixture(authored); + const selected: NativeRoutingResolution = { + ...resolution, + open_preference: effective, + open_preference_origin: "checkout_local", + open_origin: expected, + }; + expect(routing.openOrigin).not.toBe(expected); + expect( + legacyComposeRoutingResolutionMatches({ routing, resolution: selected }) + ).toBe(true); + expect(resolveNativeComposeOpenOrigin({ resolution: selected })).toBe( + expected + ); + } +}); +test("effective preference cannot select an inconsistent or unavailable origin", () => { + const { routing, resolution } = fixture("dev"); + const web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { open_preference: "dev", open_origin: "https://original.hack.gy" }, + { open_preference: "alias", open_origin: "https://original.hack.local" }, + { open_preference: "auto", open_origin: "https://original.hack.local" }, + ] as const) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + } + expect( + legacyComposeRoutingResolutionMatches({ + routing: { ...routing, aliasHost: null }, + resolution: { + ...resolution, + aliases: {}, + oauth_alias: null, + open_preference: "alias", + open_origin: "https://original.hack.local", + routes: { + web: { ...web, aliases: {} }, + }, + }, + }) + ).toBe(false); +}); for (const change of [ { branch: "other" }, { project_origin: "https://renamed.test" }, From 3fb9319d929e08ed2dc7cf5cff8625764573f342 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 05:48:41 -0400 Subject: [PATCH 20/23] fix: retain owner-issued process policy refusal diagnostics --- .github/workflows/ci.yml | 1 + src/lib/native-compose-ownership.ts | 143 +++++++++++++----- .../native-process-policy-initial-replay.ts | 99 +++++++++--- .../scenarios/native-config-process-policy.ts | 13 +- tests/native-compose-ownership.test.ts | 105 +++++++++++-- ...ative-process-policy-initial-trace.test.ts | 104 ++++++++++++- ...ative-process-policy-replay-reason.test.ts | 20 +-- 7 files changed, 396 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31345967d..29c7115f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,7 @@ jobs: run: HACK_TEST_COMPOSE_CONFIG=1 bun test tests/native-compose-renderer-config.test.ts --test-name-pattern '^one authored bridge keeps internal policy and static aliases through compiler and Compose normalization$' - name: Run local and Docker E2E env: + HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE: "1" HACK_E2E_CLI_BIN: ${{ github.workspace }}/dist/hack run: | HACK_E2E_DOCKER_HOST="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}" diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index fe852b0c2..b9a17c8c0 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -155,6 +155,28 @@ export type NativeComposeProbeFailure = | "decode"; const probeFailures = new WeakMap(); +/** The first refused ownership predicate only; never a claim about its external cause. */ +export type NativeComposeOwnershipRefusal = + | "resource-label" + | "generation" + | "state" + | "volume-birth" + | "bridge-policy" + | "topology" + | "endpoint" + | "cross-scan-drift" + | "unknown"; +const ownershipRefusals = new WeakMap(); + +/** Only captured-reply replay reads this diagnostic; callers cannot mint it from error properties. */ +export function nativeComposeOwnershipRefusal( + error: unknown +): NativeComposeOwnershipRefusal | undefined { + return typeof error === "object" && error !== null + ? ownershipRefusals.get(error) + : undefined; +} + /** Copies, prototypes and caller-created errors cannot acquire an observed probe classification. */ export function nativeComposeProbeFailure( error: unknown @@ -196,12 +218,20 @@ export class NativeComposeOwnershipError extends Error { this.code = code; } } -function refuse(code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP"): never { - throw new NativeComposeOwnershipError(code); +function refuse( + code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP", + reason: NativeComposeOwnershipRefusal = "unknown" +): never { + const error = new NativeComposeOwnershipError(code); + ownershipRefusals.set(error, reason); + throw error; } -function requireValue(value: unknown): asserts value { +function requireValue( + value: unknown, + reason: NativeComposeOwnershipRefusal = "unknown" +): asserts value { if (!value) { - refuse(); + refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason); } } @@ -229,7 +259,7 @@ export function mergeNativeComposeNetworkPolicies(opts: { next.internal === previous.internal ) ) { - return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION"); + return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION", "bridge-policy"); } } } @@ -484,7 +514,8 @@ async function queryNativeComposeOwned( current.storage === volume.storage && (volume.createdAt === undefined || current.createdAt === volume.createdAt) - ) + ), + "volume-birth" ); } } @@ -522,21 +553,25 @@ async function queryNativeComposeOwned( first.name === second.name && first.generationId === second.generationId && first.service === second.service && - first.oneoff === second.oneoff + first.oneoff === second.oneoff, + "cross-scan-drift" ); } requireValue( JSON.stringify(topologySnapshot(observations, restarting)) === - JSON.stringify(topologySnapshot(rechecked, restarting)) + JSON.stringify(topologySnapshot(rechecked, restarting)), + "cross-scan-drift" ); requireValue( JSON.stringify(volumeSnapshot(observations)) === - JSON.stringify(volumeSnapshot(rechecked)) + JSON.stringify(volumeSnapshot(rechecked)), + "cross-scan-drift" ); for (const kind of ["container", "volume", "network"] as const) { requireValue( JSON.stringify(await inventory(kind)) === - JSON.stringify(selected.get(kind)) + JSON.stringify(selected.get(kind)), + "cross-scan-drift" ); } return { @@ -594,19 +629,22 @@ async function collectInspections(input: { remaining.delete(row.id); requireValue( row.name === - (kind === "container" ? `/${resource.name}` : resource.name) + (kind === "container" ? `/${resource.name}` : resource.name), + "resource-label" ); requireValue( - row.project === opts.composeProject && row.project === resource.project + row.project === opts.composeProject && row.project === resource.project, + "resource-label" ); requireValue( row.version === "1" && row.instance === opts.runtimeIdentity && - row.owner === opts.ownerToken + row.owner === opts.ownerToken, + "resource-label" ); if (kind === "container") { containers.push(containerObservation(row, opts)); - requireValue(isRecord(row.networks)); + requireValue(isRecord(row.networks), "endpoint"); observations.endpoints.set(resource.id, row.networks); } else if (kind === "volume") { requireValue( @@ -622,18 +660,25 @@ async function collectInspections(input: { ]) ); requireValue( - typeof row.storage === "string" && SERVICE.test(row.storage) + typeof row.storage === "string" && SERVICE.test(row.storage), + "resource-label" ); const expectedVolume = opts.expectedVolumes?.find( (volume) => volume.name === resource.name ); requireValue( - expectedVolume !== undefined && expectedVolume.storage === row.storage + expectedVolume !== undefined && + expectedVolume.storage === row.storage, + "resource-label" + ); + requireValue( + nativeComposeVolumeCreatedAt(row.createdAt), + "volume-birth" ); - requireValue(nativeComposeVolumeCreatedAt(row.createdAt)); requireValue( expectedVolume.createdAt === undefined || - expectedVolume.createdAt === row.createdAt + expectedVolume.createdAt === row.createdAt, + "volume-birth" ); volumes.push({ name: resource.name, @@ -660,11 +705,15 @@ async function collectInspections(input: { requireValue( expected !== undefined && row.driver === expected.driver && - row.internal === expected.internal + row.internal === expected.internal, + "bridge-policy" ); - requireValue(isRecord(row.containers)); + requireValue(isRecord(row.containers), "topology"); const members = Object.keys(row.containers); - requireValue(members.every((id) => ID.test(id))); + requireValue( + members.every((id) => ID.test(id)), + "topology" + ); observations.members.set(resource.name, members.sort()); networks.push({ id: resource.id, name: resource.name }); } @@ -695,17 +744,20 @@ function containerObservation( ); requireValue( typeof row.generation === "string" && - opts.generationIds.includes(row.generation) + opts.generationIds.includes(row.generation), + "generation" ); requireValue( typeof row.service === "string" && - opts.expectedServices.includes(row.service) + opts.expectedServices.includes(row.service), + "resource-label" ); requireValue( row.oneoff === "True" || row.oneoff === "False" || row.oneoff === "true" || - row.oneoff === "false" + row.oneoff === "false", + "resource-label" ); requireValue( row.state === "created" || @@ -714,18 +766,21 @@ function containerObservation( row.state === "removing" || row.state === "paused" || row.state === "exited" || - row.state === "dead" + row.state === "dead", + "state" ); requireValue( typeof row.exitCode === "number" && Number.isSafeInteger(row.exitCode) && - row.exitCode >= 0 + row.exitCode >= 0, + "state" ); requireValue( row.health === null || row.health === "starting" || row.health === "healthy" || - row.health === "unhealthy" + row.health === "unhealthy", + "state" ); requireValue(typeof row.id === "string" && ID.test(row.id)); requireValue(typeof row.name === "string"); @@ -745,7 +800,8 @@ function endpointAliases(value: unknown): readonly string[] { requireValue( Array.isArray(value) && value.every((alias) => typeof alias === "string" && NAME.test(alias)) && - new Set(value).size === value.length + new Set(value).size === value.length, + "endpoint" ); return [...new Set(value as string[])].sort(); } @@ -767,7 +823,8 @@ function validateEndpointIdentity(opts: { if (unrealizedCreatedEndpoint) { requireValue( id !== undefined && - (endpoint.NetworkID === undefined || endpoint.NetworkID === "") + (endpoint.NetworkID === undefined || endpoint.NetworkID === ""), + "endpoint" ); if ( endpoint.Aliases === undefined || @@ -777,16 +834,20 @@ function validateEndpointIdentity(opts: { return; } } else if (absentOwnedRecovery) { - requireValue(endpoint.NetworkID === undefined || endpoint.NetworkID === ""); + requireValue( + endpoint.NetworkID === undefined || endpoint.NetworkID === "", + "endpoint" + ); if (endpoint.Aliases === undefined || endpoint.Aliases === null) { return; } } else { - requireValue(id !== undefined && endpoint.NetworkID === id); + requireValue(id !== undefined && endpoint.NetworkID === id, "endpoint"); } requireValue( JSON.stringify(endpointAliases(endpoint.Aliases)) === - JSON.stringify(expectedAliases) + JSON.stringify(expectedAliases), + "endpoint" ); } @@ -869,7 +930,8 @@ function requireLiveMember(opts: { networkPolicies(selection).length === 1 && networkPolicies(selection)[0]?.internal === false && networkId !== undefined && - endpoint.NetworkID === networkId + endpoint.NetworkID === networkId, + "topology" ); return true; } @@ -894,7 +956,8 @@ function validateTopology( (id) => containers.has(id) && Object.hasOwn(observations.endpoints.get(id) ?? {}, name) - ) + ), + "topology" ); } for (const container of observations.containers) { @@ -903,18 +966,19 @@ function validateTopology( workload.generationId === container.generationId && workload.service === container.service ); - requireValue(policy !== undefined); + requireValue(policy !== undefined, "topology"); const endpoints = observations.endpoints.get(container.id); requireValue( endpoints !== undefined && hasKeys( endpoints, policy.networks.map((network) => network.name) - ) + ), + "topology" ); for (const attachment of policy.networks) { const endpoint = endpoints[attachment.name]; - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); const id = attachment.externalId ?? owned.get(attachment.name); const absentOwnedRecovery = opts.recovery === "down" && @@ -930,7 +994,8 @@ function validateTopology( }); if (unrealizedCreatedEndpoint) { requireValue( - !observations.members.get(attachment.name)?.includes(container.id) + !observations.members.get(attachment.name)?.includes(container.id), + "topology" ); } const expected = container.oneoff @@ -984,7 +1049,7 @@ function topologySnapshot( id, Object.entries(endpoints) .map(([name, endpoint]) => { - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); return [ name, endpoint.NetworkID, diff --git a/tests/e2e/native-process-policy-initial-replay.ts b/tests/e2e/native-process-policy-initial-replay.ts index 5f103492e..ee14e83a6 100644 --- a/tests/e2e/native-process-policy-initial-replay.ts +++ b/tests/e2e/native-process-policy-initial-replay.ts @@ -3,16 +3,15 @@ import { join } from "node:path"; import { isRecord } from "../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../src/lib/native-compose-generation.ts"; import { readNativeComposeNetworkTopology } from "../../src/lib/native-compose-network-topology.ts"; -import type { NativeComposeOwnershipOptions } from "../../src/lib/native-compose-ownership.ts"; +import type { + NativeComposeOwnershipOptions, + NativeComposeOwnershipRefusal, +} from "../../src/lib/native-compose-ownership.ts"; import { exec } from "../../src/lib/shell.ts"; import { type ProcessPolicyInitialTraceQuery, readProcessPolicyInitialTrace, } from "./native-process-policy-initial-trace.ts"; -import { - isProcessPolicyReplayReason, - type ProcessPolicyReplayReason, -} from "./native-process-policy-replay-reason.ts"; const REFUSAL = "Initial process-policy replay unavailable; values omitted"; const SERVICES = ["forced", "graceful", "reaper", "retry"] as const; @@ -26,13 +25,28 @@ const CODES = [ type Replay = { readonly outcome: "owned" | "unready" | "refused"; readonly code: (typeof CODES)[number] | null; + /** First replayed ownership predicate only; absent for success or unclassified probe errors. */ + readonly reason: NativeComposeOwnershipRefusal | null; readonly consumed: number; readonly protocolMatched: boolean; - readonly reason: ProcessPolicyReplayReason | null; }; function replayCode(value: unknown): value is Replay["code"] { return value === null || CODES.some((code) => code === value); } +function replayReason(value: unknown): value is Replay["reason"] { + return ( + value === null || + value === "resource-label" || + value === "generation" || + value === "state" || + value === "volume-birth" || + value === "bridge-policy" || + value === "topology" || + value === "endpoint" || + value === "cross-scan-drift" || + value === "unknown" + ); +} /** Run the real ownership policy against original recorded replies, with no engine access. */ export async function replayProcessPolicyInitialOwnership(opts: { @@ -70,14 +84,11 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode); ); await chmod(docker, 0o700); const program = ` -import {captureProcessPolicyOwnershipSource,sameProcessPolicyOwnershipSource,processPolicyReplayReason} from ${JSON.stringify(join(import.meta.dir, "native-process-policy-replay-reason.ts"))}; -const sourceBefore=captureProcessPolicyOwnershipSource(); -const {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError}=await import(${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}); +import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; const {selection}=await Bun.file(${JSON.stringify(inputs)}).json(); let outcome="refused",code=null,reason=null; try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";} -catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=code==="E_NATIVE_COMPOSE_OWNERSHIP"&&sourceBefore?processPolicyReplayReason({stack:error.stack,sourcePath:sourceBefore.path,sourceSha256:sourceBefore.sha256}):"unavailable";} -if(outcome==="refused"&&!sameProcessPolicyOwnershipSource(sourceBefore,captureProcessPolicyOwnershipSource()))reason="unavailable"; +catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;} const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0; process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); `; @@ -101,13 +112,9 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche value.outcome === "unready" || value.outcome === "refused") && replayCode(value.code) && - (value.reason === null || isProcessPolicyReplayReason(value.reason)) && - (value.outcome === "refused" - ? value.reason !== null - : value.reason === null) && - (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || - value.reason === null || - value.reason === "unavailable") && + replayReason(value.reason) && + (value.outcome === "refused" || value.reason === null) && + (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || value.reason === null) && Number.isInteger(value.consumed) && typeof value.consumed === "number" && value.consumed >= 0 && @@ -276,3 +283,59 @@ export async function summarizeProcessPolicyInitialTrace(opts: { await store.close(); } } + +/** Explicit opt-in persists only the first owner-issued refusal from matching after-Compose + * recorded replies. This cannot identify the original caller mode or timing. */ +export async function persistProcessPolicyFirstAfterComposeRefusal(opts: { + readonly directory: string; + readonly enabled: string | undefined; + readonly summary: Awaited< + ReturnType + >; +}) { + if (opts.enabled !== "1") { + return null; + } + const row = opts.summary.observations.find( + (observation) => + observation.phase === "after-compose" && + observation.startup.protocolMatched && + observation.strict.protocolMatched && + [observation.startup, observation.strict].some( + (mode) => + mode.outcome === "refused" && + mode.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ) + ); + if (!row) { + return null; + } + const capsule = { + version: 1 as const, + kind: "native-process-policy-after-compose-replay-refusal" as const, + observationIndex: row.index, + replayUsesRecordedReplies: true as const, + replaysWallTiming: false as const, + originalCallerModeKnown: false as const, + startupReason: + row.startup.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.startup.reason + : null, + strictReason: + row.strict.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.strict.reason + : null, + }; + const handle = await open( + join(opts.directory, "first-after-compose-replay-refusal.json"), + "wx", + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(capsule)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + return capsule; +} diff --git a/tests/e2e/scenarios/native-config-process-policy.ts b/tests/e2e/scenarios/native-config-process-policy.ts index 88a20ae75..aa52125fe 100644 --- a/tests/e2e/scenarios/native-config-process-policy.ts +++ b/tests/e2e/scenarios/native-config-process-policy.ts @@ -16,7 +16,10 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { summarizeProcessPolicyInitialTrace } from "../native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + summarizeProcessPolicyInitialTrace, +} from "../native-process-policy-initial-replay.ts"; import { prepareProcessPolicyInitialTrace } from "../native-process-policy-initial-trace.ts"; import { isKnownUncertainProcessPolicyStartup, @@ -733,6 +736,14 @@ export const nativeConfigProcessPolicyScenario: Scenario = { ctx.log( `fixed-field original-query replay: ${JSON.stringify(replay)}` ); + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: ctx.tempRoot, + enabled: process.env.HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE, + summary: replay, + }); + ctx.log( + `fixed-field first after-compose replay refusal: ${JSON.stringify(capsule)}` + ); } catch { stage( "fixed-field original-query replay unavailable; no cause inferred" diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index f758b63a3..5bbca5bc0 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -7,6 +7,8 @@ import { mergeNativeComposeNetworkPolicies, NativeComposeOwnershipError, type NativeComposeOwnershipOptions, + type NativeComposeOwnershipRefusal, + nativeComposeOwnershipRefusal, observeNativeComposeStartupOwned, observeSavedNativeComposeOwned, } from "../src/lib/native-compose-ownership.ts"; @@ -172,7 +174,8 @@ async function commands(): Promise { } async function expectRefusal( opts = options, - code = "E_NATIVE_COMPOSE_OWNERSHIP" + code = "E_NATIVE_COMPOSE_OWNERSHIP", + reason?: NativeComposeOwnershipRefusal ) { try { await assertNativeComposeOwned(opts); @@ -180,12 +183,73 @@ async function expectRefusal( } catch (error: unknown) { expect(error).toBeInstanceOf(NativeComposeOwnershipError); expect(error).toMatchObject({ code }); + if (reason !== undefined) { + expect(nativeComposeOwnershipRefusal(error)).toBe(reason); + } expect(String(error)).not.toContain(CANARY); expect(JSON.stringify(error)).not.toContain(CANARY); } expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } +test("ownership refusal diagnostics cannot be forged, copied or obtained through getters", () => { + let issued: unknown; + try { + mergeNativeComposeNetworkPolicies({ + proposed: [], + retained: [ + [{ name: "fixture_default", driver: "bridge", internal: false }], + ], + }); + } catch (error: unknown) { + issued = error; + } + expect(issued).toBeInstanceOf(NativeComposeOwnershipError); + if (!(issued instanceof NativeComposeOwnershipError)) { + throw new Error("Missing owner-issued refusal"); + } + expect(nativeComposeOwnershipRefusal(issued)).toBe("bridge-policy"); + expect(issued).toMatchObject({ code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION" }); + expect(String(issued)).toBe( + "NativeComposeOwnershipError: Native Compose network topology changed. Run hack down for this instance before applying the change; values omitted." + ); + expect(JSON.parse(JSON.stringify(issued))).toEqual({ + code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION", + name: "NativeComposeOwnershipError", + }); + const clone = { ...issued, reason: "bridge-policy", secret: CANARY }; + let getters = 0; + const accessor = Object.defineProperty({}, "reason", { + get() { + getters += 1; + throw new Error(CANARY); + }, + }); + const proxy = new Proxy( + {}, + { + get() { + getters += 1; + throw new Error(CANARY); + }, + } + ); + for (const candidate of [ + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + clone, + Object.create(issued), + accessor, + proxy, + null, + undefined, + CANARY, + 1, + ]) { + expect(nativeComposeOwnershipRefusal(candidate)).toBeUndefined(); + } + expect(getters).toBe(0); +}); + test("selected on-failure restart is unready until two stable owned scans", async () => { const fixture = owned(); const container = fixture.container?.[0]; @@ -198,7 +262,7 @@ test("selected on-failure restart is unready until two stable owned scans", asyn await prepare(fixture); expect(await observeNativeComposeStartupOwned(options, ["web"])).toBeNull(); // The same observation must never authorize effect or finalization ownership. - await expectRefusal(options); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "topology"); fixture.mode = "restart-member-transition"; await prepare(fixture); await expectRefusal(options); @@ -453,7 +517,11 @@ test("old or missing owner tokens never adopt same-instance resources", async () } row.owner = owner; await prepare(fixture); - await expectRefusal(); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "resource-label" + ); } } }); @@ -465,7 +533,7 @@ test("unsolicited raw inspect fields are rejected without disclosing private val } row.Config = { Env: [CANARY], Image: CANARY }; await prepare(fixture); - await expectRefusal(); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "unknown"); }); test("every project container is checked, including unknown services and stale or absent generation", async () => { for (const fields of [ @@ -494,11 +562,23 @@ test("every project container is checked, including unknown services and stale o state: "running", exitCode: 0, health: null, + networks: { + [`${PROJECT}_default`]: { + NetworkID: NETWORK_ID, + Aliases: ["otherwise-unexpected-name", "web"], + }, + }, ...fields, }, ]; await prepare(fixture); - await expectRefusal(); + const reason = + "generation" in fields + ? "generation" + : "state" in fields || "health" in fields || "exitCode" in fields + ? "state" + : "resource-label"; + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", reason); } }); test("persistent storage must match exact generated name and logical storage ownership", async () => { @@ -533,7 +613,7 @@ test("retained volume policy requires exact presence and birth while cold invent { name: VOLUME, storage: "data", createdAt: CREATED }, ]); await prepare({}); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); // Legacy history can require presence without inventing a prior birth. await expectRefusal({ ...options, @@ -547,7 +627,7 @@ test("retained volume policy requires exact presence and birth while cold invent } volume.createdAt = REBORN; await prepare(replaced); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); }); test.each([ null, @@ -614,7 +694,8 @@ test("vanished resources and changed selected inventory refuse without retry or options, mode === "vanished" ? "E_NATIVE_COMPOSE_PROBE" - : "E_NATIVE_COMPOSE_OWNERSHIP" + : "E_NATIVE_COMPOSE_OWNERSHIP", + mode === "vanished" ? undefined : "cross-scan-drift" ); } }); @@ -833,7 +914,13 @@ test("custom bridge driver, internal flag, aliases, generation and unexpected en fixture.mode = change; } await prepare(fixture); - await expectRefusal(selection); + const reason = + change === "driver" || change === "internal" + ? "bridge-policy" + : change === "alias" || change === "endpoint-drift" + ? "endpoint" + : "topology"; + await expectRefusal(selection, "E_NATIVE_COMPOSE_OWNERSHIP", reason); expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } }); diff --git a/tests/native-process-policy-initial-trace.test.ts b/tests/native-process-policy-initial-trace.test.ts index f0c9b7126..43f7593d5 100644 --- a/tests/native-process-policy-initial-trace.test.ts +++ b/tests/native-process-policy-initial-trace.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { chmod, mkdtemp, readdir, rm, symlink, unlink } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + unlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -7,7 +16,10 @@ import { type NativeComposeOwnershipOptions, } from "../src/lib/native-compose-ownership.ts"; import { exec } from "../src/lib/shell.ts"; -import { replayProcessPolicyInitialOwnership } from "./e2e/native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + replayProcessPolicyInitialOwnership, +} from "./e2e/native-process-policy-initial-replay.ts"; import { type ProcessPolicyInitialTraceQuery, prepareProcessPolicyInitialTrace, @@ -404,7 +416,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(strict).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "live-network-membership", + reason: "topology", consumed: 6, protocolMatched: true, }); @@ -430,7 +442,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(policy).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "network-policy", + reason: "bridge-policy", consumed: 6, protocolMatched: true, }); @@ -453,13 +465,91 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(aliases).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "endpoint-aliases", + reason: "endpoint", consumed: 6, protocolMatched: true, }); expect(JSON.stringify({ policy, aliases })).not.toContain(owner); expect(JSON.stringify({ policy, aliases })).not.toContain(id); expect(JSON.stringify({ policy, aliases })).not.toContain(import.meta.dir); + const summary: Parameters< + typeof persistProcessPolicyFirstAfterComposeRefusal + >[0]["summary"] = { + status: "captured", + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + queryCount: queries.length, + consumedQueries: queries.length, + complete: true, + observations: [ + { index: 0, phase: "before-compose", startup: strict, strict }, + { index: 1, phase: "after-compose", startup, strict }, + { index: 2, phase: "after-compose", startup: strict, strict }, + ], + }; + // Disabled and malformed opt-ins cannot create a capsule, even for a matching refusal. + for (const enabled of [undefined, "", "0", "true", "1\n"]) { + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled, + summary, + }) + ).toBeNull(); + expect( + await Bun.file( + join(root, "first-after-compose-replay-refusal.json") + ).exists() + ).toBe(false); + } + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }); + expect(capsule).toEqual({ + version: 1, + kind: "native-process-policy-after-compose-replay-refusal", + observationIndex: 1, + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + startupReason: null, + strictReason: "topology", + }); + const capsulePath = join(root, "first-after-compose-replay-refusal.json"); + const capsuleBytes = await readFile(capsulePath, "utf8"); + expect(JSON.parse(capsuleBytes)).toEqual(capsule); + expect((await stat(capsulePath)).mode & 0o777).toBe(0o600); + expect(capsuleBytes).not.toContain(owner); + expect(capsuleBytes).not.toContain(id); + expect(capsuleBytes).not.toContain(CANARY); + await expect( + persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }) + ).rejects.toThrow(); + expect(await readFile(capsulePath, "utf8")).toBe(capsuleBytes); + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: join(root, "unmatched"), + enabled: "1", + summary: { + ...summary, + observations: [ + { + index: 1, + phase: "after-compose", + startup, + strict: { ...strict, protocolMatched: false }, + }, + ], + }, + }) + ).toBeNull(); const missing = await replayProcessPolicyInitialOwnership({ directory: join(root, "missing"), queries: queries.slice(0, 1), @@ -468,7 +558,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(missing.protocolMatched).toBe(false); expect(missing.outcome).toBe("refused"); - expect(missing.reason).toBe("unavailable"); + expect(missing.reason).toBeNull(); const first = queries[0]; if (!first) { throw new Error("Missing synthetic query"); @@ -481,7 +571,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(changed.protocolMatched).toBe(false); expect(changed.outcome).toBe("refused"); - expect(changed.reason).toBe("unavailable"); + expect(changed.reason).toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tests/native-process-policy-replay-reason.test.ts b/tests/native-process-policy-replay-reason.test.ts index 5c3530369..f50f03560 100644 --- a/tests/native-process-policy-replay-reason.test.ts +++ b/tests/native-process-policy-replay-reason.test.ts @@ -19,23 +19,13 @@ function stack(caller: string): string { return `${header}\n at refuse (${sourcePath}:200:9)\n at requireValue (${sourcePath}:204:5)\n at ${caller}\n at synthetic (private-canary:1:1)`; } -test("replay source pin verifies the actual canonical owner and refuses changed identity correspondence", () => { +test("historical stack classifier refuses the superseding owner source", () => { + // The replay now uses owner-issued reasons; the old line/hash classifier stays unavailable. const before = captureProcessPolicyOwnershipSource(); const after = captureProcessPolicyOwnershipSource(); - expect(before?.sha256).toBe(sourceSha256); - expect(before?.path).toBe(sourcePath); - expect(sameProcessPolicyOwnershipSource(before, after)).toBe(true); - if (!before) { - throw new Error("Missing canonical source pin"); - } - expect( - sameProcessPolicyOwnershipSource(before, { - ...before, - identity: "replaced", - }) - ).toBe(false); - expect(sameProcessPolicyOwnershipSource(before, null)).toBe(false); - expect(sameProcessPolicyOwnershipSource(null, after)).toBe(false); + expect(before).toBeNull(); + expect(after).toBeNull(); + expect(sameProcessPolicyOwnershipSource(before, after)).toBe(false); }); test("only fixed immediate owning callsites can name a replay reason", () => { From f235a34c2d0c11a3719b17d369781342097842ba Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 06:43:47 -0400 Subject: [PATCH 21/23] fix(test): retain uncertain protected file fixture children --- tests/e2e/native-file-permission-command.ts | 189 +++++--------- .../native-compose-adoption-job-worktrees.ts | 66 ++++- .../native-config-protected-files.ts | 10 + ...ive-config-protected-files-fixture.test.ts | 247 ++++++++++++++---- 4 files changed, 332 insertions(+), 180 deletions(-) diff --git a/tests/e2e/native-file-permission-command.ts b/tests/e2e/native-file-permission-command.ts index 443d53bb4..2541dad14 100644 --- a/tests/e2e/native-file-permission-command.ts +++ b/tests/e2e/native-file-permission-command.ts @@ -1,6 +1,14 @@ -import { lstat, realpath, writeFile } from "node:fs/promises"; +import { + lstat, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, resolve } from "node:path"; -import { isRecord } from "../../src/lib/guards.ts"; +import { captureCompletedJobFixtureCommand } from "./scenarios/native-compose-adoption-job-worktrees.ts"; const LIMIT = 256 * 1024; function refuse(): never { @@ -8,42 +16,6 @@ function refuse(): never { "Owned file fixture command did not settle within its contract; values omitted." ); } -function capture(stream: ReadableStream, stop: () => void) { - const reader = stream.getReader(); - const value = (async () => { - const parts: Uint8Array[] = []; - let size = 0; - try { - while (true) { - const next = await reader.read(); - if (next.done) { - break; - } - size += next.value.length; - if (size > LIMIT) { - refuse(); - } - parts.push(next.value); - } - return Buffer.concat(parts); - } catch { - stop(); - refuse(); - } finally { - reader.releaseLock(); - } - })(); - return { - value, - cancel: async () => { - try { - await reader.cancel(); - } catch { - /* Settled readers have released their lock. */ - } - }, - }; -} export type NativeFileFixtureCommandResult = { readonly exitCode: number; readonly stdout: string; @@ -57,6 +29,7 @@ type NativeFileFixtureCommandOptions = { readonly signal?: AbortSignal; readonly stdin?: Uint8Array; readonly capturePrefix?: string; + readonly onUnconfirmed?: () => void; }; function captureCommandInputs(opts: NativeFileFixtureCommandOptions) { // Freeze selection before the first await. In particular, the validated @@ -70,6 +43,7 @@ function captureCommandInputs(opts: NativeFileFixtureCommandOptions) { signal: opts.signal, stdin: privateInput === undefined ? undefined : Buffer.from(privateInput), capturePrefix: opts.capturePrefix, + onUnconfirmed: opts.onUnconfirmed, }; const binary = captured.argv[0]; if ( @@ -88,27 +62,26 @@ function captureCommandInputs(opts: NativeFileFixtureCommandOptions) { } return captured; } -/** - * Uses the compiler/config-only owner pattern: leader AND both pipes must settle - * before group authority disarms. Private stdin is bounded and never captured. - * Failure/cancellation kills once, cancels inherited pipes and reaps the leader; - * publication errors after settlement never signal a former process group. - */ +/** Reuses the maintained finite exit/EOF/group-absence owner; unknown settlement retains the fixture. */ export async function runNativeFileFixtureCommand( opts: NativeFileFixtureCommandOptions ): Promise { const captured = captureCommandInputs(opts); const deadline = Date.now() + captured.timeoutMs; - const captureDirectory = + const temporary = captured.capturePrefix === undefined - ? undefined - : dirname(captured.capturePrefix); + ? await realpath( + await mkdtemp(resolve(tmpdir(), "protected-file-command-")) + ) + : undefined; + const prefix = captured.capturePrefix ?? resolve(temporary!, "capture"); + const captureDirectory = dirname(prefix); let captureIdentity: Awaited> | undefined; if (captureDirectory !== undefined) { if ( - !(captured.capturePrefix && isAbsolute(captured.capturePrefix)) || - resolve(captured.capturePrefix) !== captured.capturePrefix || - !/^[a-z0-9-]+$/.test(basename(captured.capturePrefix)) || + !isAbsolute(prefix) || + resolve(prefix) !== prefix || + !/^[a-z0-9-]+$/.test(basename(prefix)) || (await realpath(captureDirectory)) !== captureDirectory ) { refuse(); @@ -140,86 +113,60 @@ export async function runNativeFileFixtureCommand( refuse(); } }; - if (captured.signal?.aborted || Date.now() >= deadline) { - refuse(); - } - const child = Bun.spawn(captured.argv, { - cwd: captured.cwd, - env: captured.env, - stdin: captured.stdin === undefined ? "ignore" : new Blob([captured.stdin]), - stdout: "pipe", - stderr: "pipe", - detached: true, - }); - let complete = false, - stopped = false; - let stdout: ReturnType | undefined, - stderr: 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 { - /* Failure cannot pass; leader is still awaited. */ - } - } - } - } - void stdout?.cancel(); - void stderr?.cancel(); + let unconfirmed = false; + const onUnconfirmed = () => { + unconfirmed = true; + captured.onUnconfirmed?.(); }; - const timer = setTimeout(stop, Math.max(1, deadline - Date.now())); - captured.signal?.addEventListener("abort", stop, { once: true }); - stdout = capture(child.stdout, stop); - stderr = capture(child.stderr, stop); - const pending = [child.exited, stdout.value, stderr.value] as const; try { - const [exitCode, output, diagnostics] = await Promise.all(pending); - complete = true; - if (captured.capturePrefix !== undefined) { - await checkCapture(); - await writeFile(`${captured.capturePrefix}.stdout`, output, { - mode: 0o600, - flag: "wx", - }); - await writeFile(`${captured.capturePrefix}.stderr`, diagnostics, { - mode: 0o600, - flag: "wx", - }); - await writeFile( - `${captured.capturePrefix}.json`, - JSON.stringify({ - exitCode, - outputBytes: output.length, - diagnosticBytes: diagnostics.length, - settled: true, - stopRequested: stopped, - aborted: captured.signal?.aborted === true, - }), - { mode: 0o600, flag: "wx" } - ); + await checkCapture(); + const remaining = deadline - Date.now(); + if (captured.signal?.aborted || remaining <= 0) { + refuse(); } + const result = await captureCompletedJobFixtureCommand({ + argv: captured.argv, + cwd: captured.cwd, + env: captured.env, + stdin: captured.stdin, + signal: captured.signal, + captures: prefix, + timeoutMs: remaining, + maxStreamBytes: LIMIT, + onUnconfirmed, + }); await checkCapture(); - if (stopped || captured.signal?.aborted || Date.now() >= deadline) { + if (captured.signal?.aborted || Date.now() >= deadline) { refuse(); } + const output = await readFile(`${prefix}.stdout`); + const diagnostics = await readFile(`${prefix}.stderr`); const decoder = new TextDecoder("utf-8", { fatal: true }); - return { - exitCode, - stdout: decoder.decode(output), - stderr: decoder.decode(diagnostics), - }; + const stdout = decoder.decode(output), + stderr = decoder.decode(diagnostics); + await writeFile( + `${prefix}.json`, + JSON.stringify({ + exitCode: result.exitCode, + outputBytes: output.byteLength, + diagnosticBytes: diagnostics.byteLength, + settled: true, + stopRequested: false, + aborted: false, + }), + { mode: 0o600, flag: "wx" } + ); + await checkCapture(); + if (captured.signal?.aborted || Date.now() >= deadline) { + refuse(); + } + return { exitCode: result.exitCode, stdout, stderr }; } catch { - stop(); - await Promise.allSettled(pending); return refuse(); } finally { - clearTimeout(timer); - captured.signal?.removeEventListener("abort", stop); + if (temporary !== undefined && !unconfirmed) { + await checkCapture(); + await rm(temporary, { recursive: true }); + } } } diff --git a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts index ec0fd4181..f638be7b3 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts @@ -852,6 +852,9 @@ type CompletedJobCaptureOptions = { readonly env: Readonly>; readonly captures: string; readonly timeoutMs: number; + readonly stdin?: Uint8Array; + readonly signal?: AbortSignal; + readonly maxStreamBytes?: number; readonly beforeInterrupt?: ( admission: InterruptReadAdmission ) => Promise; @@ -888,6 +891,9 @@ async function captureCompletedJobFixtureChild( env: Object.freeze({ ...opts.env }), captures: opts.captures, timeoutMs: opts.timeoutMs, + stdin: opts.stdin === undefined ? undefined : Buffer.from(opts.stdin), + signal: opts.signal, + maxStreamBytes: opts.maxStreamBytes, beforeInterrupt: opts.beforeInterrupt, onUnconfirmed: opts.onUnconfirmed, deadline: Date.now() + opts.timeoutMs, @@ -898,14 +904,20 @@ async function captureCompletedJobFixtureChild( executable.startsWith("/") && Number.isSafeInteger(snapshot.timeoutMs) && snapshot.timeoutMs > 0 && - snapshot.timeoutMs <= TIMEOUT + snapshot.timeoutMs <= TIMEOUT && + !snapshot.signal?.aborted && + (snapshot.stdin === undefined || snapshot.stdin.byteLength <= 8192) && + (snapshot.maxStreamBytes === undefined || + (Number.isSafeInteger(snapshot.maxStreamBytes) && + snapshot.maxStreamBytes > 0 && + snapshot.maxStreamBytes <= 524_288)) ); let ownerStarted = false; const output = await open(`${snapshot.captures}.stdout`, "wx", 0o600); try { const errors = await open(`${snapshot.captures}.stderr`, "wx", 0o600); try { - requireValue(snapshot.deadline > Date.now()); + requireValue(snapshot.deadline > Date.now() && !snapshot.signal?.aborted); ownerStarted = true; const exitCode = await captureInterruptedPipes({ ...snapshot, @@ -951,7 +963,7 @@ async function captureInterruptedPipes( cwd: opts.cwd, env: { ...opts.env }, detached: true, - stdio: ["ignore", "pipe", "pipe"], + stdio: [opts.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); const stdoutStream = child.stdout, stderrStream = child.stderr; @@ -962,6 +974,7 @@ async function captureInterruptedPipes( let openPipes = 2; let settled = false; let timedOut = false; + let cancelled = false; let oversized = false; let groupFailure = false; let captureFailure = false; @@ -1000,15 +1013,31 @@ async function captureInterruptedPipes( resolveExit(code ?? 128); }); }); + const input = new Promise((resolveInput, reject) => { + if (opts.stdin === undefined) { + resolveInput(); + return; + } + const stream = child.stdin; + requireValue(stream !== null); + stream.once("error", () => { + captureFailure = true; + stopOwned(); + reject(new Error("Completed-job input refused; values omitted.")); + }); + stream.end(opts.stdin, resolveInput); + }); const capture = async ( stream: NonNullable, file: Awaited> ) => { + let streamRemaining = opts.maxStreamBytes ?? 524_288; try { for await (const raw of stream) { requireValue(Buffer.isBuffer(raw)); - const count = Math.min(raw.length, remaining); + const count = Math.min(raw.length, remaining, streamRemaining); remaining -= count; + streamRemaining -= count; if (count < raw.length) { oversized = true; stopOwned(); @@ -1029,7 +1058,7 @@ async function captureInterruptedPipes( }; const stdout = capture(stdoutStream, opts.output); const stderr = capture(stderrStream, opts.errors); - const all = Promise.all([exited, stdout, stderr]).then(([code]) => { + const all = Promise.all([exited, stdout, stderr, input]).then(([code]) => { settled = true; return code; }); @@ -1044,6 +1073,16 @@ async function captureInterruptedPipes( ); }); expired.catch(() => undefined); + const cancel = () => { + cancelled = true; + admission.abort(); + stopOwned(); + expire?.(); + }; + opts.signal?.addEventListener("abort", cancel, { once: true }); + if (opts.signal?.aborted) { + cancel(); + } const timer = setTimeout( () => { timedOut = true; @@ -1079,7 +1118,7 @@ async function captureInterruptedPipes( try { await Promise.race([ (async () => { - await Promise.allSettled([exited, stdout, stderr]); + await Promise.allSettled([exited, stdout, stderr, input]); requireValue(settlementDeadline > Date.now()); requireValue( leaderExited && openPipes === 0 && !captureFailure && !groupFailure @@ -1117,7 +1156,7 @@ async function captureInterruptedPipes( capturedLeaderAbsent: true, capturedGroupAbsent: true, scope: "captured-child-group", - interrupted: timedOut || oversized, + interrupted: timedOut || oversized || cancelled, callbackSettled: outstandingPhase === 0, }) ); @@ -1137,6 +1176,7 @@ async function captureInterruptedPipes( opts.onUnconfirmed(); stdoutStream.destroy(); stderrStream.destroy(); + child.stdin?.destroy(); // A FileHandle.write already in progress cannot be canceled by destroying its stream. // Keep its rejection handled, but never extend this settlement deadline to await it. Promise.allSettled([stdout, stderr]) @@ -1187,7 +1227,9 @@ async function captureInterruptedPipes( child.kill("SIGINT"); } code = await Promise.race([all, expired]); - requireValue((!beforeInterrupt || code !== 0) && !timedOut && !oversized); + requireValue( + (!beforeInterrupt || code !== 0) && !timedOut && !oversized && !cancelled + ); } finally { if (outstandingPhase > 0) { opts.onUnconfirmed(); @@ -1198,10 +1240,16 @@ async function captureInterruptedPipes( } finally { admission.abort(); clearTimeout(timer); + opts.signal?.removeEventListener("abort", cancel); } } requireValue( - code !== undefined && !timedOut && !oversized && opts.deadline > Date.now() + code !== undefined && + !timedOut && + !oversized && + !cancelled && + !opts.signal?.aborted && + opts.deadline > Date.now() ); return code; } diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index 2603a2ec2..4d797c06a 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -39,6 +39,8 @@ import { readNativeFileFixtureGuest, } from "../native-file-permission-guest.ts"; +import { createCompletedJobFixtureSettlement } from "./native-compose-adoption-job-worktrees.ts"; + const ID = /^[a-f0-9]{64}$/; const HASH = /^[a-f0-9]{64}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; @@ -468,6 +470,7 @@ async function setup(ctx: ScenarioContext) { HACK_COMPOSE_STARTUP_TIMEOUT_MS: "45000", }, }); + const settlement = createCompletedJobFixtureSettlement(); let serial = 0, remainingOutput = 16 * 1024 * 1024; const command = async ( @@ -476,6 +479,7 @@ async function setup(ctx: ScenarioContext) { stdin?: unknown, extra?: Readonly> ) => { + settlement.assertConfirmed(); requireValue(serial < 1000 && remainingOutput > 0); const result = await runNativeFileFixtureCommand({ argv, @@ -486,6 +490,7 @@ async function setup(ctx: ScenarioContext) { stdin: stdin === undefined ? undefined : Buffer.from(JSON.stringify(stdin)), capturePrefix: join(captures, String(++serial).padStart(5, "0")), + onUnconfirmed: settlement.markUnconfirmed, }); remainingOutput -= Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr); @@ -513,6 +518,7 @@ async function setup(ctx: ScenarioContext) { ); }; const fence = async () => { + settlement.assertConfirmed(); remaining(); socketBinding.assertFresh(); const currentSocket = await lstat(socket); @@ -828,6 +834,7 @@ async function setup(ctx: ScenarioContext) { first, second, stop, + assertChildrenSettled: settlement.assertConfirmed, }; } type Fixture = Awaited>; @@ -1426,6 +1433,7 @@ async function activeSourceRefusal(h: Fixture) { } } } finally { + h.assertChildrenSettled(); await rename(withheld, join(instance.root, "material")); } await checkSources(instance.material); @@ -1524,6 +1532,7 @@ async function interruptedStart(h: Fixture) { canonical(await resources(h, sibling)) === canonical(siblingBefore) ); } finally { + h.assertChildrenSettled(); await rename(withheld, join(instance.root, "material")); } await checkSources(instance.material); @@ -1846,6 +1855,7 @@ export const nativeConfigProtectedFilesScenario: Scenario = { await retainedFlow(h); }, cleanup: async () => { + h.assertChildrenSettled(); await removeRetained(h, h.first); await removeRetained(h, h.second); await h.fence(); diff --git a/tests/native-config-protected-files-fixture.test.ts b/tests/native-config-protected-files-fixture.test.ts index 127499efa..ac2570458 100644 --- a/tests/native-config-protected-files-fixture.test.ts +++ b/tests/native-config-protected-files-fixture.test.ts @@ -1,4 +1,10 @@ -import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + afterEach, + beforeEach, + test as boundedTest, + expect, + spyOn, +} from "bun:test"; import { lstat, mkdir, @@ -10,7 +16,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runNativeFileFixtureCommand } from "./e2e/native-file-permission-command.ts"; +import { runNativeFileFixtureCommand as captureNativeFileFixtureCommand } from "./e2e/native-file-permission-command.ts"; import { nativeProtectedFileReadAllowed, nativeProtectedFileStartAllowed, @@ -21,11 +27,56 @@ import { nativeFileFixtureNonowner, readNativeFileFixtureGuest, } from "./e2e/native-file-permission-guest.ts"; +import { createCompletedJobFixtureSettlement } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; import { nativeProtectedFileContainerIdentity, nativeProtectedFileRemovalMatches, } from "./e2e/scenarios/native-config-protected-files.ts"; +import { retainedRoutingFixtureLifetime } from "./helpers/retained-routing-adoption.ts"; + +let lifetime: ReturnType | undefined; +let uncertain = false; +let expectedUnknown = false; +let restorers: (() => void)[] = []; +function ownedTest(name: string, run: () => unknown, timeoutMs = 5000) { + boundedTest( + name, + async () => { + lifetime = retainedRoutingFixtureLifetime(Date.now() + timeoutMs); + await lifetime.track(Promise.resolve().then(run)); + }, + timeoutMs + ); +} +const test = Object.assign(ownedTest, { + each: + (rows: readonly T[]) => + (name: string, run: (value: T) => unknown) => { + for (const value of rows) { + ownedTest(name.replace("%s", String(value)), () => run(value)); + } + }, + skipIf: (skip: boolean) => (skip ? boundedTest.skip : ownedTest), +}); +function runNativeFileFixtureCommand( + opts: Parameters[0] +) { + if (!lifetime) { + throw new Error("Fixture lifetime missing; values omitted."); + } + const owner = lifetime; + return owner.track( + captureNativeFileFixtureCommand({ + ...opts, + onUnconfirmed: () => { + owner.retain(); + opts.onUnconfirmed?.(); + }, + }) + ); +} + const grants = [ { target: "/settings", mode: "0444" as const, bytes: [1] }, { target: "/run/secrets/owner", mode: "0400" as const, bytes: [2] }, @@ -268,11 +319,30 @@ test("stopped cleanup preserves full immutable facts and argv order", () => { }); let directory: string; beforeEach(async () => { + if (uncertain) { + throw new Error("Prior fixture lifetime is unknown; values omitted."); + } + lifetime = undefined; + expectedUnknown = false; + restorers = []; directory = await realpath( await mkdtemp(join(tmpdir(), "protected-command-")) ); }); afterEach(async () => { + if (!lifetime?.canRestore()) { + uncertain = true; + if (expectedUnknown) { + // The last intentional-unknown case asserts retention; it never releases teardown. + return; + } + throw new Error( + "Fixture callback/child is unsettled; root and globals retained, values omitted." + ); + } + for (const restore of restorers) { + restore(); + } await rm(directory, { recursive: true, force: true }); }); test("bounded command privately delivers stdin and captures a known nonzero unchanged", async () => { @@ -335,46 +405,47 @@ test("caller mutation cannot retarget capture or change an admitted invocation", ); expect(await Bun.file(wrong).exists()).toBe(false); }); -test("leader exit does not disarm an owned descendant holding capture pipes", async () => { - const pidPath = join(directory, "descendant"), - late = join(directory, "late"), - child = join(directory, "child.ts"); - await writeFile( - child, - `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));process.on('SIGTERM',()=>{});await Bun.sleep(2000);await Bun.write(${JSON.stringify(late)},'late');` - ); - const controller = new AbortController(), - timer = setTimeout(() => controller.abort(), 300); +test("external cancellation settles a live leader within the existing owner and cannot qualify a late success", async () => { + const pidPath = join(directory, "cancelled-leader"), + controller = new AbortController(); + const prefix = join(directory, "cancelled"); + const pending = runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));await Bun.sleep(5000)`, + ], + cwd: directory, + env: {}, + timeoutMs: 1000, + signal: controller.signal, + capturePrefix: prefix, + }); try { - await expect( - runNativeFileFixtureCommand({ - argv: [ - process.execPath, - "-e", - `Bun.spawn([${JSON.stringify(process.execPath)},${JSON.stringify(child)}],{stdin:'ignore',stdout:'inherit',stderr:'inherit'});await Bun.sleep(150);process.exit(0)`, - ], - cwd: directory, - env: { PATH: process.env.PATH ?? "" }, - timeoutMs: 1000, - signal: controller.signal, - }) - ).rejects.toThrow(); - } finally { - clearTimeout(timer); - } - const pid = Number(await readFile(pidPath, "utf8")); - const deadline = Date.now() + 2000; - while (true) { + const deadline = Date.now() + 500; + while (!(await Bun.file(pidPath).exists())) { + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(5); + } + controller.abort(); + await expect(pending).rejects.toThrow(); + const pid = Number(await readFile(pidPath, "utf8")); try { process.kill(pid, 0); + throw new Error("captured leader remains"); } catch (error: unknown) { expect((error as { code: string }).code).toBe("ESRCH"); - break; } - expect(Date.now()).toBeLessThan(deadline); - await Bun.sleep(10); + const receipt = JSON.parse( + await readFile(`${prefix}.settlement.json`, "utf8") + ); + expect(receipt.interrupted).toBe(true); + expect(receipt.capturedGroupAbsent).toBe(true); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); + } finally { + controller.abort(); + await pending.catch(() => undefined); } - expect(await Bun.file(late).exists()).toBe(false); }); test("capture overflow cancels and reaps an owned sleeping leader", async () => { const pidPath = join(directory, "leader"); @@ -398,7 +469,7 @@ test("capture overflow cancels and reaps an owned sleeping leader", async () => expect((error as { code: string }).code).toBe("ESRCH"); } }); -test("publication refusal after leader and both captures settle never signals a former group", async () => { +test("capture publication conflict refuses before spawn without any group signal", async () => { const prefix = join(directory, "publication"); await writeFile(`${prefix}.stdout`, "keep-existing", { flag: "wx", @@ -407,24 +478,21 @@ test("publication refusal after leader and both captures settle never signals a const original = process.kill, groups: number[] = []; const signal = spyOn(process, "kill").mockImplementation((pid, kind) => { - if (pid < 0) { + if (pid < 0 && kind !== 0) { groups.push(pid); } return original.call(process, pid, kind); }); - try { - await expect( - runNativeFileFixtureCommand({ - argv: [process.execPath, "-e", 'console.log("complete")'], - cwd: directory, - env: { PATH: process.env.PATH ?? "" }, - timeoutMs: 1000, - capturePrefix: prefix, - }) - ).rejects.toThrow(); - } finally { - signal.mockRestore(); - } + restorers.push(() => signal.mockRestore()); + await expect( + runNativeFileFixtureCommand({ + argv: [process.execPath, "-e", 'console.log("complete")'], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + capturePrefix: prefix, + }) + ).rejects.toThrow(); expect(groups).toEqual([]); expect(await readFile(`${prefix}.stdout`, "utf8")).toBe("keep-existing"); expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); @@ -511,3 +579,82 @@ test.skipIf(process.platform !== "darwin")( } } ); + +// This intentional-unknown negative is last: its root and global spy remain retained until process exit. +test("closed-pipe survivor refuses completion and permanently vetoes restoration without a former-group signal", async () => { + const pidPath = join(directory, "descendant"), + child = join(directory, "child.ts"), + prefix = join(directory, "survivor"); + await writeFile( + child, + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));await Bun.sleep(1500);` + ); + const settlement = createCompletedJobFixtureSettlement(); + const original = process.kill.bind(process), + groups: number[] = []; + const signal = spyOn(process, "kill").mockImplementation((pid, kind) => { + if (pid < 0 && kind !== 0) { + groups.push(pid); + } + return original(pid, kind); + }); + restorers.push(() => signal.mockRestore()); + let descendant: number | undefined; + let descendantJoined = false; + const waitForDescendant = async () => { + descendant = Number(await readFile(pidPath, "utf8")); + expect(Number.isSafeInteger(descendant) && descendant > 0).toBe(true); + const deadline = Date.now() + 2500; + while (true) { + try { + original(descendant, 0); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + return; + } + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(10); + } + }; + try { + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `Bun.spawn([${JSON.stringify(process.execPath)},${JSON.stringify(child)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'});while(!await Bun.file(${JSON.stringify(pidPath)}).exists())await Bun.sleep(5);process.exit(0)`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + capturePrefix: prefix, + onUnconfirmed: settlement.markUnconfirmed, + }) + ).rejects.toThrow(); + descendant = Number(await readFile(pidPath, "utf8")); + expect(original(descendant, 0)).toBe(true); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); + expect(await Bun.file(`${prefix}.settlement.json`).exists()).toBe(false); + let restoration = 0; + expect(() => { + settlement.assertConfirmed(); + restoration += 1; + }).toThrow(); + expect(restoration).toBe(0); + expect(groups).toEqual([]); + await waitForDescendant(); + expect(() => settlement.assertConfirmed()).toThrow(); + expect(groups).toEqual([]); + } finally { + // Only an exact finite self-join can release this test's root/global teardown. + await lifetime!.track(waitForDescendant()).then( + () => { + descendantJoined = true; + }, + () => lifetime!.retain() + ); + } + expect(descendantJoined).toBe(true); + expect(lifetime!.canRestore()).toBe(false); + expectedUnknown = true; +}); From bf4919d6a795bdbf8a62b5c3a71e2db73c8bd625 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 08:36:40 -0400 Subject: [PATCH 22/23] test(config): preserve stopped retained file fixture identity --- tests/e2e/native-file-permission-control.ts | 102 ++++++++++ .../native-config-protected-files.ts | 41 +++- ...ive-config-protected-files-fixture.test.ts | 190 ++++++++++++++++++ 3 files changed, 327 insertions(+), 6 deletions(-) diff --git a/tests/e2e/native-file-permission-control.ts b/tests/e2e/native-file-permission-control.ts index 7267552e6..d8241ab4b 100644 --- a/tests/e2e/native-file-permission-control.ts +++ b/tests/e2e/native-file-permission-control.ts @@ -1,6 +1,108 @@ import { isRecord } from "../../src/lib/guards.ts"; +import { + type Checkout, + parseLegacyComposeAdoptionReceipt, +} from "../../src/lib/native-compose-adoption-receipt.ts"; +import { keys } from "../../src/lib/native-compose-private-state.ts"; import { adoptionDependencyReadAllowed } from "./scenarios/native-compose-adoption-dependency-inputs.ts"; +function fixtureIdentity(value: unknown): boolean { + return ( + isRecord(value) && + Number.isSafeInteger(value.dev) && + typeof value.dev === "number" && + value.dev >= 0 && + Number.isSafeInteger(value.ino) && + typeof value.ino === "number" && + value.ino > 0 + ); +} +function fixtureSource(value: unknown): boolean { + return ( + isRecord(value) && + keys(value, "dev,hash,ino") && + fixtureIdentity(value) && + typeof value.hash === "string" && + /^[a-f0-9]{64}$/.test(value.hash) + ); +} +function fixtureCheckout(value: unknown): value is Checkout { + const directory = (entry: unknown) => + isRecord(entry) && keys(entry, "dev,ino") && fixtureIdentity(entry); + if ( + !( + isRecord(value) && + keys(value, "git,project,root") && + directory(value.root) && + directory(value.project) + ) + ) { + return false; + } + const git = value.git; + return ( + directory(git) || + (isRecord(git) && + keys(git, "admin,backlink,common,commonLink,kind,marker,primary") && + git.kind === "linked-worktree" && + directory(git.admin) && + directory(git.common) && + directory(git.primary) && + fixtureSource(git.marker) && + fixtureSource(git.backlink) && + fixtureSource(git.commonLink)) + ); +} +function cleanupRefused(): never { + throw new Error( + "Protected fixture cleanup selection refused; values omitted." + ); +} +function cleanupDownArgs(value: unknown): readonly string[] { + if (!(isRecord(value) && fixtureCheckout(value.checkout))) { + return cleanupRefused(); + } + let receipt: ReturnType; + try { + receipt = parseLegacyComposeAdoptionReceipt(value, value.checkout); + } catch { + return cleanupRefused(); + } + if ( + !( + receipt.adoption_receipt_version === 8 && + receipt.prepared !== null && + receipt.publication?.phase === "active" + ) + ) { + return cleanupRefused(); + } + if (receipt.pendingOperation === null) { + return Object.freeze(["down", "--json"]); + } + const pending = receipt.pendingOperation; + if ( + !( + pending.services.length === 3 && + [...pending.services].sort().join() === "db,reader,ungranted" + ) + ) { + return cleanupRefused(); + } + return Object.freeze(["down", "--recover", "--json"]); +} + +/** Select only the fixture's validated active file8 receipt and whole pending + * selection. The production CLI still revalidates all receipt/effect authority. */ +export async function nativeProtectedFileCleanupDown(opts: { + readonly receipt: unknown; + readonly invoke: (args: readonly string[]) => Promise; +}): Promise { + const invoke = opts.invoke; + const args = cleanupDownArgs(opts.receipt); + return await invoke(args); +} + /** Current material admission has a fixed redacted generation-state diagnostic. */ export function nativeProtectedFileStateRefused(value: unknown): boolean { if ( diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index 4d797c06a..586a8d84d 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -26,6 +26,7 @@ import { runNativeFileFixtureCommand, } from "../native-file-permission-command.ts"; import { + nativeProtectedFileCleanupDown, nativeProtectedFileStateRefused, nativeProtectedFileToolAllowed, } from "../native-file-permission-control.ts"; @@ -229,6 +230,32 @@ export function nativeProtectedFileContainerIdentity(value: unknown): string { } = value; return canonical({ ...identity, mounts: rows(value.mounts) }); } +/** Only a stopped original may lose its null-valued exposed-port keys. Configured + * publication and every other immutable field remain part of the comparison. */ +export function nativeProtectedFileContainerMatches(opts: { + readonly pin: Row; + readonly current: Row; +}): boolean { + const { pin, current } = opts; + const stoppedPortKeysRemoved = + current.running === false && + current.paused === false && + current.status === "exited" && + noPorts(pin) && + noPorts(current) && + isRecord(pin.runtimePorts) && + Object.values(pin.runtimePorts).every((value) => value === null) && + isRecord(current.runtimePorts) && + Object.keys(current.runtimePorts).length === 0; + return ( + nativeProtectedFileContainerIdentity(pin) === + nativeProtectedFileContainerIdentity( + stoppedPortKeysRemoved + ? { ...current, runtimePorts: pin.runtimePorts } + : current + ) + ); +} /** Fixture cleanup cannot target an added resource, changed creation birth, or a live container. */ export function nativeProtectedFileRemovalMatches(opts: { readonly pin: Row; @@ -244,8 +271,7 @@ export function nativeProtectedFileRemovalMatches(opts: { Array.isArray(opts.current.networks) && canonical(rows(opts.pin.networks)) === canonical(rows(opts.current.networks)) && - nativeProtectedFileContainerIdentity(opts.pin) === - nativeProtectedFileContainerIdentity(opts.current) + nativeProtectedFileContainerMatches(opts) ); } if (opts.kind === "network") { @@ -868,8 +894,7 @@ function containerTopology(entries: readonly unknown[]) { function requireOriginalResourceIdentity(kind: Kind, pin: Row, current: Row) { if (kind === "container") { requireValue( - nativeProtectedFileContainerIdentity(current) === - nativeProtectedFileContainerIdentity(pin) && + nativeProtectedFileContainerMatches({ pin, current }) && Array.isArray(pin.networks) && Array.isArray(current.networks) ); @@ -1611,8 +1636,12 @@ async function removeRetained(h: Fixture, instance: Checkout) { // reach this exact active file8 receipt. Unknown bootstrap/publication is // retained for explicit inspection instead of inferred or replayed. if (!instance.rolledBack) { - await retainedReceipt(instance); - h.successful(await h.invoke(instance, ["down", "--recover", "--json"])); + h.successful( + await nativeProtectedFileCleanupDown({ + receipt: await retainedReceipt(instance), + invoke: (args) => h.invoke(instance, args), + }) + ); h.successful( await h.invoke(instance, ["config", "adopt", "--rollback", "--json"]) ); diff --git a/tests/native-config-protected-files-fixture.test.ts b/tests/native-config-protected-files-fixture.test.ts index ac2570458..aaa190763 100644 --- a/tests/native-config-protected-files-fixture.test.ts +++ b/tests/native-config-protected-files-fixture.test.ts @@ -18,6 +18,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runNativeFileFixtureCommand as captureNativeFileFixtureCommand } from "./e2e/native-file-permission-command.ts"; import { + nativeProtectedFileCleanupDown, nativeProtectedFileReadAllowed, nativeProtectedFileStartAllowed, nativeProtectedFileStateRefused, @@ -30,6 +31,7 @@ import { import { createCompletedJobFixtureSettlement } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; import { nativeProtectedFileContainerIdentity, + nativeProtectedFileContainerMatches, nativeProtectedFileRemovalMatches, } from "./e2e/scenarios/native-config-protected-files.ts"; @@ -317,6 +319,194 @@ test("stopped cleanup preserves full immutable facts and argv order", () => { }) ).toBe(nativeProtectedFileContainerIdentity(pin)); }); +const portPin = { + ...pin, + publishAll: false, + ports: null, + runtimePorts: { "5432/tcp": null }, +}; +const stoppedPortRow = { + ...portPin, + running: false, + status: "exited", + runtimePorts: {}, +}; +test("stopped original null runtime-port keys may become empty while full policy remains exact", () => { + expect( + nativeProtectedFileContainerMatches({ + pin: portPin, + current: stoppedPortRow, + }) + ).toBe(true); + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin: portPin, + current: stoppedPortRow, + }) + ).toBe(true); + expect(nativeProtectedFileContainerIdentity(stoppedPortRow)).not.toBe( + nativeProtectedFileContainerIdentity(portPin) + ); +}); +test("stopped runtime-port correspondence refuses foreign ports, publication and nonstopped state", () => { + for (const current of [ + { ...stoppedPortRow, runtimePorts: { "9999/tcp": null } }, + { ...stoppedPortRow, runtimePorts: null }, + { + ...stoppedPortRow, + runtimePorts: { + "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }], + }, + }, + { + ...stoppedPortRow, + ports: { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }] }, + }, + { ...stoppedPortRow, publishAll: true }, + { ...stoppedPortRow, running: true, status: "running" }, + { ...stoppedPortRow, paused: true }, + { ...stoppedPortRow, status: "created" }, + { ...stoppedPortRow, command: ["second", "first"] }, + { ...stoppedPortRow, image: `sha256:${ids[1]}` }, + ]) { + expect(nativeProtectedFileContainerMatches({ pin: portPin, current })).toBe( + false + ); + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin: portPin, + current, + }) + ).toBe(false); + } + for (const runtimePorts of [ + { "5432/tcp": [] }, + { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }] }, + ]) { + expect( + nativeProtectedFileContainerMatches({ + pin: { ...portPin, runtimePorts }, + current: stoppedPortRow, + }) + ).toBe(false); + } +}); +const cleanupReceipt = { + adoption_receipt_version: 8, + kind: "legacy-compose-adopted", + checkout: { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, + }, + prepared, + publication: { + phase: "active", + generation: prepared, + native: { dev: 1, ino: 5, hash: "f".repeat(64) }, + }, + pendingOperation: null, +}; +test("retained cleanup uses ordinary stop for clean active state and recovery only for a validated pending selection", async () => { + const calls: string[][] = []; + const invoke = async (args: readonly string[]) => { + calls.push([...args]); + return 17; + }; + expect( + await nativeProtectedFileCleanupDown({ receipt: cleanupReceipt, invoke }) + ).toBe(17); + for (const operation of ["start", "restart", "stop"]) { + expect( + await nativeProtectedFileCleanupDown({ + receipt: { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, operation }, + }, + invoke, + }) + ).toBe(17); + } + expect(calls).toEqual([ + ["down", "--json"], + ["down", "--recover", "--json"], + ["down", "--recover", "--json"], + ["down", "--recover", "--json"], + ]); +}); +test("retained cleanup refuses malformed or unknown selection before any invocation", async () => { + let calls = 0; + const invoke = async () => { + calls++; + return 0; + }; + for (const value of [ + null, + {}, + { ...cleanupReceipt, pendingOperation: undefined }, + { ...cleanupReceipt, pendingOperation: true }, + { ...cleanupReceipt, pendingOperation: {} }, + { ...cleanupReceipt, checkout: {} }, + { + ...cleanupReceipt, + checkout: { ...cleanupReceipt.checkout, git: { dev: 1, ino: 0 } }, + }, + { ...cleanupReceipt, adoption_receipt_version: 14 }, + { + ...cleanupReceipt, + publication: { ...cleanupReceipt.publication, phase: "switching" }, + }, + { + ...cleanupReceipt, + publication: { ...cleanupReceipt.publication, native: null }, + }, + { ...cleanupReceipt, prepared: { ...prepared, id: "invalid" } }, + { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, operation: "unknown" }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + generation: { ...prepared, id: "f".repeat(32) }, + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader", "foreign"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader", "reader"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, extra: true }, + }, + { ...cleanupReceipt, extra: true }, + ]) { + await expect( + nativeProtectedFileCleanupDown({ receipt: value, invoke }) + ).rejects.toThrow("selection refused; values omitted"); + } + expect(calls).toBe(0); +}); let directory: string; beforeEach(async () => { if (uncertain) { From 67c399aa3c540c61e15d019d01780b6bfd3789e4 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 09:36:27 -0400 Subject: [PATCH 23/23] fix(config): retain topology refusal predicates --- docs/reference/native-compose-ownership.md | 9 ++ src/lib/native-compose-ownership.ts | 69 +++++++-- .../native-process-policy-initial-replay.ts | 20 ++- tests/native-compose-ownership.test.ts | 140 +++++++++++++++++- ...ative-process-policy-initial-trace.test.ts | 6 + 5 files changed, 226 insertions(+), 18 deletions(-) diff --git a/docs/reference/native-compose-ownership.md b/docs/reference/native-compose-ownership.md index 1e4abab24..0cc7a0ba3 100644 --- a/docs/reference/native-compose-ownership.md +++ b/docs/reference/native-compose-ownership.md @@ -87,6 +87,15 @@ UTF-8 errors now normalize to the fixed `E_NATIVE_COMPOSE_PROBE` refusal. Ordere commands translate this metadata and their separate JSON/object boundaries into fixed redacted reasons; no argv, daemon output, IDs or exit values are retained. +Topology refusals additionally retain a private fixed predicate label on the +exact owner-issued error: network member shape or ID, member/container endpoint +agreement, workload policy or endpoint keyset, and created or live endpoint +membership. Recorded-query replay reports this label alongside the existing +broad reason. It identifies the first refused check on those saved replies; +it does not reconstruct the original caller mode, timing or external cause. +No inspect values are exposed, and admission, scan order and deadlines are +unchanged. + The closest regression suite is `tests/native-compose-ownership.test.ts`. Its isolated executable checks accepted resources, collisions, stale generations, inventory changes, redaction, and actual subprocess overflow/timeout/cancellation. diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index b9a17c8c0..5535d30ea 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -166,17 +166,49 @@ export type NativeComposeOwnershipRefusal = | "endpoint" | "cross-scan-drift" | "unknown"; -const ownershipRefusals = new WeakMap(); +const TOPOLOGY_PREDICATES = [ + "network-members-shape", + "network-member-id", + "member-container-endpoint", + "workload-policy", + "workload-endpoint-keyset", + "created-endpoint-membership", + "live-endpoint-membership", +] as const; +export type NativeComposeTopologyPredicate = + (typeof TOPOLOGY_PREDICATES)[number]; +const ownershipRefusals = new WeakMap< + object, + Readonly<{ + reason: NativeComposeOwnershipRefusal; + topologyPredicate: NativeComposeTopologyPredicate | null; + }> +>(); /** Only captured-reply replay reads this diagnostic; callers cannot mint it from error properties. */ export function nativeComposeOwnershipRefusal( error: unknown ): NativeComposeOwnershipRefusal | undefined { return typeof error === "object" && error !== null - ? ownershipRefusals.get(error) + ? ownershipRefusals.get(error)?.reason : undefined; } +/** Exact first topology predicate from this owner, without reading untrusted error fields. */ +export function nativeComposeTopologyPredicate( + error: unknown +): NativeComposeTopologyPredicate | undefined { + return typeof error === "object" && error !== null + ? (ownershipRefusals.get(error)?.topologyPredicate ?? undefined) + : undefined; +} + +export function isNativeComposeTopologyPredicate( + value: unknown +): value is NativeComposeTopologyPredicate { + return TOPOLOGY_PREDICATES.some((predicate) => predicate === value); +} + /** Copies, prototypes and caller-created errors cannot acquire an observed probe classification. */ export function nativeComposeProbeFailure( error: unknown @@ -220,18 +252,20 @@ export class NativeComposeOwnershipError extends Error { } function refuse( code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP", - reason: NativeComposeOwnershipRefusal = "unknown" + reason: NativeComposeOwnershipRefusal = "unknown", + topologyPredicate: NativeComposeTopologyPredicate | null = null ): never { const error = new NativeComposeOwnershipError(code); - ownershipRefusals.set(error, reason); + ownershipRefusals.set(error, Object.freeze({ reason, topologyPredicate })); throw error; } function requireValue( value: unknown, - reason: NativeComposeOwnershipRefusal = "unknown" + reason: NativeComposeOwnershipRefusal = "unknown", + topologyPredicate: NativeComposeTopologyPredicate | null = null ): asserts value { if (!value) { - refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason); + refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason, topologyPredicate); } } @@ -708,11 +742,16 @@ async function collectInspections(input: { row.internal === expected.internal, "bridge-policy" ); - requireValue(isRecord(row.containers), "topology"); + requireValue( + isRecord(row.containers), + "topology", + "network-members-shape" + ); const members = Object.keys(row.containers); requireValue( members.every((id) => ID.test(id)), - "topology" + "topology", + "network-member-id" ); observations.members.set(resource.name, members.sort()); networks.push({ id: resource.id, name: resource.name }); @@ -931,7 +970,8 @@ function requireLiveMember(opts: { networkPolicies(selection)[0]?.internal === false && networkId !== undefined && endpoint.NetworkID === networkId, - "topology" + "topology", + "live-endpoint-membership" ); return true; } @@ -957,7 +997,8 @@ function validateTopology( containers.has(id) && Object.hasOwn(observations.endpoints.get(id) ?? {}, name) ), - "topology" + "topology", + "member-container-endpoint" ); } for (const container of observations.containers) { @@ -966,7 +1007,7 @@ function validateTopology( workload.generationId === container.generationId && workload.service === container.service ); - requireValue(policy !== undefined, "topology"); + requireValue(policy !== undefined, "topology", "workload-policy"); const endpoints = observations.endpoints.get(container.id); requireValue( endpoints !== undefined && @@ -974,7 +1015,8 @@ function validateTopology( endpoints, policy.networks.map((network) => network.name) ), - "topology" + "topology", + "workload-endpoint-keyset" ); for (const attachment of policy.networks) { const endpoint = endpoints[attachment.name]; @@ -995,7 +1037,8 @@ function validateTopology( if (unrealizedCreatedEndpoint) { requireValue( !observations.members.get(attachment.name)?.includes(container.id), - "topology" + "topology", + "created-endpoint-membership" ); } const expected = container.oneoff diff --git a/tests/e2e/native-process-policy-initial-replay.ts b/tests/e2e/native-process-policy-initial-replay.ts index ee14e83a6..17aaba4cb 100644 --- a/tests/e2e/native-process-policy-initial-replay.ts +++ b/tests/e2e/native-process-policy-initial-replay.ts @@ -6,7 +6,9 @@ import { readNativeComposeNetworkTopology } from "../../src/lib/native-compose-n import type { NativeComposeOwnershipOptions, NativeComposeOwnershipRefusal, + NativeComposeTopologyPredicate, } from "../../src/lib/native-compose-ownership.ts"; +import { isNativeComposeTopologyPredicate } from "../../src/lib/native-compose-ownership.ts"; import { exec } from "../../src/lib/shell.ts"; import { type ProcessPolicyInitialTraceQuery, @@ -27,6 +29,7 @@ type Replay = { readonly code: (typeof CODES)[number] | null; /** First replayed ownership predicate only; absent for success or unclassified probe errors. */ readonly reason: NativeComposeOwnershipRefusal | null; + readonly topologyPredicate: NativeComposeTopologyPredicate | null; readonly consumed: number; readonly protocolMatched: boolean; }; @@ -84,13 +87,13 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode); ); await chmod(docker, 0o700); const program = ` -import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; +import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal,nativeComposeTopologyPredicate} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; const {selection}=await Bun.file(${JSON.stringify(inputs)}).json(); -let outcome="refused",code=null,reason=null; +let outcome="refused",code=null,reason=null,topologyPredicate=null; try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";} -catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;} +catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;topologyPredicate=nativeComposeTopologyPredicate(error)??null;} const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0; -process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); +process.stdout.write(JSON.stringify({outcome,code,reason,topologyPredicate,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); `; const result = await exec( [process.execPath, "--no-env-file", "-e", program], @@ -113,6 +116,12 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche value.outcome === "refused") && replayCode(value.code) && replayReason(value.reason) && + (value.topologyPredicate === null || + isNativeComposeTopologyPredicate(value.topologyPredicate)) && + (value.topologyPredicate === null || + (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" && + value.reason === "topology" && + value.outcome === "refused")) && (value.outcome === "refused" || value.reason === null) && (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || value.reason === null) && Number.isInteger(value.consumed) && @@ -128,6 +137,7 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche outcome: value.outcome, code: value.code, reason: value.reason, + topologyPredicate: value.topologyPredicate, consumed: value.consumed, protocolMatched: value.protocolMatched, }; @@ -325,6 +335,8 @@ export async function persistProcessPolicyFirstAfterComposeRefusal(opts: { row.strict.code === "E_NATIVE_COMPOSE_OWNERSHIP" ? row.strict.reason : null, + startupTopologyPredicate: row.startup.topologyPredicate, + strictTopologyPredicate: row.strict.topologyPredicate, }; const handle = await open( join(opts.directory, "first-after-compose-replay-refusal.json"), diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index 5bbca5bc0..12da75c7d 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -8,7 +8,9 @@ import { NativeComposeOwnershipError, type NativeComposeOwnershipOptions, type NativeComposeOwnershipRefusal, + type NativeComposeTopologyPredicate, nativeComposeOwnershipRefusal, + nativeComposeTopologyPredicate, observeNativeComposeStartupOwned, observeSavedNativeComposeOwned, } from "../src/lib/native-compose-ownership.ts"; @@ -175,21 +177,28 @@ async function commands(): Promise { async function expectRefusal( opts = options, code = "E_NATIVE_COMPOSE_OWNERSHIP", - reason?: NativeComposeOwnershipRefusal + reason?: NativeComposeOwnershipRefusal, + topologyPredicate?: NativeComposeTopologyPredicate ) { + let captured: unknown; try { await assertNativeComposeOwned(opts); throw new Error("unexpected probe success"); } catch (error: unknown) { + captured = error; expect(error).toBeInstanceOf(NativeComposeOwnershipError); expect(error).toMatchObject({ code }); if (reason !== undefined) { expect(nativeComposeOwnershipRefusal(error)).toBe(reason); } + if (topologyPredicate !== undefined) { + expect(nativeComposeTopologyPredicate(error)).toBe(topologyPredicate); + } expect(String(error)).not.toContain(CANARY); expect(JSON.stringify(error)).not.toContain(CANARY); } expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); + return captured; } test("ownership refusal diagnostics cannot be forged, copied or obtained through getters", () => { @@ -246,10 +255,139 @@ test("ownership refusal diagnostics cannot be forged, copied or obtained through 1, ]) { expect(nativeComposeOwnershipRefusal(candidate)).toBeUndefined(); + expect(nativeComposeTopologyPredicate(candidate)).toBeUndefined(); } expect(getters).toBe(0); }); +test("topology predicates distinguish original refusal sites and preserve the first failure", async () => { + const predicates: NativeComposeTopologyPredicate[] = [ + "network-members-shape", + "network-member-id", + "member-container-endpoint", + "workload-policy", + "workload-endpoint-keyset", + "created-endpoint-membership", + "live-endpoint-membership", + ]; + for (const predicate of predicates) { + const fixture = owned(); + const container = fixture.container?.[0]; + const network = fixture.network?.[0]; + if (!(container && network)) { + throw new Error("Missing topology fixture"); + } + let selection = options; + if (predicate === "network-members-shape") { + network.containers = null; + // A later keyset failure must not replace the first refused predicate. + container.networks = {}; + } else if (predicate === "network-member-id") { + network.containers = { invalid: {} }; + } else if (predicate === "member-container-endpoint") { + network.containers = { ["f".repeat(64)]: {} }; + } else if (predicate === "workload-policy") { + selection = { ...options, expectedWorkloadNetworks: [] }; + } else if (predicate === "workload-endpoint-keyset") { + (container.networks as Record).foreign = {}; + } else if (predicate === "created-endpoint-membership") { + container.state = "created"; + container.networks = { + [`${PROJECT}_default`]: { NetworkID: "", Aliases: null }, + }; + } else { + network.containers = {}; + } + await prepare(fixture); + let error: unknown; + if (predicate === "created-endpoint-membership") { + try { + await observeSavedNativeComposeOwned(selection); + } catch (caught) { + error = caught; + } + expect(nativeComposeOwnershipRefusal(error)).toBe("topology"); + expect(nativeComposeTopologyPredicate(error)).toBe(predicate); + } else { + error = await expectRefusal( + selection, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + predicate + ); + } + const serialized = JSON.stringify(error); + expect(serialized).not.toContain(predicate); + expect(serialized).not.toContain(CANARY); + expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); + } +}, 30_000); + +test("topology predicate evidence rejects hostile fields and cannot be copied or forged", async () => { + const fixture = owned(); + const network = fixture.network?.[0]; + if (!network) { + throw new Error("Missing topology fixture"); + } + network.containers = {}; + await prepare(fixture); + const issued = await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + "live-endpoint-membership" + ); + expect(nativeComposeTopologyPredicate(issued)).toBe( + "live-endpoint-membership" + ); + let reads = 0; + const hostile = new Proxy( + {}, + { + get() { + reads++; + throw new Error(CANARY); + }, + has() { + reads++; + throw new Error(CANARY); + }, + } + ); + const accessor = Object.defineProperty({}, "topologyPredicate", { + get() { + reads++; + throw new Error(CANARY); + }, + }); + for (const candidate of [ + { topologyPredicate: "live-endpoint-membership" }, + { ...(issued as object), topologyPredicate: "live-endpoint-membership" }, + Object.create(issued as object), + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + hostile, + accessor, + null, + undefined, + CANARY, + ]) { + expect(nativeComposeTopologyPredicate(candidate)).toBeUndefined(); + } + expect(reads).toBe(0); + // A later independent refusal cannot relabel the exact first issued error. + network.containers = null; + await prepare(fixture); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + "network-members-shape" + ); + expect(nativeComposeTopologyPredicate(issued)).toBe( + "live-endpoint-membership" + ); +}); + test("selected on-failure restart is unready until two stable owned scans", async () => { const fixture = owned(); const container = fixture.container?.[0]; diff --git a/tests/native-process-policy-initial-trace.test.ts b/tests/native-process-policy-initial-trace.test.ts index 43f7593d5..a344d140e 100644 --- a/tests/native-process-policy-initial-trace.test.ts +++ b/tests/native-process-policy-initial-trace.test.ts @@ -404,6 +404,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc outcome: "unready", code: null, reason: null, + topologyPredicate: null, consumed: calls.length, protocolMatched: true, }); @@ -417,6 +418,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", reason: "topology", + topologyPredicate: "live-endpoint-membership", consumed: 6, protocolMatched: true, }); @@ -443,6 +445,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", reason: "bridge-policy", + topologyPredicate: null, consumed: 6, protocolMatched: true, }); @@ -466,6 +469,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", reason: "endpoint", + topologyPredicate: null, consumed: 6, protocolMatched: true, }); @@ -517,6 +521,8 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc originalCallerModeKnown: false, startupReason: null, strictReason: "topology", + startupTopologyPredicate: null, + strictTopologyPredicate: "live-endpoint-membership", }); const capsulePath = join(root, "first-after-compose-replay-refusal.json"); const capsuleBytes = await readFile(capsulePath, "utf8");