From 96379d69bb6822be33deb4614e02f45ce15890f7 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 13:11:53 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 8a57ac157a1f77c8a5e5ebaa0b8bbe9b3ce3f765 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:37:10 -0400 Subject: [PATCH 7/9] 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 1aa67ac0ac10eb89a44c14db83bf6b37a7a9120b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 21:23:20 -0400 Subject: [PATCH 8/9] 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 7be8ad22f88468131d2ecb82e6ca3e8e0b113dec Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:24:04 -0400 Subject: [PATCH 9/9] 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 +);