diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31345967d..29c7115f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,7 @@ jobs: run: HACK_TEST_COMPOSE_CONFIG=1 bun test tests/native-compose-renderer-config.test.ts --test-name-pattern '^one authored bridge keeps internal policy and static aliases through compiler and Compose normalization$' - name: Run local and Docker E2E env: + HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE: "1" HACK_E2E_CLI_BIN: ${{ github.workspace }}/dist/hack run: | HACK_E2E_DOCKER_HOST="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}" diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 2e83c9747..320517ba3 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -421,6 +421,63 @@ 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 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, 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. +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. 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 +device/inode, owner, mode, size, timestamps and a content digest. Fixed original-ID +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. + +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. + +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 legacy originals. Link-before-unlink restoration cannot overwrite another file; @@ -524,6 +581,56 @@ Use the same prerequisites and flags as above with `--only=native-compose-adoption-managed-worktrees`. Registration and synthetic fixture controls do not establish a live pass. +## Retained routing contract under implementation + +The separate private version 14 mapper admits literal legacy `dev_host`, its +already configured OAuth alias and `open.prefer`, together with closed static +Caddy HTTP upstream labels. It pins full HTTPS origins rather than deriving a +new host from the project name or a global domain. Routed services must configure +exactly the existing `hack-dev` attachment and the project default bridge; other +services retain only the default bridge and existing local named storage. + +Ordinary import preview remains outside this private family. The retained routing +owner binds the original resources and ingress incarnations, reserves the exact +hostnames, and verifies current proxy dispatch before clearing a startup receipt. +Saved reads preserve literal origins and typed local precedence without acquiring +managed values. Every original-ID lifecycle child has a durable prospective +record; only its one-use known-return and process-group-absence proof settles that +record. Explicit recovery can contain an uncertain child but cannot clear its +uncertainty merely because containers are stopped. + +Within one read-only dispatch proof, a private owner-issued context reuses its +entry resource observation while checking source, receipt, claims and lease +authority throughout. Routing, ingress and foreign-site observations remain +fresh. A complete resource/runtime observation brackets the proof, including +final volume and inventory rereads. The context is revoked before return and +cannot cross a lifecycle effect, publication or another observation phase. +This reduces nested inspection calls; it is not a measured runtime or CPU claim. + +Rollback requires restored source bytes, stopped original resources and absent +proxy dispatch before handing hostname claims back to the restored legacy source. +It retains a durable handoff state across interrupted claim removal. Builds, jobs, +readiness, source binds, files, branch overrides and custom bridges remain outside +this initial routing family. The owner and private-store model do not prove live +TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated +ingress lifecycle fixture remains required; global DNS and trust are unchanged. + +The explicitly selected `native-compose-adoption-routing-worktrees` scenario +adds an HTTP service to the original two-worktree PostgreSQL fixture. It requires +both literal HTTPS origins and the existing OAuth alias to serve each checkout's +marker, with no published proxy ports. Saved `open` must select alpha's +checkout-local alias over the authored and primary-local dev preference. It also +checks original SQL/IDs/births, a known partial-stop journal and explicit +recovery, two retained up/down cycles, exact source rollback and claim handoff. +Unknown child or cleanup disposition retains the fixture. The temporary Caddy +owner is shared with `native-config-routing`; stopped user proxies and the +existing `hack-dev` network stay intact. This scenario does not perform OAuth +login, change host DNS/trust, or qualify combined unsupported families. + +With current compiled artifacts, cached fixture images and an exclusively +coordinated Docker lane, select it using the same prerequisites above and +`--only=native-compose-adoption-routing-worktrees`. Its source and pure controls +are separate from a completed live TLS/SQL run. ## Retained directory binds Version 12 is a distinct retained source-directory owner. It accepts literal diff --git a/docs/reference/native-compose-ownership.md b/docs/reference/native-compose-ownership.md index 1e4abab24..0cc7a0ba3 100644 --- a/docs/reference/native-compose-ownership.md +++ b/docs/reference/native-compose-ownership.md @@ -87,6 +87,15 @@ UTF-8 errors now normalize to the fixed `E_NATIVE_COMPOSE_PROBE` refusal. Ordere commands translate this metadata and their separate JSON/object boundaries into fixed redacted reasons; no argv, daemon output, IDs or exit values are retained. +Topology refusals additionally retain a private fixed predicate label on the +exact owner-issued error: network member shape or ID, member/container endpoint +agreement, workload policy or endpoint keyset, and created or live endpoint +membership. Recorded-query replay reports this label alongside the existing +broad reason. It identifies the first refused check on those saved replies; +it does not reconstruct the original caller mode, timing or external cause. +No inspect values are exposed, and admission, scan order and deadlines are +unchanged. + The closest regression suite is `tests/native-compose-ownership.test.ts`. Its isolated executable checks accepted resources, collisions, stale generations, inventory changes, redaction, and actual subprocess overflow/timeout/cancellation. diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index d1dc7fbf8..0bc970d14 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -138,8 +138,12 @@ grant does not authorize retained adoption of any of those feature families. 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 +runtime binding equivalence. The ordinary retained-adoption baseline still refuses +file declarations and grants before private values, keys or engine probes. The +separate [retained-file owner](native-compose-adoption.md) requires original bind +and material proof for its closed config-0444 and protected secret-0400/0600 subset; +preview completeness does not provide that authority or qualify ordinary secret +permissions. 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). diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index dea609317..ecd8c5e46 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -16,6 +16,7 @@ import { } from "../lib/native-compose-adoption-generation.ts"; import { previewLegacyComposeAdoption } from "../lib/native-compose-adoption-preview.ts"; import { legacyComposeRetainedOrdered } from "../lib/native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../lib/native-compose-adoption-routing-execution.ts"; import { requireNativeComposeBackend } from "../lib/native-compose-selection.ts"; import { run } from "../lib/shell.ts"; @@ -117,6 +118,14 @@ async function adoptPrepared( if (opts.signal.aborted) { throw new Error("Legacy adoption cancelled; values omitted."); } + if (input.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + signal: opts.signal, + }); + } if ( legacyComposeRetainedOrdered(input.retainedPlan) || input.retainedBuild || diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 1332cc8da..cbf7bb247 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeBranch, @@ -16,11 +17,19 @@ import { legacyComposeAdoptionCandidateSupported, legacyComposeAdoptionLayoutSupported, } from "./native-compose-adoption-contract.ts"; +import { + type LegacyComposeRetainedFileProof, + legacyComposeRetainedFileGrants, + observeLegacyComposeRetainedFileProof, + observeLegacyComposeRetainedFileSources, +} from "./native-compose-adoption-files.ts"; import { retainLegacyAdoptionLocalRefusal } from "./native-compose-adoption-local.ts"; import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedFileAdoption, + planLegacyComposeRetainedRoutingAdoption, planLegacyComposeSourceBindAdoption, } from "./native-compose-adoption-plan.ts"; import { @@ -28,10 +37,19 @@ import { LegacyComposeAdoptionProjection, } from "./native-compose-adoption-projection.ts"; import { legacyComposeRetainedPlan } from "./native-compose-adoption-readiness.ts"; +import { + inspectLegacyComposeRetainedRouting, + type LegacyComposeRetainedRoutingProof, +} from "./native-compose-adoption-routing.ts"; +import { inspectLegacyComposeContainerStates } from "./native-compose-adoption-runtime.ts"; import { acquireLegacyComposeSourceBind, type LegacyComposeSourceBindProof, } from "./native-compose-adoption-source-bind.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; import { createNativeComposeProbe, NativeComposeOwnershipError, @@ -43,6 +61,8 @@ import { import { freezeImportValue, mapLegacyNativeBranchStorageAdoption, + mapLegacyNativeRetainedFileStorage, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import type { LegacyComposeSourceBindIntent } from "./native-config-import-storage.ts"; @@ -67,7 +87,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}")}}}`, @@ -78,15 +98,21 @@ 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}}}' ); const SOURCE_BIND_CONTAINER_FORMAT = formats.container.inspect.replace( - "{{json $m.Name}}", + '{{$name := ""}}{{range $key, $value := $m}}{{if eq $key "Name"}}{{$name = $value}}{{end}}{{end}}{{json $name}}', '{{if eq $m.Type "bind"}}{{json (index $m "Name")}}{{else}}{{json $m.Name}}{{end}}' ); -if (CUSTOM_CONTAINER_NETWORK_FORMAT === formats.container.inspect) { +if ( + CUSTOM_CONTAINER_NETWORK_FORMAT === formats.container.inspect || + SOURCE_BIND_CONTAINER_FORMAT === formats.container.inspect +) { throw new Error( "Legacy adoption inspection format is invalid; values omitted." ); @@ -332,6 +358,12 @@ export type LegacyComposeVerifiedBinding = LegacyComposeVerifiedBindingBase & readonly composeFiles: readonly string[]; readonly networks: readonly LegacyComposeVerifiedNetwork[]; } + | { + readonly binding_version: 14; + readonly composeFiles: readonly string[]; + readonly network: LegacyComposeOriginalNetwork; + readonly routing: LegacyComposeRetainedRoutingProof; + } | { readonly binding_version: 13; readonly composeFiles: readonly string[]; @@ -427,7 +459,8 @@ function containerMounts( readonly service: string; readonly intent: LegacyComposeStorageIntent; readonly volumes: readonly LegacyComposeVerifiedVolume[]; - readonly root?: string; + readonly root: string; + readonly fileCandidate?: unknown; readonly sourceBinds?: LegacyComposeSourceBindIntent["sourceBinds"]; } ) { @@ -437,7 +470,16 @@ function containerMounts( ); const sources = opts.sourceBinds?.filter((mount) => mount.service === opts.service) ?? []; - requireValue(row.mounts.length === expected.length + sources.length); + const files = + opts.fileCandidate === undefined + ? [] + : legacyComposeRetainedFileGrants(opts.fileCandidate).filter( + (grant) => grant.service === opts.service + ); + requireValue(!(sources.length && files.length)); + requireValue( + row.mounts.length === expected.length + sources.length + files.length + ); const targets = new Set(); for (const item of row.mounts) { requireValue(isRecord(item)); @@ -460,6 +502,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 && @@ -482,6 +537,8 @@ function containerRows( readonly network?: { readonly name: string; readonly id: string }; readonly networks?: readonly LegacyComposeVerifiedNetwork[]; readonly composeFiles: readonly string[]; + readonly fileCandidate?: unknown; + readonly ingress?: NativeComposeIngressBinding; readonly sourceBinds?: LegacyComposeSourceBindIntent["sourceBinds"]; } ): LegacyComposeVerifiedContainer[] { @@ -528,6 +585,9 @@ function containerRows( service: row.service, intent: opts.intent, volumes: opts.volumes, + ...(opts.fileCandidate === undefined + ? {} + : { fileCandidate: opts.fileCandidate }), }); if (opts.intent.ownedNetworks) { const verifiedNetworks = opts.networks; @@ -576,8 +636,36 @@ function containerRows( } return { id: row.id, name: row.name.slice(1), service: row.service }; } - requireValue(Array.isArray(row.networks) && row.networks.length === 1); - const network = row.networks[0]; + requireValue(Array.isArray(row.networks)); + const routed = + opts.intent.routing?.routes.some( + (route) => route.service === row.service + ) === true; + if (opts.intent.routing) { + requireValue( + opts.ingress && + !opts.intent.ownedNetwork && + !opts.intent.ownedNetworks && + row.networks.length === (routed ? 2 : 1) + ); + const shared = row.networks.filter( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + requireValue(routed ? shared.length === 1 : shared.length === 0); + if (routed) { + const item = shared[0]; + requireValue(isRecord(item)); + keys(item, ["id", "name"]); + requireValue(item.id === opts.ingress.networkId); + } + } else { + requireValue(row.networks.length === 1); + } + const local = row.networks.filter( + (item) => !isRecord(item) || item.name !== DEFAULT_INGRESS_NETWORK + ); + requireValue(local.length === 1); + const network = local[0]; requireValue(isRecord(network)); keys( network, @@ -780,6 +868,25 @@ export async function inspectLegacyComposeSourceBindResources(opts: { return await inspectResources(opts, opts.intent.sourceBinds); } +/** Read-only proof of exact original host-file binds, never VM delivery or source-bind 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); + requireValue( + !( + opts.intent.ownedNetwork || + opts.intent.ownedNetworks || + opts.intent.routing + ) + ); + return await inspectResources({ ...opts, fileCandidate: opts.candidate }); +} + async function inspectResources( opts: { readonly root: string; @@ -788,9 +895,19 @@ async function inspectResources( readonly timeoutMs?: number; readonly composeFiles?: readonly string[]; readonly selectedBranch?: string; + readonly fileCandidate?: unknown; }, sourceBinds?: LegacyComposeSourceBindIntent["sourceBinds"] ): Promise { + requireValue( + !( + opts.fileCandidate !== undefined && + (sourceBinds || + opts.selectedBranch || + opts.composeFiles || + opts.intent.routing) + ) + ); const composeFiles = canonicalComposeFiles( opts.root, opts.composeFiles, @@ -863,6 +980,7 @@ async function inspectResources( }; let { plural, single } = await readNetwork(); const networkIdentity = JSON.stringify(plural?.networks ?? single?.network); + let ingress: NativeComposeIngressBinding | undefined; const readContainers = async () => { const facts = await inspect({ kind: "container", @@ -874,12 +992,19 @@ async function inspectResources( opts.intent.ownedNetworks !== undefined, sourceBinds: sourceBinds !== undefined, }); + // Retain the original routed observation after the first container read. + // The complete routing proof below rechecks this same ingress incarnation. + if (opts.intent.routing && ingress === undefined) { + ingress = await observeNativeComposeIngress({ signal: opts.signal }); + requireValue(ingress.engineId === engineId); + } const containers = containerRows(facts, { ...opts, composeFiles, volumes, network: single?.network, networks: plural?.networks, + ingress, sourceBinds, }); return { facts, containers }; @@ -989,6 +1114,31 @@ async function inspectResources( }; } requireValue(single); + if (opts.intent.routing) { + requireValue( + !( + plural || + sourceBinds || + opts.selectedBranch || + opts.intent.ownedNetwork || + opts.intent.ownedNetworks + ) && ingress + ); + const routing = await inspectLegacyComposeRetainedRouting({ + binding: common, + routing: opts.intent.routing, + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }); + requireValue(JSON.stringify(routing.ingress) === JSON.stringify(ingress)); + return { + ...common, + binding_version: 14, + composeFiles: Object.freeze(composeFiles), + network: single.network, + routing, + }; + } if (sourceBinds) { requireValue( !( @@ -1059,6 +1209,7 @@ export type LegacyComposeAdoptionBinding = { }; readonly branch?: LegacyComposeBranchProof; readonly projection?: Readonly; + readonly fileProof?: LegacyComposeRetainedFileProof; readonly sourceBindProof?: LegacyComposeSourceBindProof; readonly build?: { readonly source: LegacyComposeBuildSourceProof; @@ -1125,9 +1276,58 @@ export async function acquireLegacyComposeSourceBindBinding( return await acquireBinding(input, "source-bind"); } +function requireInitialRetainedFamily( + candidate: Readonly> | undefined, + files: boolean +): boolean { + if (files) { + // Close the static family before any generated/env/material/engine acquisition. + legacyComposeRetainedFileGrants(candidate); + } + const jobFamily = + candidate !== undefined && + legacyComposeRetainedPlan(candidate).requiresV7 === true; + if (jobFamily && !legacyComposeAdoptionCandidateSupported(candidate)) { + refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); + } + return jobFamily; +} + +async function requireRetainedFileStaticLayout(opts: { + readonly files: boolean; + readonly generatedPresent: boolean; + readonly root: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}): Promise { + if ( + opts.files && + (opts.generatedPresent || + !(await legacyComposeAdoptionLayoutSupported({ + projectRoot: opts.root, + candidate: opts.candidate, + signal: opts.signal, + }))) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); + } +} + +/** Explicit retained-file owner; preparation may select it only from closed authored intent. */ +export async function acquireLegacyComposeRetainedFileBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "files"); +} + async function acquireBinding( input: BindingSelection, - purpose: "image-only" | "basic-build" | "source-bind" | "preparation" + purpose: + | "image-only" + | "basic-build" + | "source-bind" + | "preparation" + | "files" ): Promise { let signal: AbortSignal | undefined; try { @@ -1158,25 +1358,52 @@ async function acquireBinding( composeText: source.composeText, selectedComposeProject: branch?.proof.composeProject, }); + const filePlan = + purpose === "files" || + (purpose === "preparation" && !ordinary.intent && !branch) + ? planLegacyComposeRetainedFileAdoption(source) + : undefined; + const files = purpose === "files" || Boolean(filePlan?.intent); + const routed = + purpose === "preparation" && !ordinary.intent && !branch && !files + ? planLegacyComposeRetainedRoutingAdoption(source) + : undefined; const basicPlan = purpose === "basic-build" || - (purpose === "preparation" && !ordinary.intent) + (purpose === "preparation" && + !ordinary.intent && + !routed?.intent && + !files) ? planLegacyComposeRetainedBasicBuildAdoption(source) : undefined; const basic = purpose === "basic-build" || (purpose === "preparation" && !ordinary.intent && + !routed?.intent && + !files && !branch && Boolean(basicPlan?.intent)); const sources = purpose === "source-bind" || - (purpose === "preparation" && !ordinary.intent && !basic && !branch); - if (branch && (basic || sources || !ordinary.intent)) { + (purpose === "preparation" && + !ordinary.intent && + !routed?.intent && + !basic && + !files && + !branch); + if ( + branch && + (files || basic || sources || routed?.intent || !ordinary.intent) + ) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } let planned = ordinary; - if (sources) { + if (files && filePlan) { + planned = filePlan; + } else if (routed?.intent) { + planned = routed; + } else if (sources) { planned = planLegacyComposeSourceBindAdoption(source); } else if (basic && basicPlan) { planned = basicPlan; @@ -1191,18 +1418,21 @@ async function acquireBinding( const sourceBind = sources ? await acquireLegacyComposeSourceBind({ source, signal }) : undefined; - const mapped = ( - branch - ? mapLegacyNativeBranchStorageAdoption - : mapLegacyNativeStorageAdoption - )({ + let mapper = mapLegacyNativeStorageAdoption; + if (files) { + mapper = mapLegacyNativeRetainedFileStorage; + } else if (intent.routing) { + mapper = mapLegacyNativeRetainedRouting; + } else if (branch) { + mapper = mapLegacyNativeBranchStorageAdoption; + } + const mapped = mapper({ configText: source.configText, composeText: source.composeText, }); const candidate = sourceBind?.candidate ?? buildSource?.candidate ?? mapped.candidate; - const jobFamily = - candidate && legacyComposeRetainedPlan(candidate).requiresV7 === true; + const jobFamily = requireInitialRetainedFamily(candidate, files); if ( branch && (jobFamily || @@ -1225,22 +1455,31 @@ async function acquireBinding( signal, Boolean(branch) ); + await requireRetainedFileStaticLayout({ + files, + generatedPresent, + root, + candidate, + signal, + }); if ( candidate && - (generatedPresent || + (intent.routing !== undefined || + generatedPresent || !(await legacyComposeAdoptionLayoutSupported({ projectRoot: root, candidate, signal, }))) ) { - if (sourceBind || buildSource || jobFamily || branch) { + if (files || sourceBind || buildSource || jobFamily || branch) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } projection = await LegacyComposeAdoptionProjection.acquire({ source, signal, binary, + retainedRouting: intent.routing !== undefined, }); const resolved = await projection.resolve({ signal }); projected = Object.freeze({ @@ -1292,30 +1531,74 @@ async function acquireBinding( } }; await layoutSupported(signal); - const inspectBinding = () => - sources && "sourceBinds" in intent - ? inspectLegacyComposeSourceBindResources({ - root, - intent, - signal, - timeoutMs, - }) - : inspectLegacyComposeAdoptionResources({ - root, - intent, - signal, - timeoutMs, - composeFiles: projected?.composeFiles, - ...(branch - ? { - composeFiles: branch.composeFiles, - selectedBranch: branch.proof.branch, - } - : {}), - }); + const initialFileSources = files + ? await observeLegacyComposeRetainedFileSources({ + projectRoot: root, + candidate, + signal, + }) + : undefined; + const inspectBinding = (selectedSignal = signal) => { + if (files) { + return inspectLegacyComposeRetainedFileResources({ + root, + intent, + candidate, + signal: selectedSignal, + timeoutMs, + }); + } + if (sources && "sourceBinds" in intent) { + return inspectLegacyComposeSourceBindResources({ + root, + intent, + signal: selectedSignal, + timeoutMs, + }); + } + return inspectLegacyComposeAdoptionResources({ + root, + intent, + signal: selectedSignal, + timeoutMs, + composeFiles: projected?.composeFiles, + ...(branch + ? { + composeFiles: branch.composeFiles, + selectedBranch: branch.proof.branch, + } + : {}), + }); + }; const baseline = sourceBind ? await sourceBind.withFresh({ signal }, inspectBinding) : await inspectBinding(); + 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; + if ( + fileProof && + JSON.stringify(fileProof.sources) !== JSON.stringify(initialFileSources) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } const buildImages = buildSource ? await inspectLegacyComposeRetainedBuildImages({ binding: baseline, @@ -1345,27 +1628,7 @@ async function acquireBinding( } await source.assertFresh({ signal: currentSignal }); await layoutSupported(currentSignal); - const inspectCurrent = () => - sources && "sourceBinds" in intent - ? inspectLegacyComposeSourceBindResources({ - root, - intent, - signal: currentSignal, - timeoutMs, - }) - : inspectLegacyComposeAdoptionResources({ - root, - intent, - signal: currentSignal, - timeoutMs, - composeFiles: projected?.composeFiles, - ...(branch - ? { - composeFiles: branch.composeFiles, - selectedBranch: branch.proof.branch, - } - : {}), - }); + const inspectCurrent = () => inspectBinding(currentSignal); const observed = sourceBind ? await sourceBind.withFresh( { signal: currentSignal }, @@ -1375,6 +1638,43 @@ async function acquireBinding( 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 inspectResources({ + root, + intent, + signal: currentSignal, + timeoutMs, + fileCandidate: candidate, + }) + ) !== JSON.stringify(baseline) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } + } if ( buildImages && JSON.stringify( @@ -1419,6 +1719,7 @@ async function acquireBinding( binding: baseline, sourceFiles: source.sourceFiles, ...(branch ? { branch: branch.proof } : {}), + ...(fileProof ? { fileProof } : {}), ...(projected ? { projection: projected } : {}), ...(sourceBind ? { sourceBindProof: sourceBind.proof } : {}), ...(buildSource && buildImages @@ -1438,6 +1739,7 @@ async function acquireBinding( "branch", "projection", "sourceBindProof", + "fileProof", "build", ]) { 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 116103e07..a2b0f493b 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -11,6 +11,7 @@ import { } from "./native-compose-adoption-generation.ts"; import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; import { legacyComposeRetainedOrdered } from "./native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates } from "./native-compose-adoption-runtime.ts"; import type { NativeComposeCommandOptions } from "./native-compose-command.ts"; import { requireNativeComposeBackend } from "./native-compose-selection.ts"; @@ -61,7 +62,9 @@ async function registeredAdoptedRoot(project: string) { } /** Check private adoption ownership before ordinary authored discovery can allocate a fresh native namespace. */ -async function adoptedRoot(options: NativeComposeCommandOptions) { +export async function selectLegacyComposeAdoptedRoot( + options: Pick +) { if (options.path && options.project !== undefined) { throw new CliUsageError("Use either --path or --project (not both)."); } @@ -253,7 +256,7 @@ export async function tryLegacyComposeAdoptedCommand( command: input.command ? [...input.command] : undefined, profiles: input.profiles ? [...input.profiles] : undefined, }; - const projectRoot = await adoptedRoot(options); + const projectRoot = await selectLegacyComposeAdoptedRoot(options); if (!projectRoot) { return null; } @@ -294,6 +297,14 @@ export async function tryLegacyComposeAdoptedCommand( deadline, run: async (privateInput) => { cancelled(signal); + if (privateInput.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input: privateInput, + operation, + deadline, + signal, + }); + } if ( legacyComposeRetainedOrdered(privateInput.retainedPlan) || privateInput.retainedBuild || @@ -328,6 +339,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-env-inputs.ts b/src/lib/native-compose-adoption-env-inputs.ts index 251bac097..c73a3a581 100644 --- a/src/lib/native-compose-adoption-env-inputs.ts +++ b/src/lib/native-compose-adoption-env-inputs.ts @@ -12,7 +12,11 @@ import { privateNativeConfigImportSourceProof, } from "./native-config-import-inputs.ts"; import { parseImportDocument } from "./native-config-import-parser.ts"; -import { mapLegacyNativeStorageAdoption } from "./native-config-import-plan.ts"; +import { + mapLegacyNativeRetainedRouting, + mapLegacyNativeStorageAdoption, +} from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; import type { NativeProjectEnvSelectionOptions } from "./project-env-config.ts"; import { @@ -76,6 +80,7 @@ export class LegacyAdoptionManagedEnvAdmission { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { if ( @@ -86,11 +91,31 @@ export class LegacyAdoptionManagedEnvAdmission { const source = opts.source; const signal = opts.signal; const binary = opts.binary; + const retainedRouting = opts.retainedRouting === true; if (signal !== undefined && !(signal instanceof AbortSignal)) { refuse(); } await source.assertFresh({ signal }); - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose: parseImportDocument({ + text: source.composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if (retainedRouting && !routing) { + refuse(); + } const candidate = mapped.candidate; if (!(candidate && isRecord(candidate.services))) { refuse(); @@ -132,6 +157,7 @@ export class LegacyAdoptionManagedEnvAdmission { overlay, binary, signal, + routing, }); const context = { source, @@ -167,6 +193,13 @@ export class LegacyAdoptionManagedEnvAdmission { return this.#context.local.fields; } + get routingResolution() { + if (!ownedAdmissions.has(this)) { + refuse(); + } + return this.#context.local.routingResolution; + } + /** Private manifest provenance; captured sources are factory-issued and still fresh. No key or layer reread. */ async resolvePrivatePrimaryProof() { await this.assertRoot(this.selection); diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index 20bc64268..778be699d 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -17,6 +17,7 @@ import { legacyComposeRetainedReady, } from "./native-compose-adoption-readiness.ts"; import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import type { LegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeJobStates, inspectLegacyComposeReadiness, @@ -38,7 +39,10 @@ function refuse( } const COMPLETION = Symbol("legacy-compose-job-completion"); export type LegacyComposeJobCompletion = { readonly [COMPLETION]: true }; -export type LegacyComposeRetainedOutcome = number | LegacyComposeJobCompletion; +export type LegacyComposeRetainedOutcome = + | number + | LegacyComposeJobCompletion + | LegacyComposeRoutingCompletion; type CompletionWitness = { readonly plan: LegacyComposeRetainedPlan; readonly binding: LegacyComposeVerifiedBinding; diff --git a/src/lib/native-compose-adoption-files.ts b/src/lib/native-compose-adoption-files.ts new file mode 100644 index 000000000..1fed36085 --- /dev/null +++ b/src/lib/native-compose-adoption-files.ts @@ -0,0 +1,982 @@ +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 NativeComposeFileMode, + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "./native-compose-file-permissions.ts"; +import { + type HeldDirectory, + holdDirectory, + recheckDirectories, +} from "./native-compose-private-state.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}$/; +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: NativeComposeFileMode; + readonly size: number; + readonly digest: string; +}; +/** Private material digests and identities. Never serialize this proof into a public report. */ +export type LegacyComposeRetainedFileProof = { + 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( + "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[]; + readonly policies?: readonly RetainedFilePermissionPolicy[]; +}; +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"]); + const logical = mount[kind]; + const mode = nativeComposeFileMode(mount.mode); + if ( + typeof logical !== "string" || + !Object.hasOwn(opts.definitions[kind], logical) || + mount.access !== "read-only" || + !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(); + } + 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, + policies: legacyNativeRetainedFilePolicies(candidate), + }); + } + 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, version: 1 | 2): 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) || + !nativeComposeFileMode(item.mode) || + (version === 1 && 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: 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; + 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); + 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((entry) => parseGuest(entry, version)); + 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(); + } + assertProofSources({ sources, grants: unique }); + 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 || + 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: 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; +} +/** 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; + const fact: SourceFact = { + 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, + }; + 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(); + } 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, + 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] !== requiredMode.slice(1) + ) { + 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 || + (Number.parseInt(parts[5] ?? "", 16) & 0o777) !== + nativeComposeFileModeBits(requiredMode) + ) { + 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; + readonly mode: NativeComposeFileMode; +}): 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, + opts.mode + ); + 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: opts.mode, + 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, + mode: effectiveMode({ candidate: opts.candidate, grant, source }), + }) + ); + } + 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: 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 033a71109..2ba39c81e 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -2,10 +2,12 @@ import { createHash } from "node:crypto"; import type { Stats } from "node:fs"; import { link, lstat, mkdir, rename, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeAdoptionPreparationBinding, inspectLegacyComposeAdoptionResources, + inspectLegacyComposeRetainedFileResources, inspectLegacyComposeSourceBindResources, type LegacyComposeVerifiedBinding, } from "./native-compose-adoption-binding.ts"; @@ -30,13 +32,22 @@ import { consumeLegacyComposeJobCompletion, type LegacyComposeRetainedOutcome, } from "./native-compose-adoption-execution.ts"; +import { + type LegacyComposeRetainedFileProof, + normalizeLegacyComposeRetainedFileCandidate, + observeLegacyComposeRetainedFileProof, + readLegacyComposeRetainedFileProof, +} from "./native-compose-adoption-files.ts"; import { legacyComposeFreshJobResult, legacyComposeJobStates, } from "./native-compose-adoption-jobs.ts"; import { + type LegacyComposeStorageIntent, planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedFileAdoption, + planLegacyComposeRetainedRoutingAdoption, planLegacyComposeSourceBindAdoption, } from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; @@ -56,13 +67,17 @@ import { type Receipt, parseLegacyComposeAdoptionReceipt as receipt, } from "./native-compose-adoption-receipt.ts"; +import { assertLegacyComposeRetainedRoutingState } from "./native-compose-adoption-routing.ts"; +import { consumeLegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates, inspectLegacyComposeJobStates, inspectLegacyComposeReadiness, inspectLegacyComposeRuntimeConfig, + type LegacyComposeContainerState, } from "./native-compose-adoption-runtime.ts"; import { holdSavedLegacyComposeSourceBind } from "./native-compose-adoption-source-bind.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; import { createNativeComposePrivateMutationLock, type HeldDirectory, @@ -79,6 +94,13 @@ import { token, writeExclusive, } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteAttempt, + type NativeComposeRouteClaims, + type NativeComposeRouteReference, + openNativeComposeRouteClaims, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; import { NATIVE_CONFIG_INPUT_LIMIT } from "./native-config-compiler.ts"; import { type NativeConfigImportSourceIdentity, @@ -90,9 +112,16 @@ import { freezeImportValue, mapLegacyNativeBranchStorageAdoption, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedFileStorage, + mapLegacyNativeRetainedRouting, mapLegacyNativeRetainedSourceBind, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + type LegacyComposeRoutingIntent, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; const HASH = /^[a-f0-9]{64}$/; @@ -117,17 +146,22 @@ type SavedManifest = { | 5 | 6 | 7 + | 8 | 9 | 10 | 11 | 12 - | 13; + | 13 + | 14; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly fileProof?: unknown; + readonly routingClaims?: NativeComposeRouteReference; + readonly routingRoot?: string; readonly buildProof?: { readonly source: unknown; readonly images: unknown }; readonly sourceBindProof?: unknown; readonly branchProof?: LegacyComposeBranchProof; @@ -152,11 +186,15 @@ type PrivateInputs = { readonly projectionMetadata?: NativeProjectEnvMetadata; /** Private version9 policy; never serialized into a compiler report or receipt label. */ readonly retainedBuild?: true; + readonly retainedRouting?: true; + readonly routingResolution?: NativeRoutingResolution; readonly retainedSourceBind?: true; }; type MutationInputs = PrivateInputs & { /** Issued while the journal and mutation lock are held; valid only during this callback. */ readonly assertFresh: () => Promise; + /** Synchronous revocation fence for the final spawn boundary. */ + readonly assertActive: () => void; readonly retainedPlan: LegacyComposeRetainedPlan; }; @@ -265,6 +303,12 @@ function sourceFileIdentity( ); } function manifestFieldKeys(value: Record) { + if (value.adoption_generation_version === 8) { + return "adoption_generation_version,binding,fileProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; + } + if (value.adoption_generation_version === 14) { + return "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,routingClaims,routingRoot,runtimeConfig,sourceFiles"; + } if (value.adoption_generation_version === 13) { return "adoption_generation_version,binding,branchProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; } @@ -289,6 +333,12 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { isRecord(value) && keys(value, manifestFieldKeys(value)) && (value.adoption_generation_version === 1 || + (value.adoption_generation_version === 14 && + isRecord(value.projectionProof) && + value.projectionProof.projection_version === 3 && + isRecord(value.routingClaims) && + typeof value.routingRoot === "string" && + resolve(value.routingRoot) === value.routingRoot) || (value.adoption_generation_version === 13 && legacyComposeBranchProof(value.branchProof)) || (value.adoption_generation_version === 12 && @@ -302,6 +352,7 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { Array.isArray(value.buildProof.images)) || value.adoption_generation_version === 11 || value.adoption_generation_version === 10 || + value.adoption_generation_version === 8 || (value.adoption_generation_version === 5 && (!Object.hasOwn(value, "projectionProof") || (isRecord(value.projectionProof) && @@ -339,6 +390,15 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { ? { branchProof: value.branchProof } : {}), runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 8 + ? { fileProof: value.fileProof } + : {}), + ...(value.adoption_generation_version === 14 + ? { + routingClaims: parseNativeComposeRouteReference(value.routingClaims), + routingRoot: String(value.routingRoot), + } + : {}), ...(value.adoption_generation_version === 12 ? { sourceBindProof: value.sourceBindProof } : {}), @@ -353,7 +413,8 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { ...((value.adoption_generation_version === 5 && Object.hasOwn(value, "projectionProof")) || value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 + value.adoption_generation_version === 4 || + value.adoption_generation_version === 14 ? { projectionProof: value.projectionProof } : {}), sourceFiles: { @@ -441,6 +502,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: { @@ -470,6 +533,7 @@ export type LegacyComposeAdoptedGenerationStore = { type Context = { readonly root: string; + readonly routingRoot: string; readonly checkout: Checkout; readonly directories: HeldDirectory[]; readonly stateRoot: string; @@ -493,6 +557,97 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +type RetainedRouteObservation = { + readonly binding: LegacyComposeVerifiedBinding; + readonly runtimeConfig: unknown; + readonly assertActive: () => void; + readonly assertManifest: (meta: SavedManifest) => void; +}; +// Only the private read-only routing proof issues this context. Revoked entries +// remain in the WeakMap so a late continuation refuses instead of reacquiring. +const retainedRouteObservations = new WeakMap< + Context, + RetainedRouteObservation +>(); +function savedRetainedFileContainers( + meta: SavedManifest +): readonly { readonly id: string; readonly service: string }[] { + if (!(isRecord(meta.binding) && Array.isArray(meta.binding.containers))) { + refuse(); + } + return 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 }; + }); +} +function savedRetainedFileProof( + meta: SavedManifest, + candidate: unknown +): LegacyComposeRetainedFileProof { + return readLegacyComposeRetainedFileProof({ + proof: meta.fileProof, + candidate, + containers: savedRetainedFileContainers(meta), + }); +} +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"); + } +} async function assertRetainedBuildSource(opts: { readonly ctx: Context; readonly meta: SavedManifest; @@ -545,6 +700,7 @@ async function requireSelectedTopologyOwner(opts: { readonly plural: boolean; readonly requiresV5: boolean; readonly branch: boolean; + readonly fileFamily: boolean; readonly preparing: boolean; }) { const { ctx, selected, meta, custom, plural, requiresV5, preparing, branch } = @@ -560,6 +716,14 @@ async function requireSelectedTopologyOwner(opts: { (meta.adoption_generation_version === 11) !== plural || (meta.adoption_generation_version === 5) !== healthOnly || (meta.adoption_generation_version === 10) !== bridgeAndHealth || + (meta.adoption_generation_version === 8) !== opts.fileFamily || + (opts.fileFamily && + (custom || + plural || + requiresV5 || + branch || + meta.binding.binding_version !== 1 || + meta.projectionProof !== undefined)) || (meta.adoption_generation_version === 13) !== branch || (branch && (custom || @@ -585,6 +749,14 @@ async function requireSelectedTopologyOwner(opts: { (!plural && (meta.binding.binding_version === 5 || meta.binding.binding_version === 6)) || + (meta.adoption_generation_version === 14) !== + (meta.binding.binding_version === 14) || + (meta.adoption_generation_version === 14 && + (custom || + plural || + requiresV5 || + !meta.routingClaims || + meta.routingClaims.generationIdentity !== selected.id)) || (meta.adoption_generation_version === 12) !== (meta.binding.binding_version === 12) || (meta.adoption_generation_version === 12 && @@ -601,9 +773,12 @@ async function requireSelectedTopologyOwner(opts: { (state.adoption_receipt_version === 11) !== plural || (state.adoption_receipt_version === 5) !== healthOnly || (state.adoption_receipt_version === 10) !== bridgeAndHealth || + (state.adoption_receipt_version === 8) !== opts.fileFamily || (state.adoption_receipt_version === 13) !== branch || (state.adoption_receipt_version === 9) !== (meta.adoption_generation_version === 9) || + (state.adoption_receipt_version === 14) !== + (meta.adoption_generation_version === 14) || (state.adoption_receipt_version === 12) !== (meta.adoption_generation_version === 12) || JSON.stringify(state.prepared) !== JSON.stringify(selected) @@ -611,14 +786,158 @@ async function requireSelectedTopologyOwner(opts: { refuse(); } } +function retainedRoutingIntent(input: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeRoutingIntent { + const mapped = mapLegacyComposeRouting({ + config: parseImportDocument({ text: input.configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: input.composeText, + document: "compose", + }).value, + }); + return mapped?.intent ?? refuse(); +} +function openRetainedRoutingClaims( + ctx: Context, + generation: string, + binding: LegacyComposeVerifiedBinding +): Promise { + if (binding.binding_version !== 14) { + refuse(); + } + return openNativeComposeRouteClaims({ + root: ctx.routingRoot, + binding: { + engineId: binding.engineId, + proxyId: binding.routing.ingress.proxyId, + networkId: binding.routing.ingress.networkId, + }, + owner: { composeProject: binding.composeProject, ownerToken: generation }, + }); +} +function requireRetainedClaimContext(opts: { + readonly inputs: PrivateInputs; + readonly generation: string; + readonly claim: Parameters< + Parameters[0]["assertAbsent"] + >[0]; + readonly handoff?: true; +}): void { + const binding = opts.inputs.binding; + const names = retainedRoutingIntent(opts.inputs) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort(); + if ( + binding.binding_version !== 14 || + (opts.handoff + ? opts.claim.hostnames.some((hostname) => !names.includes(hostname)) + : JSON.stringify([...opts.claim.hostnames].sort()) !== + JSON.stringify(names)) || + opts.claim.binding.engineId !== binding.engineId || + opts.claim.binding.proxyId !== binding.routing.ingress.proxyId || + opts.claim.binding.networkId !== binding.routing.ingress.networkId || + opts.claim.owner.composeProject !== binding.composeProject || + opts.claim.owner.ownerToken !== opts.generation + ) { + refuse(); + } +} +async function assertRetainedRouteState( + ctx: Context, + loaded: Awaited>, + phase: "active" | "stopped", + deadline: number, + assertOwner: ( + current: Context + ) => Promise>> +): Promise { + const binding = loaded.inputs.binding; + if (binding.binding_version !== 14 || retainedRouteObservations.has(ctx)) { + refuse(); + } + const first = await assertOwner(ctx); + if ( + first.manifest.id !== loaded.manifest.id || + JSON.stringify(first.inputs.binding) !== JSON.stringify(binding) + ) { + refuse(); + } + const observedManifest = JSON.stringify(first.manifest); + const current: Context = { ...ctx }; + let active = true; + const assertActive = () => { + cancelled(current.signal); + if (!(active && Number.isFinite(deadline)) || Date.now() >= deadline) { + refuse(); + } + }; + retainedRouteObservations.set(current, { + binding: first.inputs.binding, + runtimeConfig: first.manifest.runtimeConfig, + assertActive, + assertManifest: (meta) => { + assertActive(); + if (JSON.stringify(meta) !== observedManifest) { + refuse(); + } + }, + }); + try { + await assertLegacyComposeRetainedRoutingState({ + binding, + routing: retainedRoutingIntent(loaded.inputs), + proof: binding.routing, + phase, + signal: current.signal, + timeoutMs: current.timeoutMs, + deadline, + assertOwner: async () => { + assertActive(); + await assertOwner(current); + assertActive(); + }, + }); + } finally { + active = false; + } + // The original context performs a complete fresh binding/runtime proof again, + // including foreign resources, final volumes and inventories, before success. + await assertOwner(ctx); +} +function selectedMapper(routing: boolean, basic: boolean) { + if (routing) { + return mapLegacyNativeRetainedRouting; + } + if (basic) { + return mapLegacyNativeRetainedBasicBuild; + } + return mapLegacyNativeStorageAdoption; +} +function selectedPlanner(routing: boolean, basic: boolean) { + if (routing) { + return planLegacyComposeRetainedRoutingAdoption; + } + if (basic) { + return planLegacyComposeRetainedBasicBuildAdoption; + } + return planLegacyComposeAdoption; +} async function readInputs( ctx: Context, selected: Anchor, - preparing = false + preparing = false, + material: "verify" | "saved" = "verify" ): Promise<{ readonly manifest: Manifest; readonly inputs: Readonly; }> { + const observation = retainedRouteObservations.get(ctx); + observation?.assertActive(); await ctx.check(); const generationRoot = join(ctx.generationsRoot, selected.id); const held = await holdDirectory(generationRoot, true); @@ -634,6 +953,7 @@ async function readInputs( refuse(); } const meta = manifest(saved.value, ctx.root, selected.id); + observation?.assertManifest(meta); if ( ctx.requestedBranch !== undefined && meta.adoption_generation_version !== 13 @@ -652,7 +972,12 @@ async function readInputs( join(generationRoot, "candidate.json"), meta.files.candidate ); + const fileFamily = meta.adoption_generation_version === 8; const basic = meta.adoption_generation_version === 9; + const routing = meta.adoption_generation_version === 14; + if (routing && meta.routingRoot !== ctx.routingRoot) { + refuse(); + } const sourceBind = meta.adoption_generation_version === 12; const selectedBranch = meta.adoption_generation_version === 13 @@ -668,9 +993,12 @@ async function readInputs( if ((meta.adoption_generation_version === 13) !== Boolean(selectedBranch)) { refuse(); } - let mapper = mapLegacyNativeStorageAdoption; - let planner = planLegacyComposeAdoption; - if (sourceBind) { + let mapper = selectedMapper(routing, basic); + let planner = selectedPlanner(routing, basic); + if (fileFamily) { + mapper = mapLegacyNativeRetainedFileStorage; + planner = planLegacyComposeRetainedFileAdoption; + } else if (sourceBind) { mapper = mapLegacyNativeRetainedSourceBind; planner = planLegacyComposeSourceBindAdoption; } else if (basic) { @@ -706,6 +1034,7 @@ async function readInputs( plural: planned.intent?.ownedNetworks !== undefined, requiresV5: retainedPlan.requiresV5 && !retainedPlan.requiresV7, branch: Boolean(selectedBranch), + fileFamily, preparing, }); const projectionOpts = { @@ -719,18 +1048,25 @@ async function readInputs( const projection = (meta.adoption_generation_version === 5 && meta.projectionProof !== undefined) || + meta.adoption_generation_version === 14 || meta.adoption_generation_version === 3 || meta.adoption_generation_version === 4 ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) : 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(); } requireRetainedGenerationVersion( @@ -762,22 +1098,77 @@ async function readInputs( } : {}), }; - const observed = - sourceBind && "sourceBinds" in planned.intent - ? await inspectLegacyComposeSourceBindResources({ - ...inspection, - intent: planned.intent, - }) - : await inspectLegacyComposeAdoptionResources(inspection); + let observed: LegacyComposeVerifiedBinding; + if (observation) { + observed = observation.binding; + } else if (fileFamily) { + observed = await inspectLegacyComposeRetainedFileResources({ + ...inspection, + candidate: mapped.candidate, + }); + } else if (sourceBind && "sourceBinds" in planned.intent) { + observed = await inspectLegacyComposeSourceBindResources({ + ...inspection, + intent: planned.intent, + }); + } else { + observed = await inspectLegacyComposeAdoptionResources(inspection); + } if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } - const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ - binding: observed, - composeFile: join(generationRoot, "legacy-compose.yml"), - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); + if (fileFamily && material === "verify") { + if (!fileProof) { + refuse(); + } + await verifyRetainedFileMaterial({ + ctx, + candidate: mapped.candidate, + intent: planned.intent, + binding: observed, + proof: fileProof, + }); + } + if (routing) { + if (!meta.routingClaims || observed.binding_version !== 14) { + refuse(); + } + const claims = await openRetainedRoutingClaims( + ctx, + selected.id, + observed + ); + try { + const attempt = await claims.reopen(meta.routingClaims); + if ( + attempt.reference.generationIdentity !== selected.id || + JSON.stringify(attempt.hostnames) !== + JSON.stringify( + retainedRoutingIntent({ configText, composeText }) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort() + ) + ) { + refuse(); + } + const current = preparing ? undefined : await publicationState(ctx); + if (current?.routingHandoff !== "releasing") { + await claims.assertHeld(meta.routingClaims); + } + } finally { + await claims.close(); + } + } + const runtimeConfig = observation + ? observation.runtimeConfig + : await inspectLegacyComposeRuntimeConfig({ + binding: observed, + composeFile: join(generationRoot, "legacy-compose.yml"), + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); if (JSON.stringify(meta.runtimeConfig) !== JSON.stringify(runtimeConfig)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } @@ -837,6 +1228,7 @@ async function readInputs( // receipt, compiler and source awaits before a callback or publication. await ctx.check(); } + observation?.assertManifest(meta); freezeImportValue(observed); return { manifest: { ...meta, binding: observed }, @@ -847,6 +1239,10 @@ async function readInputs( binding: observed, ...(projection ? { projectionMetadata: projection.metadata } : {}), ...(basic ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + ...(projection?.routingResolution + ? { routingResolution: projection.routingResolution } + : {}), ...(sourceBind ? { retainedSourceBind: true as const } : {}), }), }; @@ -882,9 +1278,13 @@ async function save( if (opts) { await opts.beforeCommit(); } - if (ctx.sourceBind.current) { - // The receipt is still read after additional awaited work; its final - // context check revalidates the same generation's mounted directory lease. + if ( + value.adoption_receipt_version === 8 || + value.adoption_receipt_version === 14 || + ctx.sourceBind.current + ) { + // Both owners await fresh source proofs; preserve receipt incarnation and + // revalidate mounted directories after that last admission boundary. await requireReceiptSnapshot(ctx, expected); } await rename(temporary, ctx.receiptPath); @@ -926,8 +1326,21 @@ function manifestVersion( requiresV5: boolean, projection?: { readonly projectionProof: { readonly projection_version: number }; - } + }, + fileFamily = false ): Manifest["adoption_generation_version"] { + if (fileFamily) { + if (requiresV5 || binding.binding_version !== 1 || projection) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + return 8; + } + if (binding.binding_version === 14) { + if (requiresV5 || projection?.projectionProof.projection_version !== 3) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + return 14; + } if (binding.binding_version === 12) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } @@ -966,6 +1379,7 @@ function manifestVersion( function preparedManifestVersion(opts: { readonly build: boolean; readonly sourceBind: boolean; + readonly files: boolean; readonly retainedPlan: LegacyComposeRetainedPlan; readonly binding: LegacyComposeVerifiedBinding; readonly projection?: { @@ -984,7 +1398,8 @@ function preparedManifestVersion(opts: { return manifestVersion( opts.binding, opts.retainedPlan.requiresV5, - opts.projection + opts.projection, + opts.files ); } async function prepare( @@ -1018,8 +1433,13 @@ async function prepare( ) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } - let mapper = mapLegacyNativeStorageAdoption; - if (acquired.sourceBindProof) { + let mapper = selectedMapper( + acquired.binding.binding_version === 14, + Boolean(acquired.build) + ); + if (acquired.fileProof) { + mapper = mapLegacyNativeRetainedFileStorage; + } else if (acquired.sourceBindProof) { mapper = mapLegacyNativeRetainedSourceBind; } else if (acquired.build) { mapper = mapLegacyNativeRetainedBasicBuild; @@ -1030,11 +1450,24 @@ 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 ( + (acquired.fileProof && + (acquired.binding.binding_version !== 1 || + acquired.projection || + acquired.branch || + acquired.build || + acquired.sourceBindProof || + retainedPlan.requiresV5 || + retainedPlan.requiresV7)) || (acquired.sourceBindProof && (acquired.binding.binding_version !== 12 || acquired.projection !== undefined || @@ -1075,7 +1508,19 @@ async function prepare( const generationRoot = join(ctx.generationsRoot, id); await mkdir(generationRoot, { mode: 0o700 }); const held = await holdDirectory(generationRoot, true); + let routeClaims: NativeComposeRouteClaims | undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + let prepared = false; try { + if (acquired.binding.binding_version === 14) { + routeClaims = await openRetainedRoutingClaims(ctx, id, acquired.binding); + routeAttempt = await routeClaims.acquire({ + generationIdentity: id, + hostnames: retainedRoutingIntent(acquired).routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ), + }); + } const files = { config: await writeArtifact( join(generationRoot, "legacy-config.json"), @@ -1097,6 +1542,7 @@ async function prepare( adoption_generation_version: preparedManifestVersion({ build: Boolean(acquired.build), sourceBind: Boolean(acquired.sourceBindProof), + files: Boolean(acquired.fileProof), retainedPlan, binding: acquired.binding, projection: acquired.projection, @@ -1105,6 +1551,13 @@ async function prepare( projectRoot: ctx.root, id, binding: acquired.binding, + ...(acquired.fileProof ? { fileProof: acquired.fileProof } : {}), + ...(routeAttempt + ? { + routingClaims: routeAttempt.reference, + routingRoot: ctx.routingRoot, + } + : {}), ...(acquired.branch ? { branchProof: acquired.branch } : {}), ...(acquired.build ? { buildProof: acquired.build } : {}), ...(acquired.sourceBindProof @@ -1135,9 +1588,25 @@ async function prepare( await recheckDirectories([held]); await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); await ctx.check(); + if (routeAttempt) { + await routeClaims?.assertHeld(routeAttempt.reference); + await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); + await ctx.check(); + } + prepared = true; return { id, manifest: { ...fileIdentity(written), hash: hash(text) } }; } finally { - await held.file.close(); + try { + try { + if (!prepared && routeAttempt) { + await routeClaims?.rollback(routeAttempt); + } + } finally { + await routeClaims?.close(); + } + } finally { + await held.file.close(); + } } } @@ -1575,10 +2044,19 @@ async function completePublication( if (!publication || publication.phase !== "switching") { refuse(); } + if ( + state.adoption_receipt_version === 14 && + state.routingHandoff !== "held" + ) { + refuse(); + } + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); const loaded = await readInputs(ctx, publication.generation); await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); + await assertPublicationRoutingStopped(ctx, loaded, state, routingDeadline); const held = await holdDirectory( join(ctx.generationsRoot, publication.generation.id, "originals"), true @@ -1632,13 +2110,27 @@ async function completePublication( await readInputs(transaction, publication.generation); await requireStopped(transaction, loaded.inputs.binding); await recheckDirectories([held]); + let beforeCommit: (() => Promise) | undefined; + if (loaded.inputs.retainedRouting) { + beforeCommit = () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline + ); + } else if (loaded.manifest.adoption_generation_version === 8) { + beforeCommit = async () => { + await readInputs(transaction, publication.generation); + await requireStopped(transaction, loaded.inputs.binding); + await requireActiveCandidate(transaction, installed, loaded.inputs); + }; + } await save( transaction, - { - ...current, - publication: { ...installed, phase: "active" }, - }, - current + { ...current, publication: { ...installed, phase: "active" } }, + current, + beforeCommit ? { beforeCommit } : undefined ); } finally { await held.file.close(); @@ -1649,7 +2141,9 @@ async function completeRollback(ctx: Context, state: Receipt) { if (!publication || publication.phase !== "rolling-back") { refuse(); } - const loaded = await readInputs(ctx, publication.generation); + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); + 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"), @@ -1701,22 +2195,126 @@ 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]); + if (loaded.inputs.retainedRouting) { + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + if (current.routingHandoff !== "releasing") { + current = await save( + transaction, + { ...current, routingHandoff: "releasing" }, + current, + { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + ); + } + const routingGeneration = publication.generation.id; + const claims = await openRetainedRoutingClaims( + transaction, + routingGeneration, + loaded.inputs.binding + ); + try { + await claims.releaseRetained({ + assertStoppedAndRestored: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: routingGeneration, + claim, + handoff: true, + }); + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + }, + }); + } finally { + await claims.close(); + } + } await save( transaction, { ...current, publication: { ...publication, phase: "rolled-back" }, }, - current + current, + loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + : undefined ); } finally { await held.file.close(); } } +/** Retained originals remain present. This proves their stopped state and no + * proxy route; the native ABSENT-only release boundary remains unchanged. */ +async function assertPublicationRoutingStopped( + ctx: Context, + loaded: Awaited>, + state: Receipt, + deadline: number, + restored = false +): Promise { + if (!loaded.inputs.retainedRouting) { + return; + } + if ( + state.pendingOperation !== null || + state.routingOperation?.disposition === "prospective" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } + await assertRetainedRouteState( + ctx, + loaded, + "stopped", + deadline, + async (current) => { + const fresh = await readInputs(current, state.prepared ?? refuse()); + await requireStopped(current, fresh.inputs.binding); + if (restored) { + await requireRestoredRoutingSourceInputs(current, fresh); + } + await requireReceiptSnapshot(current, state); + cancelled(current.signal); + if (Date.now() >= deadline) { + refuse(); + } + return fresh; + } + ); +} + type MutationOptions = Parameters< LegacyComposeAdoptedGenerationStore["withMutation"] >[0]; @@ -1726,8 +2324,10 @@ function requireMutationDeadline( ) { if ( (legacyComposeRetainedOrdered(plan) || + captured.generation.report.adoption_generation_version === 8 || captured.generation.report.adoption_generation_version === 9 || - captured.generation.report.adoption_generation_version === 12) && + captured.generation.report.adoption_generation_version === 12 || + captured.generation.report.adoption_generation_version === 14) && (captured.deadline === undefined || !Number.isFinite(captured.deadline) || captured.deadline <= Date.now()) @@ -1786,10 +2386,12 @@ function preparedReceiptVersion( prior.adoption_receipt_version === 6 || prior.adoption_receipt_version === 9 || prior.adoption_receipt_version === 7 || + prior.adoption_receipt_version === 8 || prior.adoption_receipt_version === 10 || prior.adoption_receipt_version === 11 || prior.adoption_receipt_version === 12 || - prior.adoption_receipt_version === 13 + prior.adoption_receipt_version === 13 || + prior.adoption_receipt_version === 14 ) { return "kind" in checkout.git ? 2 : 1; } @@ -1841,6 +2443,32 @@ async function requirePreparedSourceInputs( } await ctx.check(); } +/** Authenticated restoration preserves the original inode and bytes, while its + * link/unlink transaction changes ctime. Initial preparation remains strict. */ +async function requireRestoredRoutingSourceInputs( + ctx: Context, + loaded: Awaited> +) { + if ( + loaded.manifest.adoption_generation_version !== 14 || + loaded.inputs.retainedRouting !== true + ) { + refuse(); + } + await requireFirstSliceLayout(ctx, loaded.inputs); + if (!(await absent(join(ctx.root, ".hack/hack.project.json")))) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + for (const location of originalLocations( + ctx, + { id: loaded.manifest.id }, + loaded.manifest, + loaded.inputs + )) { + await requireOriginal(ctx, location.active, location); + } + await ctx.check(); +} async function requireMutationInputs( ctx: Context, active: Publication | null, @@ -1863,7 +2491,7 @@ async function mutateRetainedContainers( refuse(); } if ( - ![5, 7, 9, 10, 12].includes( + ![5, 7, 8, 9, 10, 12, 14].includes( captured.generation.report.adoption_generation_version ) ) { @@ -1924,6 +2552,47 @@ function requireRetainedCompletionState(opts: { } } +function retainedMutationCommitFence(opts: { + readonly ctx: Context; + readonly owned: Anchor; + readonly material: "verify" | "saved"; + readonly activePublication: ReturnType; + readonly state: Receipt; + readonly ids: ReadonlySet; + readonly operation: AdoptionOperation; + readonly requiresV7: boolean; + readonly version: Manifest["adoption_generation_version"]; + readonly confirmV7Commit: () => Promise; +}): { readonly beforeCommit: () => Promise } | undefined { + if (opts.requiresV7) { + return { beforeCommit: opts.confirmV7Commit }; + } + if (opts.version !== 8) { + return undefined; + } + return { + beforeCommit: async () => { + const final = await readInputs( + opts.ctx, + opts.owned, + false, + opts.material + ); + await requireMutationInputs(opts.ctx, opts.activePublication, final); + requireRetainedCompletion({ + observed: await inspectLegacyComposeContainerStates({ + binding: final.inputs.binding, + signal: opts.ctx.signal, + timeoutMs: opts.ctx.timeoutMs, + }), + ids: opts.ids, + operation: opts.operation, + }); + await requireReceiptSnapshot(opts.ctx, opts.state); + }, + }; +} + async function mutateRetainedContainersWithinBudget( ctx: Context, known: WeakMap, @@ -1937,227 +2606,400 @@ 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( - (container) => container.service - ); - const selectedServices = captured.services.length - ? captured.services - : services; - const retainedPlan = legacyComposeRetainedPlan( - JSON.parse(loaded.inputs.candidateText) - ); - if ( - (legacyComposeRetainedOrdered(retainedPlan) || - loaded.inputs.retainedBuild || - loaded.inputs.retainedSourceBind) && - JSON.stringify([...selectedServices].sort()) !== - JSON.stringify([...services].sort()) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); - } - validateMutationSelection( - state, - { ...captured, services: selectedServices }, - services - ); - const observed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - observed.some( - (value) => - value.paused || !["created", "running", "exited"].includes(value.status) - ) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + const routing = loaded.inputs.retainedRouting === true; + if (routing && state.routingHandoff !== "held") { + refuse("E_LEGACY_ADOPTION_BUSY"); } - if (!captured.recover) { - requireMutationDeadline(captured, retainedPlan); - state = await save( - ctx, - { - ...state, - pendingOperation: { - generation: owned, - operation: captured.operation, - services: selectedServices, - }, - }, - state + const priorUnknown = + routing && + captured.recover === true && + state.routingOperation?.disposition === "prospective"; + const routeClaims = routing + ? await openRetainedRoutingClaims(ctx, owned.id, loaded.inputs.binding) + : undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + try { + await admitCandidate(ctx, loaded.inputs, captured.binary); + const services = loaded.inputs.binding.containers.map( + (container) => container.service ); - } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - requireMutationDeadline(captured, retainedPlan); - let callbackOpen = true; - const privateInput = privateResult({ - configText: loaded.inputs.configText, - composeText: loaded.inputs.composeText, - candidateText: loaded.inputs.candidateText, - binding: loaded.inputs.binding, - ...(loaded.inputs.projectionMetadata - ? { projectionMetadata: loaded.inputs.projectionMetadata } - : {}), - retainedPlan, - ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), - ...(loaded.inputs.retainedSourceBind - ? { retainedSourceBind: true as const } - : {}), - assertFresh: async () => { + const selectedServices = captured.services.length + ? captured.services + : services; + const retainedPlan = legacyComposeRetainedPlan( + JSON.parse(loaded.inputs.candidateText) + ); + if ( + (legacyComposeRetainedOrdered(retainedPlan) || + loaded.inputs.retainedBuild || + loaded.inputs.retainedSourceBind || + loaded.manifest.adoption_generation_version === 8 || + routing) && + JSON.stringify([...selectedServices].sort()) !== + JSON.stringify([...services].sort()) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + validateMutationSelection( + state, + { ...captured, services: selectedServices }, + services + ); + const observed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + if ( + observed.some( + (value) => + value.paused || + !["created", "running", "exited"].includes(value.status) + ) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + if (!captured.recover) { + requireMutationDeadline(captured, retainedPlan); + routeAttempt = routeClaims + ? await routeClaims.acquire({ + generationIdentity: owned.id, + hostnames: retainedRoutingIntent(loaded.inputs).routes.flatMap( + (route) => route.origins.map((origin) => new URL(origin).hostname) + ), + }) + : undefined; + state = await save( + ctx, + { + ...state, + pendingOperation: { + generation: owned, + operation: captured.operation, + services: selectedServices, + }, + ...(routeAttempt + ? { + routingOperation: { + generation: owned, + token: token(), + reference: routeAttempt.reference, + disposition: "prospective" as const, + code: null, + }, + } + : {}), + }, + state + ); + } else if (routing) { + const previous = state.routingOperation; + if ( + !previous || + JSON.stringify(previous.generation) !== JSON.stringify(owned) || + !routeClaims + ) { + refuse(); + } + routeAttempt = await routeClaims.reopen(previous.reference); + await routeClaims.assertHeld(previous.reference); + if (!priorUnknown) { + state = await save( + ctx, + { + ...state, + routingOperation: { + ...previous, + disposition: "prospective", + code: null, + }, + }, + state + ); + } + } + if (routeClaims && routeAttempt && !captured.recover) { + await routeClaims.markEffectsPossible(routeAttempt); + } + await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + let callbackOpen = true; + const assertActive = () => { if (!callbackOpen) { refuse(); } - const current = await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, current); - await requireReceiptSnapshot(ctx, state); + cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }, - }); - let outcome: LegacyComposeRetainedOutcome; - try { - outcome = await captured.run(privateInput); - } finally { - callbackOpen = false; - } - await ctx.check(); - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - const completed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - const ids = new Set( - loaded.inputs.binding.containers - .filter((container) => selectedServices.includes(container.service)) - .map((container) => container.id) - ); - if (typeof outcome === "number" && outcome !== 0) { - return outcome; - } - const jobAttempts = - retainedPlan.requiresV7 && captured.operation !== "stop" - ? consumeLegacyComposeJobCompletion({ - outcome, - plan: retainedPlan, - binding: privateInput.binding, - operation: captured.operation, - deadline: captured.deadline ?? 0, - assertFresh: privateInput.assertFresh, - }) - : undefined; - if (!jobAttempts && outcome !== 0) { - refuse("E_LEGACY_ADOPTION_CHANGED"); - } - const jobIds = new Set( - retainedPlan.ordered - .filter((item) => item.kind === "job") - .map( - (item) => - loaded.inputs.binding.containers.find( - (container) => container.service === item.service - )?.id - ) - ); - requireRetainedCompletionState({ - completed, - ids, - jobIds, - operation: captured.operation, - }); - const confirmV7Commit = async () => { - await readInputs(ctx, owned); + }; + const privateInput = privateResult({ + configText: loaded.inputs.configText, + composeText: loaded.inputs.composeText, + candidateText: loaded.inputs.candidateText, + binding: loaded.inputs.binding, + ...(loaded.inputs.projectionMetadata + ? { projectionMetadata: loaded.inputs.projectionMetadata } + : {}), + retainedPlan, + ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + ...(loaded.inputs.retainedSourceBind + ? { retainedSourceBind: true as const } + : {}), + assertActive, + assertFresh: async () => { + assertActive(); + const current = await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, current); + await requireReceiptSnapshot(ctx, state); + assertActive(); + }, + }); + let outcome: LegacyComposeRetainedOutcome; + try { + outcome = await captured.run(privateInput); + } finally { + callbackOpen = false; + } + if (routing) { + const code = consumeLegacyComposeRoutingCompletion({ + outcome, + input: privateInput, + operation: captured.operation, + deadline: captured.deadline ?? 0, + }); + if (priorUnknown) { + // A fresh stop cannot establish the disposition of an earlier unknown child. + // No claim, receipt or original resource may be retired from engine absence. + refuse("E_LEGACY_ADOPTION_BUSY"); + } + const pendingRoute = state.routingOperation; + if (!pendingRoute) { + refuse(); + } + state = await save( + ctx, + { + ...state, + routingOperation: { ...pendingRoute, disposition: "settled", code }, + }, + state + ); + outcome = code; + } + await ctx.check(); + await readInputs(ctx, owned, false, material); await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - if (jobAttempts) { - const finalRows = legacyComposeJobStates({ - binding: loaded.inputs.binding, - observed: await inspectLegacyComposeJobStates({ + const completed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + const ids = new Set( + loaded.inputs.binding.containers + .filter((container) => selectedServices.includes(container.service)) + .map((container) => container.id) + ); + if (typeof outcome === "number" && outcome !== 0) { + return outcome; + } + const jobAttempts = + retainedPlan.requiresV7 && captured.operation !== "stop" + ? consumeLegacyComposeJobCompletion({ + outcome, + plan: retainedPlan, + binding: privateInput.binding, + operation: captured.operation, + deadline: captured.deadline ?? 0, + assertFresh: privateInput.assertFresh, + }) + : undefined; + if (!jobAttempts && outcome !== 0) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + const jobIds = new Set( + retainedPlan.ordered + .filter((item) => item.kind === "job") + .map( + (item) => + loaded.inputs.binding.containers.find( + (container) => container.service === item.service + )?.id + ) + ); + requireRetainedCompletionState({ + completed, + ids, + jobIds, + operation: captured.operation, + }); + const confirmV7Commit = async () => { + await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + if (jobAttempts) { + const finalRows = legacyComposeJobStates({ binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }), + observed: await inspectLegacyComposeJobStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }), + }); + const serviceRequired = retainedPlan.ordered + .filter((item) => item.kind !== "job") + .map((item) => ({ + service: item.service, + condition: item.healthy ? ("ready" as const) : ("started" as const), + })); + if ( + jobAttempts.length !== jobIds.size || + jobAttempts.some((attempt) => { + const row = finalRows.find((item) => item.id === attempt.id); + return ( + !(jobIds.has(attempt.id) && row) || + legacyComposeFreshJobResult({ attempt, observed: row }) !== + "ready" + ); + }) || + !legacyComposeRetainedReady({ + plan: retainedPlan, + ids: new Map( + loaded.inputs.binding.containers.map((item) => [ + item.service, + item.id, + ]) + ), + observed: finalRows, + required: serviceRequired, + }) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + } else { + await requireStopped(ctx, loaded.inputs.binding); + } + await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + await ctx.check(); + cancelled(ctx.signal); + requireMutationDeadline(captured, retainedPlan); + }; + if ( + !retainedPlan.requiresV7 && + retainedPlan.requiresV5 && + captured.operation !== "stop" + ) { + const readiness = await inspectLegacyComposeReadiness({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, }); - const serviceRequired = retainedPlan.ordered - .filter((item) => item.kind !== "job") - .map((item) => ({ - service: item.service, - condition: item.healthy ? ("ready" as const) : ("started" as const), - })); if ( - jobAttempts.length !== jobIds.size || - jobAttempts.some((attempt) => { - const row = finalRows.find((item) => item.id === attempt.id); - return ( - !(jobIds.has(attempt.id) && row) || - legacyComposeFreshJobResult({ attempt, observed: row }) !== "ready" - ); - }) || !legacyComposeRetainedReady({ plan: retainedPlan, ids: new Map( - loaded.inputs.binding.containers.map((item) => [ - item.service, - item.id, + loaded.inputs.binding.containers.map((container) => [ + container.service, + container.id, ]) ), - observed: finalRows, - required: serviceRequired, + observed: readiness, }) ) { refuse("E_LEGACY_ADOPTION_CHANGED"); } - } else { - await requireStopped(ctx, loaded.inputs.binding); + await readInputs(ctx, owned, false, material); + await requireMutationInputs(ctx, activePublication, loaded); } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - await ctx.check(); - cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }; + const confirmRoutingCommit = async () => { + await assertRetainedRouteState( + ctx, + loaded, + captured.operation === "stop" ? "stopped" : "active", + captured.deadline ?? 0, + async (current) => { + const fresh = await readInputs(current, owned); + await requireMutationInputs(current, activePublication, fresh); + await requireReceiptSnapshot(current, state); + requireMutationDeadline(captured, retainedPlan); + return fresh; + } + ); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + }; + if (routeClaims && routeAttempt) { + if (captured.recover) { + await routeClaims.recoverRetainedStopped({ + references: [routeAttempt.reference], + assertStopped: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: owned.id, + claim, + }); + await confirmRoutingCommit(); + }, + }); + } else { + await routeClaims.complete({ + attempt: routeAttempt, + assertTransition: confirmRoutingCommit, + }); + } + } + let beforeCommit: (() => Promise) | undefined; + if (routing) { + beforeCommit = confirmRoutingCommit; + } else { + beforeCommit = retainedMutationCommitFence({ + ctx, + owned, + material, + activePublication, + state, + ids, + operation: captured.operation, + requiresV7: retainedPlan.requiresV7 === true, + version: loaded.manifest.adoption_generation_version, + confirmV7Commit, + })?.beforeCommit; + } + await save( + ctx, + { ...state, pendingOperation: null }, + state, + beforeCommit ? { beforeCommit } : undefined + ); + return 0; + } finally { + await routeClaims?.close(); + } +} + +function requireRetainedCompletion(opts: { + readonly observed: readonly LegacyComposeContainerState[]; + readonly ids: ReadonlySet; + readonly operation: AdoptionOperation; +}): void { if ( - !retainedPlan.requiresV7 && - retainedPlan.requiresV5 && - captured.operation !== "stop" + opts.observed.some( + (value) => + opts.ids.has(value.id) && + (value.paused || + value.running !== (opts.operation !== "stop") || + !["created", "running", "exited"].includes(value.status)) + ) ) { - const readiness = await inspectLegacyComposeReadiness({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - !legacyComposeRetainedReady({ - plan: retainedPlan, - ids: new Map( - loaded.inputs.binding.containers.map((container) => [ - container.service, - container.id, - ]) - ), - observed: readiness, - }) - ) { - refuse("E_LEGACY_ADOPTION_CHANGED"); - } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); + refuse("E_LEGACY_ADOPTION_CHANGED"); } - requireMutationDeadline(captured, retainedPlan); - await save( - ctx, - { ...state, pendingOperation: null }, - state, - retainedPlan.requiresV7 ? { beforeCommit: confirmV7Commit } : undefined - ); - return 0; } /** @@ -2198,7 +3040,8 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { mode = input.mode ?? "prepare"; signal = input.signal; const timeoutMs = input.timeoutMs, - capturedRoute = route(); + capturedRoute = route(), + routingRoot = join(resolveGlobalHackDir(), "compose-routing"); cancelled(signal); for (const path of [root, join(root, ".hack")]) { directories.push(await holdDirectory(path, false)); @@ -2260,6 +3103,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { }; const ctx: Context = { root, + routingRoot, checkout, directories, stateRoot, @@ -2355,8 +3199,18 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { prepared: generated, publication: null, pendingOperation: null, + ...(loaded.manifest.adoption_generation_version === 14 + ? { routingOperation: null, routingHandoff: "held" as const } + : {}), }, - prior + prior, + loaded.manifest.adoption_generation_version === 8 + ? { + beforeCommit: async () => { + await readInputs(ctx, generated, true); + }, + } + : undefined ); return claim( generated, @@ -2381,7 +3235,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, @@ -2403,7 +3257,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, @@ -2432,6 +3291,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { refuse(); } const loaded = await readInputs(ctx, owned); + if ( + loaded.inputs.retainedRouting && + state.routingHandoff !== "held" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); @@ -2456,7 +3321,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 = { @@ -2506,6 +3376,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); @@ -2517,7 +3394,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, @@ -2527,7 +3409,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-local.ts b/src/lib/native-compose-adoption-local.ts index 977b81f37..fc9a88118 100644 --- a/src/lib/native-compose-adoption-local.ts +++ b/src/lib/native-compose-adoption-local.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { lstat } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "./guards.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode } from "./native-compose-private-state.ts"; import { NativeConfigCompilerError, @@ -17,6 +18,8 @@ import { parseImportDocument, } from "./native-config-import-parser.ts"; import { freezeImportValue } from "./native-config-import-plan.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; type Source = Extract; type Role = "primary_local" | "checkout_local"; @@ -66,6 +69,7 @@ function check(signal?: AbortSignal) { export function mapLegacyAdoptionLocalInput(opts: { readonly text: string; readonly document: Role; + readonly retainedRouting?: boolean; }) { const parsed = parseImportDocument(opts); const value = parsed.value; @@ -77,14 +81,29 @@ export function mapLegacyAdoptionLocalInput(opts: { value !== undefined && value.schema_version === 1 && Object.keys(value).every( - (key) => key === "schema_version" || key === "environment" + (key) => + key === "schema_version" || + key === "environment" || + (opts.retainedRouting === true && (key === "routes" || key === "open")) ) && (environment === undefined || (isRecord(environment) && Object.keys(environment).every((key) => key === "default_overlay") && (!Object.hasOwn(environment, "default_overlay") || environment.default_overlay === null || - typeof environment.default_overlay === "string"))); + typeof environment.default_overlay === "string"))) && + (!Object.hasOwn(value, "routes") || + (opts.retainedRouting === true && + isRecord(value.routes) && + Object.keys(value.routes).every((key) => key === "domain") && + (!Object.hasOwn(value.routes, "domain") || + typeof value.routes.domain === "string"))) && + (!Object.hasOwn(value, "open") || + (opts.retainedRouting === true && + isRecord(value.open) && + Object.keys(value.open).every((key) => key === "prefer") && + (!Object.hasOwn(value.open, "prefer") || + ["auto", "alias", "dev"].includes(String(value.open.prefer))))); const fields: readonly ImportField[] = parsed.fields.map((field) => ({ ...field, status: valid ? "exact" : "refused", @@ -116,7 +135,9 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { readonly overlay: string | null; readonly binary?: string; readonly signal?: AbortSignal; + readonly routing?: LegacyComposeRoutingIntent; }) { + const routing = opts.routing; check(opts.signal); const checkout = privateNativeConfigImportLocalInput(opts.source); const primary = opts.primary @@ -131,6 +152,7 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { const mapped = mapLegacyAdoptionLocalInput({ text: input.text, document, + retainedRouting: routing !== undefined, }); if (!mapped.complete) { refuseFields(mapped.fields); @@ -139,15 +161,25 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { } } const present = checkout?.text != null || primary?.text != null; - if (present) { + let routingResolution: NativeRoutingResolution | undefined; + if (present || routing) { const resolved = await resolveNativeConfig({ input: new TextEncoder().encode(JSON.stringify(opts.candidate)), primaryLocal: encodedLocal(primary?.text), checkoutLocal: encodedLocal(checkout?.text), binary: opts.binary, signal: opts.signal, + requireRoutingPlanning: routing !== undefined, }); - if (!resolved.ok || resolved.local_resolution.overlay !== opts.overlay) { + if ( + !resolved.ok || + resolved.local_resolution.overlay !== opts.overlay || + (routing && + !legacyComposeRoutingResolutionMatches({ + routing, + resolution: resolved.routing_resolution, + })) + ) { refuseFields( fields.map((field) => ({ ...field, @@ -156,12 +188,16 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { })) ); } + if (resolved.ok) { + routingResolution = resolved.routing_resolution; + } } await opts.source.assertFresh({ signal: opts.signal }); await opts.primary?.assertFresh({ signal: opts.signal }); check(opts.signal); const result = { fields, + routingResolution, proof: present ? { checkout: checkout?.proof ?? null, primary: primary?.proof ?? null } : undefined, @@ -178,6 +214,7 @@ async function assertCheckoutLocal(opts: { readonly projectRoot: string; readonly proof: unknown; readonly signal?: AbortSignal; + readonly retainedRouting?: boolean; }) { const proof = opts.proof; const path = join(opts.projectRoot, ".hack/hack.local.json"); @@ -223,6 +260,7 @@ async function assertCheckoutLocal(opts: { !mapLegacyAdoptionLocalInput({ text: new TextDecoder("utf-8", { fatal: true }).decode(current.bytes), document: "checkout_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); @@ -237,6 +275,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { readonly proof: unknown; readonly signal?: AbortSignal; readonly checkOwner: () => Promise; + readonly retainedRouting?: boolean; }) { check(opts.signal); const proof = opts.proof; @@ -252,6 +291,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { projectRoot: opts.projectRoot, proof: proof.checkout, signal: opts.signal, + retainedRouting: opts.retainedRouting, }); const primary = opts.primary ? privateNativeConfigImportLocalInput(opts.primary) @@ -261,6 +301,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { !mapLegacyAdoptionLocalInput({ text: primary.text, document: "primary_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 4ce313820..cf51243a8 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -10,9 +10,15 @@ import { mapLegacyNativeBranchStorageAdoption, mapLegacyNativeCompletedJobAdoptionBaseline, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedFileAdoptionBaseline, + mapLegacyNativeRetainedRouting, mapLegacyNativeRetainedSourceBind, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { type LegacyComposeSourceBindIntent, type LegacyComposeStorageIntent, @@ -99,12 +105,28 @@ export function planLegacyComposeRetainedBasicBuildAdoption(opts: { return plan(opts, mapLegacyNativeRetainedBasicBuild(opts)); } +/** Separate v14 authored contract; mixed bind/build/job/branch families remain refused. */ +export function planLegacyComposeRetainedRoutingAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedRouting(opts), "routing"); +} + /** Closed static directory-binding family; still requires the separate issued path and engine owner. */ export function planLegacyComposeSourceBindAdoption(opts: { readonly configText: string; readonly composeText: string; }): LegacyComposeAdoptionPlan { - return plan(opts, mapLegacyNativeRetainedSourceBind(opts), true); + return plan(opts, mapLegacyNativeRetainedSourceBind(opts), "source-bind"); +} + +/** Separate proof-bearing retained host-file family; no material or effect authority. */ +export function planLegacyComposeRetainedFileAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedFileAdoptionBaseline(opts)); } function plan( @@ -114,7 +136,7 @@ function plan( readonly selectedComposeProject?: string; }, baseline: ReturnType, - sourceBind = false + family: "ordinary" | "routing" | "source-bind" = "ordinary" ): LegacyComposeAdoptionPlan { const config = parseImportDocument({ text: opts.configText, @@ -124,18 +146,27 @@ function plan( text: opts.composeText, document: "compose", }).value; + const routingFamily = family === "routing"; + const sourceBind = family === "source-bind"; + const routing = routingFamily + ? mapLegacyComposeRouting({ config, compose }) + : undefined; const qualified = sourceBind ? mapLegacyComposeSourceBindStorage({ config, compose }) : mapLegacyComposeStorage({ config, - compose, + compose: + routing && compose ? legacyRoutingStorageDocument(compose) : compose, selectedComposeProject: opts.selectedComposeProject, }); const mapping = { - supported: qualified !== undefined, + supported: + qualified !== undefined && (!routingFamily || routing !== undefined), accepted: qualified?.accepted ?? new Map(), }; - const intent = qualified?.intent; + const intent = qualified + ? { ...qualified.intent, ...(routing ? { routing: routing.intent } : {}) } + : undefined; const fields = storageFields(baseline.report.fields, mapping); const supported = mapping.supported && !fields.some((field) => field.status === "refused"); diff --git a/src/lib/native-compose-adoption-preview.ts b/src/lib/native-compose-adoption-preview.ts index 7166c4206..bdc6154a7 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, @@ -13,6 +14,7 @@ import type { ImportField } from "./native-config-import-parser.ts"; import { freezeImportValue, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedFileStorage, mapLegacyNativeRetainedSourceBind, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -44,7 +46,9 @@ export async function previewLegacyComposeAdoption(input: { const owner = await acquireLegacyComposeAdoptionPreparationBinding(opts), acquired = await owner.resolvePreparationInputs(opts); let mapper = mapLegacyNativeStorageAdoption; - if (acquired.sourceBindProof) { + if (acquired.fileProof) { + mapper = mapLegacyNativeRetainedFileStorage; + } else if (acquired.sourceBindProof) { mapper = mapLegacyNativeRetainedSourceBind; } else if (acquired.build) { mapper = mapLegacyNativeRetainedBasicBuild; @@ -54,7 +58,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-compose-adoption-projection.ts b/src/lib/native-compose-adoption-projection.ts index e61c3eb82..2c12ed55f 100644 --- a/src/lib/native-compose-adoption-projection.ts +++ b/src/lib/native-compose-adoption-projection.ts @@ -17,6 +17,7 @@ import { assertSavedLegacyAdoptionLocalInputs, retainLegacyAdoptionLocalRefusal, } from "./native-compose-adoption-local.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode, holdDirectory, @@ -37,9 +38,12 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; +import { parseNativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import { defaultProjectSlugFromPath } from "./project.ts"; import { acquireProjectEnvForLegacyAdoption, @@ -163,6 +167,23 @@ function primarySourceFactory(localInputs: unknown) { : acquireLegacyAdoptionSourceInputs; } +function projectionKeys(version: unknown): string { + if (version === 3) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version,routingResolution"; + } + if (version === 2) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version"; + } + return "generated,inheritPrimaryLocal,managedRevision,primary,projection_version"; +} +function projectionVersion( + admission: LegacyAdoptionManagedEnvAdmission +): number { + if (admission.routingResolution) { + return 3; + } + return admission.localFields.length ? 2 : 1; +} /** Validate and snapshot the bounded private envelope before any async recheck. */ function savedProjectionEnvelope(value: unknown) { const text = JSON.stringify(value); @@ -174,13 +195,11 @@ function savedProjectionEnvelope(value: unknown) { if ( !( isRecord(proof) && - keys( - proof, - proof.projection_version === 2 - ? "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version" - : "generated,inheritPrimaryLocal,managedRevision,primary,projection_version" - ) && + keys(proof, projectionKeys(proof.projection_version)) && (proof.projection_version === 1 || + (proof.projection_version === 3 && + (proof.localInputs === null || isRecord(proof.localInputs)) && + parseNativeRoutingResolution(proof.routingResolution) !== null) || (proof.projection_version === 2 && isRecord(proof.localInputs))) && typeof proof.managedRevision === "string" && typeof proof.inheritPrimaryLocal === "boolean" && @@ -206,7 +225,15 @@ function savedProjectionEnvelope(value: unknown) { inheritPrimaryLocal: proof.inheritPrimaryLocal, generated: proof.generated, primary, - localInputs: proof.projection_version === 2 ? proof.localInputs : undefined, + localInputs: + proof.projection_version === 2 || + (proof.projection_version === 3 && proof.localInputs !== null) + ? proof.localInputs + : undefined, + routingResolution: + proof.projection_version === 3 + ? parseNativeRoutingResolution(proof.routingResolution) + : undefined, }; } @@ -240,7 +267,11 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const proof = savedProjectionEnvelope(opts.proof); check(signal); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); + const mapped = ( + proof.routingResolution + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )({ configText, composeText }); const candidate = mapped.candidate; if ( !( @@ -251,6 +282,28 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { ) { refuse(); } + const routing = proof.routingResolution + ? mapLegacyComposeRouting({ + config: parseImportDocument({ text: configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if ( + proof.routingResolution && + !( + routing && + legacyComposeRoutingResolutionMatches({ + routing, + resolution: proof.routingResolution, + }) + ) + ) { + refuse(); + } const selection = { projectRoot, overlay: @@ -317,6 +370,7 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { proof: proof.localInputs, signal: current.signal ?? signal, checkOwner, + retainedRouting: routing !== undefined, }); } if ( @@ -365,8 +419,10 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { const runtimeText = buildRuntimeHostMetadataOverride({ composeYamls: [composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }); if (runtime && runtime.text !== runtimeText) { @@ -374,6 +430,9 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const result = { candidate: projectRuntimeFallbacks(candidate, runtime), + ...(proof.routingResolution + ? { routingResolution: proof.routingResolution } + : {}), metadata, composeFiles: [ join(projectRoot, ".hack/docker-compose.yml"), @@ -577,18 +636,25 @@ export class LegacyComposeAdoptionProjection { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { const { source, signal } = opts; + const retainedRouting = opts.retainedRouting === true; const admission = await LegacyAdoptionManagedEnvAdmission.acquire({ source, signal, binary: opts.binary, + retainedRouting, }); if (!source.ok) { refuse(); } - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); const candidate = mapped.candidate; if (!(candidate && legacyComposeAdoptionCandidateSupported(candidate))) { refuse(); @@ -600,6 +666,18 @@ export class LegacyComposeAdoptionProjection { if (!(compose && isRecord(compose.services))) { refuse(); } + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose, + })?.intent + : undefined; + if (retainedRouting && !(routing && admission.routingResolution)) { + refuse(); + } const env = await acquireProjectEnvForLegacyAdoption({ admission }); const generated = await acquireGenerated({ projectRoot: source.projectRoot, @@ -619,8 +697,10 @@ export class LegacyComposeAdoptionProjection { runtimeText: buildRuntimeHostMetadataOverride({ composeYamls: [source.composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }), signal, @@ -640,7 +720,7 @@ export class LegacyComposeAdoptionProjection { } const context = this.#context; const result = { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), status: "acquired", admission: "not_performed", files: [ @@ -717,6 +797,9 @@ export class LegacyComposeAdoptionProjection { refuse(); } const candidate = projectRuntimeFallbacks(context.candidate, runtime); + const primary = privatePrimaryProof( + await context.admission.resolvePrivatePrimaryProof() + ); await this.assertFresh({ signal }); const result = { candidate, @@ -730,12 +813,16 @@ export class LegacyComposeAdoptionProjection { metadata: context.env.metadata, localFields: context.admission.localFields, projectionProof: { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), managedRevision: privateLegacyAdoptionEnvRevision(context.env), - ...privatePrimaryProof( - await context.admission.resolvePrivatePrimaryProof() - ), + ...primary, generated: generatedProof(context.generated), + ...(context.admission.routingResolution + ? { + routingResolution: context.admission.routingResolution, + localInputs: primary.localInputs ?? null, + } + : {}), }, }; for (const [key, value] of Object.entries(result)) { diff --git a/src/lib/native-compose-adoption-receipt.ts b/src/lib/native-compose-adoption-receipt.ts index 8edcc353d..6e1b1f8d4 100644 --- a/src/lib/native-compose-adoption-receipt.ts +++ b/src/lib/native-compose-adoption-receipt.ts @@ -1,6 +1,10 @@ import { isRecord } from "./guards.ts"; import type { LinkedAdoptionGitIdentity } from "./native-compose-adoption-checkout.ts"; import { keys } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteReference, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; const KIND = "legacy-compose-adopted"; const TOKEN = /^[a-f0-9]{32}$/; @@ -25,16 +29,30 @@ export type Receipt = { | 5 | 6 | 7 + | 8 | 9 | 10 | 11 | 12 - | 13; + | 13 + | 14; readonly kind: typeof KIND; readonly checkout: Checkout; readonly prepared: Anchor | null; readonly publication: Publication | null; readonly pendingOperation: PendingOperation | null; + /** Required only by v14. Unknown child disposition survives stop containment. */ + readonly routingOperation?: RetainedRoutingOperation | null; + /** Required only by v14; releasing is an interrupted rollback handoff, + * never active workload or publisher admission. */ + readonly routingHandoff?: "held" | "releasing"; +}; +export type RetainedRoutingOperation = { + readonly generation: Anchor; + readonly token: string; + readonly reference: NativeComposeRouteReference; + readonly disposition: "prospective" | "settled"; + readonly code: number | null; }; export type AdoptionOperation = "start" | "restart" | "stop"; export type PendingOperation = { @@ -87,15 +105,19 @@ export function parseLegacyComposeAdoptionReceipt( isRecord(value) && keys( value, - "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" + value.adoption_receipt_version === 14 + ? "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication,routingHandoff,routingOperation" + : "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" ) && - (value.adoption_receipt_version === 13 || + (value.adoption_receipt_version === 14 || + value.adoption_receipt_version === 13 || value.adoption_receipt_version === 12 || value.adoption_receipt_version === 11 || value.adoption_receipt_version === 10 || value.adoption_receipt_version === 9 || value.adoption_receipt_version === 7 || value.adoption_receipt_version === 6 || + value.adoption_receipt_version === 8 || value.adoption_receipt_version === 5 || value.adoption_receipt_version === 4 || value.adoption_receipt_version === 3 || @@ -110,6 +132,67 @@ export function parseLegacyComposeAdoptionReceipt( ) { refuse(); } + let routingOperation: RetainedRoutingOperation | null | undefined; + if (value.adoption_receipt_version === 14) { + if ( + value.prepared === null || + (value.routingHandoff !== "held" && value.routingHandoff !== "releasing") + ) { + refuse(); + } + if ( + value.routingHandoff === "releasing" && + (value.pendingOperation !== null || + !value.publication || + !["rolling-back", "rolled-back"].includes(value.publication.phase)) + ) { + refuse(); + } + const operation = value.routingOperation; + if (operation === null) { + routingOperation = null; + } else { + if ( + !( + isRecord(operation) && + keys(operation, "code,disposition,generation,reference,token") && + anchor(operation.generation) && + typeof operation.token === "string" && + TOKEN.test(operation.token) && + (operation.disposition === "prospective" + ? operation.code === null + : operation.disposition === "settled" && + Number.isSafeInteger(operation.code) && + typeof operation.code === "number" && + operation.code >= 0 && + operation.code <= 255) && + JSON.stringify(operation.generation) === + JSON.stringify(value.prepared) + ) + ) { + refuse(); + } + routingOperation = { + generation: operation.generation, + token: operation.token, + reference: parseNativeComposeRouteReference(operation.reference), + disposition: + operation.disposition === "prospective" ? "prospective" : "settled", + code: typeof operation.code === "number" ? operation.code : null, + }; + if ( + routingOperation.reference.generationIdentity !== + operation.generation.id || + (routingOperation.disposition === "prospective" && + value.pendingOperation === null) + ) { + refuse(); + } + } + if (value.pendingOperation !== null && routingOperation === null) { + refuse(); + } + } // The distinct job family is issued with a prepared generation, never a bare version upgrade. if ( (value.adoption_receipt_version === 7 || @@ -139,12 +222,14 @@ export function parseLegacyComposeAdoptionReceipt( const version = value.adoption_receipt_version; return { adoption_receipt_version: + version === 14 || version === 13 || version === 12 || version === 11 || version === 10 || version === 9 || version === 7 || + version === 8 || version === 6 || version === 5 || version === 4 || @@ -156,6 +241,15 @@ export function parseLegacyComposeAdoptionReceipt( prepared: value.prepared, publication: value.publication, pendingOperation: value.pendingOperation, + ...(version === 14 + ? { + routingOperation: routingOperation ?? null, + routingHandoff: + value.routingHandoff === "held" + ? ("held" as const) + : ("releasing" as const), + } + : {}), }; } function pendingSelectionMatches( diff --git a/src/lib/native-compose-adoption-routing-execution.ts b/src/lib/native-compose-adoption-routing-execution.ts new file mode 100644 index 000000000..8c9c181ca --- /dev/null +++ b/src/lib/native-compose-adoption-routing-execution.ts @@ -0,0 +1,146 @@ +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import { run } from "./shell.ts"; + +const COMPLETE = Symbol("legacy-retained-routing-effect"); +export type LegacyComposeRoutingCompletion = { readonly [COMPLETE]: true }; +type Input = { + readonly binding: LegacyComposeVerifiedBinding; + readonly assertFresh: () => Promise; + readonly assertActive: () => void; +}; +type Witness = { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly code: number; +}; +const completions = new WeakMap(); +function refuse(): never { + throw new Error( + "Retained routing child disposition is uncertain; values omitted." + ); +} +function check(signal: AbortSignal | undefined, deadline: number): void { + if (signal?.aborted || !Number.isFinite(deadline) || deadline <= Date.now()) { + refuse(); + } +} +function absent(pid: number): boolean { + try { + process.kill(-pid, 0); + return false; + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) { + return true; + } + return refuse(); + } +} + +/** One fixed original-ID child. Completion means known return and fresh group + * absence; it does not infer container readiness or terminate an unknown peer. */ +export async function runLegacyComposeRetainedRoutingOperation(opts: { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly signal?: AbortSignal; +}): Promise { + const input = opts.input; + const binding = input.binding; + const projectRoot = binding.projectRoot; + const assertFresh = input.assertFresh; + const assertActive = input.assertActive; + const operation = opts.operation; + const deadline = opts.deadline; + const signal = opts.signal; + const ids = binding.containers.map((row) => row.id); + if ( + binding.binding_version !== 14 || + typeof assertActive !== "function" || + !ids.length || + new Set(ids).size !== ids.length || + !["start", "restart", "stop"].includes(operation) + ) { + refuse(); + } + check(signal, deadline); + assertActive(); + await assertFresh(); + assertActive(); + check(signal, deadline); + let group: number | undefined; + let observationFailed = false; + const code = await run(["docker", "container", operation, ...ids], { + cwd: projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + // This finite data-free child never needs a controlling-terminal handoff. + signal, + timeoutMs: Math.max(1, deadline - Date.now()), + beforeSpawn: () => { + assertActive(); + check(signal, deadline); + }, + onSpawn: (event) => { + if ( + !(event.ownsProcessGroup && Number.isSafeInteger(event.pid)) || + event.pid <= 1 + ) { + observationFailed = true; + } else { + group = event.pid; + } + return Promise.resolve(); + }, + }); + if ( + observationFailed || + group === undefined || + !Number.isSafeInteger(code) || + code < 0 || + code > 255 + ) { + refuse(); + } + const drainDeadline = Math.min(deadline, Date.now() + 3000); + while (!absent(group)) { + if (Date.now() >= drainDeadline) { + refuse(); + } + await Bun.sleep(Math.min(25, drainDeadline - Date.now())); + } + const completion = Object.freeze({ [COMPLETE]: true as const }); + completions.set(completion, { input, operation, deadline, code }); + return completion; +} + +/** Consumed by the same held generation callback; numeric/structural/replayed + * results cannot clear its durable prospective child disposition. */ +export function consumeLegacyComposeRoutingCompletion(opts: { + readonly outcome: unknown; + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; +}): number { + if (typeof opts.outcome !== "object" || opts.outcome === null) { + refuse(); + } + const witness = completions.get(opts.outcome); + if ( + !witness || + witness.input !== opts.input || + witness.operation !== opts.operation || + witness.deadline !== opts.deadline + ) { + refuse(); + } + completions.delete(opts.outcome); + return witness.code; +} diff --git a/src/lib/native-compose-adoption-routing-resolution.ts b/src/lib/native-compose-adoption-routing-resolution.ts new file mode 100644 index 000000000..e44d4a8b7 --- /dev/null +++ b/src/lib/native-compose-adoption-routing-resolution.ts @@ -0,0 +1,66 @@ +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; + +/** Effective typed-local preference selects among the already served origins. + * It may differ from the raw project preference without changing any route. */ +export function legacyComposeRoutingResolutionMatches(opts: { + readonly routing: LegacyComposeRoutingIntent; + readonly resolution: NativeRoutingResolution | null | undefined; +}): boolean { + const { routing, resolution } = opts; + if ( + !resolution || + resolution.branch !== undefined || + resolution.project_origin !== `https://${routing.devHost}` + ) { + return false; + } + const devOrigin = `https://${routing.devHost}`; + const aliasOrigin = routing.aliasHost ? `https://${routing.aliasHost}` : null; + let openOrigin: string; + if (resolution.open_preference === "dev") { + openOrigin = devOrigin; + } else if (resolution.open_preference === "auto") { + openOrigin = aliasOrigin ?? devOrigin; + } else if (resolution.open_preference === "alias" && aliasOrigin) { + openOrigin = aliasOrigin; + } else { + return false; + } + if (resolution.open_origin !== openOrigin) { + return false; + } + const aliases = routing.aliasHost + ? { oauth: `https://${routing.aliasHost}` } + : {}; + if ( + JSON.stringify(resolution.aliases) !== JSON.stringify(aliases) || + resolution.oauth_alias !== (routing.aliasHost ? "oauth" : null) + ) { + return false; + } + if ( + Object.keys(resolution.routes).sort().join(",") !== + routing.routes + .map((route) => route.service) + .sort() + .join(",") + ) { + return false; + } + return routing.routes.every((route) => { + const found = resolution.routes[route.service]; + const primary = `https://${route.hostname === "project" ? routing.devHost : `${route.hostname}.${routing.devHost}`}`; + const alias = routing.aliasHost + ? `https://${route.hostname === "project" ? routing.aliasHost : `${route.hostname}.${routing.aliasHost}`}` + : null; + return ( + found?.service === route.service && + found.port === route.port && + found.protocol === "http" && + found.origin === primary && + JSON.stringify(found.aliases) === + JSON.stringify(alias ? { oauth: alias } : {}) + ); + }); +} diff --git a/src/lib/native-compose-adoption-routing.ts b/src/lib/native-compose-adoption-routing.ts new file mode 100644 index 000000000..cbd89d0f8 --- /dev/null +++ b/src/lib/native-compose-adoption-routing.ts @@ -0,0 +1,490 @@ +import { isIP } from "node:net"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { + assertBoundNativeComposeProxyRoutes, + assertNativeComposeProxyAccess, + type BoundNativeComposeProxyRoute, +} from "./native-compose-proxy-routes.ts"; +import { NativeComposeRoutingError } from "./native-compose-routing.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; + +const ID = /^[a-f0-9]{64}$/; +const SITE_SEPARATOR = /[\s,]+/; +const CREATED = /^\d{4}-\d\d-\d\dT/; +const CONTAINER = + '{"id":{{json .Id}},"created":{{json .Created}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"native":{{json (index .Config.Labels "io.hack.native-config.owner")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"number":{{json (index .Config.Labels "com.docker.compose.container-number")}},"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy_")) (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy."))}}{"key":{{json $key}},"value":{{json $value}}},{{end}}{{end}}null],"networks":[{{range $key,$value := .NetworkSettings.Networks}}{"name":{{json $key}},"id":{{json $value.NetworkID}},"ip":{{json $value.IPAddress}}},{{end}}null]}'; +const BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const NETWORK_BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const SITES = + '{"id":{{json .Id}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 7) (eq (slice $key 0 6) "caddy_") (eq (len (split $key ".")) 1) (ne $key "caddy_ingress_network"))}}{{json $value}},{{end}}{{end}}null]}'; + +export type LegacyComposeRetainedRoutingProof = { + readonly routing_version: 14; + readonly ingress: NativeComposeIngressBinding; + readonly proxyCreatedAt: string; + readonly networkCreatedAt: string; + readonly originals: readonly { + readonly id: string; + readonly service: string; + readonly createdAt: string; + readonly labels: Readonly>; + }[]; +}; +type Binding = Pick< + LegacyComposeVerifiedBinding, + "engineId" | "composeProject" | "containers" +>; +type Observed = { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly expected: readonly BoundNativeComposeProxyRoute[]; + readonly stopped: boolean; +}; +function refuse(): never { + throw new NativeComposeRoutingError(); +} +function keys(value: Record, wanted: string) { + return Object.keys(value).sort().join(",") === wanted; +} +function rows(text: string): Record[] { + const values: unknown[] = text.trim() + ? text + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (!values.every(isRecord)) { + return refuse(); + } + return values; +} +function birth(text: string, id: string): string { + const values = rows(text), + value = values[0]; + if ( + !( + values.length === 1 && + value && + keys(value, "created,id") && + value.id === id && + typeof value.created === "string" && + CREATED.test(value.created) && + Number.isFinite(Date.parse(value.created)) + ) + ) { + return refuse(); + } + return value.created; +} +function labels(value: unknown): Readonly> { + if (!(Array.isArray(value) && value.at(-1) === null)) { + return refuse(); + } + const result: Record = {}; + for (const pair of value.slice(0, -1)) { + if ( + !( + isRecord(pair) && + keys(pair, "key,value") && + typeof pair.key === "string" && + typeof pair.value === "string" && + !Object.hasOwn(result, pair.key) + ) + ) { + return refuse(); + } + result[pair.key] = pair.value; + } + return Object.freeze( + Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)) + ) + ); +} +function equal(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right); +} +function covers(site: string, hostname: string): boolean { + if (site.startsWith(":")) { + return true; + } + const url = new URL(site.includes("://") ? site : `https://${site}`); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !["http:", "https:"].includes(url.protocol) + ) { + return refuse(); + } + return ( + url.hostname === "*" || + url.hostname === hostname || + (url.hostname.startsWith("*.") && hostname.endsWith(url.hostname.slice(1))) + ); +} +/** Foreign existing site writers, including stopped containers, cannot inherit a retained ID exception. */ +async function inventory( + probe: ReturnType, + binding: Binding, + routing: LegacyComposeRoutingIntent +) { + const hosts = routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ); + const owned = new Set( + binding.containers + .filter((item) => + routing.routes.some((route) => route.service === item.service) + ) + .map((item) => item.id) + ); + for (let pass = 0; pass < 2; pass++) { + const selected = rows( + await probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--format", + '{"id":{{json .ID}}}', + ]) + ); + const ids = selected.map((row) => { + if (!(keys(row, "id") && typeof row.id === "string" && ID.test(row.id))) { + return refuse(); + } + return row.id; + }); + if ( + new Set(ids).size !== ids.length || + [...owned].some((id) => !ids.includes(id)) + ) { + return refuse(); + } + for (let offset = 0; offset < ids.length; offset += 64) { + const batch = ids.slice(offset, offset + 64); + const observed = rows( + await probe(["container", "inspect", "--format", SITES, ...batch]) + ); + const seen = new Set(); + if (observed.length !== batch.length) { + return refuse(); + } + for (const row of observed) { + if ( + !( + keys(row, "id,sites") && + typeof row.id === "string" && + batch.includes(row.id) && + !seen.has(row.id) && + Array.isArray(row.sites) && + row.sites.at(-1) === null && + row.sites.slice(0, -1).every((site) => typeof site === "string") + ) + ) { + return refuse(); + } + seen.add(row.id); + if ( + !owned.has(row.id) && + row.sites.slice(0, -1).some((site: string) => + site + .split(SITE_SEPARATOR) + .filter(Boolean) + .some((value) => + hosts.some((hostname) => covers(value, hostname)) + ) + ) + ) { + return refuse(); + } + } + } + } +} +function snapshot(binding: Binding, routing: LegacyComposeRoutingIntent) { + if ( + routing.version !== 14 || + !binding.containers.length || + new Set(binding.containers.map((row) => row.id)).size !== + binding.containers.length || + binding.containers.some((row) => !ID.test(row.id)) + ) { + return refuse(); + } + const selectedBinding = Object.freeze({ + ...binding, + containers: Object.freeze( + binding.containers.map((item) => Object.freeze({ ...item })) + ), + }); + const selectedRouting = Object.freeze({ + ...routing, + routes: Object.freeze( + routing.routes.map((route) => + Object.freeze({ + ...route, + origins: Object.freeze([...route.origins]), + labels: Object.freeze({ ...route.labels }), + }) + ) + ), + }); + return { binding: selectedBinding, routing: selectedRouting }; +} +async function observe(opts: { + readonly binding: Binding; + readonly routing: LegacyComposeRoutingIntent; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + const { binding, routing } = snapshot(opts.binding, opts.routing); + const signal = opts.signal; + const probe = createNativeComposeProbe({ signal, timeoutMs: opts.timeoutMs }); + const ingress = await observeNativeComposeIngress({ signal }); + if (ingress.engineId !== binding.engineId) { + return refuse(); + } + const proxyCreatedAt = birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ); + const networkCreatedAt = birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ); + const originals: LegacyComposeRetainedRoutingProof["originals"][number][] = + []; + const expected: BoundNativeComposeProxyRoute[] = []; + let stopped = true; + for (const original of binding.containers) { + const found = rows( + await probe(["container", "inspect", "--format", CONTAINER, original.id]) + ); + const row = found[0]; + if ( + !( + found.length === 1 && + row && + keys( + row, + "created,id,native,networks,number,oneoff,paused,project,running,service,sites" + ) && + row.id === original.id && + row.project === binding.composeProject && + (row.native === null || row.native === "") && + row.service === original.service && + row.number === "1" && + (row.oneoff === "False" || row.oneoff === "false") && + typeof row.running === "boolean" && + row.paused === false && + typeof row.created === "string" && + CREATED.test(row.created) && + Number.isFinite(Date.parse(row.created)) && + Array.isArray(row.networks) && + row.networks.at(-1) === null + ) + ) { + return refuse(); + } + const route = routing.routes.find( + (item) => item.service === original.service + ); + const observedLabels = labels(row.sites); + const wantedLabels = Object.fromEntries( + Object.entries(route?.labels ?? {}).sort(([a], [b]) => a.localeCompare(b)) + ); + if (!equal(observedLabels, wantedLabels)) { + return refuse(); + } + const configured = row.networks.slice(0, -1); + const names = new Set(); + for (const item of configured) { + if ( + !( + isRecord(item) && + keys(item, "id,ip,name") && + typeof item.name === "string" && + !names.has(item.name) && + typeof item.id === "string" && + ID.test(item.id) && + typeof item.ip === "string" + ) + ) { + return refuse(); + } + names.add(item.name); + } + if ( + !( + configured.length === (route ? 2 : 1) && + names.has(`${binding.composeProject}_default`) && + names.has(DEFAULT_INGRESS_NETWORK) === Boolean(route) + ) + ) { + return refuse(); + } + const attached = configured.find( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + if (route && !(isRecord(attached) && attached.id === ingress.networkId)) { + return refuse(); + } + originals.push({ + id: original.id, + service: original.service, + createdAt: row.created, + labels: observedLabels, + }); + stopped &&= !row.running; + if (route && row.running) { + if ( + !( + isRecord(attached) && + typeof attached.ip === "string" && + isIP(attached.ip) === 4 + ) + ) { + return refuse(); + } + expected.push({ + service: route.service, + port: route.port, + protocol: "http", + origins: route.origins, + hostnames: route.origins.map((origin) => new URL(origin).hostname), + dials: [`${attached.ip}:${route.port}`], + }); + } + } + await inventory(probe, binding, routing); + await observeNativeComposeIngress({ expected: ingress, signal }); + if ( + birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ) !== proxyCreatedAt || + birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ) !== networkCreatedAt + ) { + return refuse(); + } + return { + proof: Object.freeze({ + routing_version: 14, + ingress, + proxyCreatedAt, + networkCreatedAt, + originals: Object.freeze( + originals.sort((a, b) => a.service.localeCompare(b.service)) + ), + }), + expected, + stopped, + }; +} +/** Snapshot exact original labels/births and shared ingress. This performs no reservation, effect or repair. */ +export async function inspectLegacyComposeRetainedRouting( + opts: Parameters[0] +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const first = await observe(selected); + await assertNativeComposeProxyAccess({ + binding: first.proof.ingress, + signal: selected.signal, + }); + const second = await observe(selected); + if (!equal(first.proof, second.proof)) { + return refuse(); + } + return first.proof; +} +/** Exact live/stopped dispatch under the saved owner. No hostname, ID or upstream is fabricated. */ +export async function assertLegacyComposeRetainedRoutingState( + opts: Parameters[0] & { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly phase: "active" | "stopped"; + readonly deadline: number; + readonly assertOwner: () => Promise; + } +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const expectedProof = structuredClone(opts.proof), + phase = opts.phase, + deadline = opts.deadline, + assertOwner = opts.assertOwner; + if ( + !["active", "stopped"].includes(phase) || + typeof assertOwner !== "function" || + !Number.isFinite(deadline) || + deadline <= Date.now() + ) { + return refuse(); + } + const remaining = AbortSignal.timeout(Math.ceil(deadline - Date.now())); + const signal = selected.signal + ? AbortSignal.any([selected.signal, remaining]) + : remaining; + const observeExpected = async () => { + await assertOwner(); + const current = await observe({ ...selected, signal }); + if ( + !equal(current.proof, expectedProof) || + (phase === "active" + ? current.expected.length !== selected.routing.routes.length + : !current.stopped) + ) { + return refuse(); + } + await assertOwner(); + return phase === "active" ? current.expected : []; + }; + await observeExpected(); + await assertBoundNativeComposeProxyRoutes({ + binding: expectedProof.ingress, + observeExpected, + absentHostnames: + phase === "stopped" + ? selected.routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + : [], + signal, + deadline, + }); + await observeExpected(); + if (signal.aborted || Date.now() >= deadline) { + return refuse(); + } +} diff --git a/src/lib/native-compose-adoption-runtime.ts b/src/lib/native-compose-adoption-runtime.ts index b35d20f86..e8187885f 100644 --- a/src/lib/native-compose-adoption-runtime.ts +++ b/src/lib/native-compose-adoption-runtime.ts @@ -67,7 +67,8 @@ export async function inspectLegacyComposeRuntimeConfig(opts: { ...(opts.binding.binding_version === 2 || opts.binding.binding_version === 4 || opts.binding.binding_version === 6 || - opts.binding.binding_version === 13 + opts.binding.binding_version === 13 || + opts.binding.binding_version === 14 ? opts.binding.composeFiles.slice(1) : []), ]; diff --git a/src/lib/native-compose-engine-identity.ts b/src/lib/native-compose-engine-identity.ts index 06e710b81..6682491f4 100644 --- a/src/lib/native-compose-engine-identity.ts +++ b/src/lib/native-compose-engine-identity.ts @@ -115,6 +115,38 @@ function bindPath(path: string, kind: "socket" | "executable"): Binding { function sameBinding(left: Binding, right: Binding): boolean { return JSON.stringify(left) === JSON.stringify(right); } +/** + * Pure filesystem binding only: this neither admits an engine nor performs a + * probe. Reuses the transport owner's bounded component walk because Bun's + * async realpath rejects a Unix-socket leaf on macOS. All named ancestor/link + * and final socket identity facts are rechecked by assertFresh. + */ +export function bindNativeComposeEngineSocketPath(path: string): { + readonly path: string; + readonly assertFresh: () => void; +} { + try { + requireValue( + typeof path === "string" && + !AMBIGUOUS_PATH.test(path) && + normalize(path) === path + ); + const bound = bindPath(path, "socket"); + return Object.freeze({ + path: bound.canonical, + assertFresh: () => { + try { + requireValue(sameBinding(bound, bindPath(path, "socket"))); + } catch { + refuse(); + } + }, + }); + } catch { + return refuse(); + } +} + function check(signal: AbortSignal | undefined, deadline: number): void { requireValue(!signal?.aborted && Date.now() < deadline); } diff --git a/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-open.ts b/src/lib/native-compose-open.ts index 1d49de7d8..025dd44ba 100644 --- a/src/lib/native-compose-open.ts +++ b/src/lib/native-compose-open.ts @@ -1,5 +1,7 @@ import { CliUsageError } from "../cli/command.ts"; import { HackCliError } from "./cli-result.ts"; +import { selectLegacyComposeAdoptedRoot } from "./native-compose-adoption-command.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "./native-compose-adoption-generation.ts"; import { openNativeComposeGenerationStore } from "./native-compose-generation.ts"; import { readNativeComposeRouteMetadata } from "./native-compose-route-owner.ts"; import { @@ -25,6 +27,16 @@ function unsupported(): never { }); } +function invalidSavedRouting(error: unknown): never { + if (error instanceof HackCliError || error instanceof CliUsageError) { + throw error; + } + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Saved native routing selection is invalid; values omitted.", + }); +} + function preferredOrigin(opts: { readonly origin: string; readonly alias?: string; @@ -90,12 +102,51 @@ export function resolveNativeComposeOpenOrigin(opts: { /** * Select native inputs before legacy context/registration. Read the immutable saved - * routing report under its generation lease; never compile, decrypt, observe Docker, - * or invent a host from current authored input merely to answer `open`. + * routing report under its generation lease. Retained routing also rechecks its + * original resource owner; neither path compiles, decrypts or invents a host. */ export async function tryNativeComposeOpen( - opts: NativeComposeOpenOptions + input: NativeComposeOpenOptions ): Promise { + const opts = { ...input }; + const adopted = await selectLegacyComposeAdoptedRoot(opts); + if (adopted) { + requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND }); + if (opts.instance !== undefined) { + return unsupported(); + } + try { + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot: adopted, + mode: "saved", + }); + try { + const generation = await store.loadActive(); + if (!generation) { + return unsupported(); + } + return await store.withLease({ + generation, + run: (input) => { + if (!(input.retainedRouting && input.routingResolution)) { + return unsupported(); + } + return Promise.resolve( + resolveNativeComposeOpenOrigin({ + resolution: input.routingResolution, + target: opts.target, + prefer: opts.prefer, + }) + ); + }, + }); + } finally { + await store.close(); + } + } catch (error: unknown) { + return invalidSavedRouting(error); + } + } const selected = await selectNativeComposeProject(opts); if (!selected) { return null; @@ -142,12 +193,6 @@ export async function tryNativeComposeOpen( await store.close(); } } catch (error: unknown) { - if (error instanceof HackCliError || error instanceof CliUsageError) { - throw error; - } - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: "Saved native routing selection is invalid; values omitted.", - }); + return invalidSavedRouting(error); } } diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index fe852b0c2..5535d30ea 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -155,6 +155,60 @@ export type NativeComposeProbeFailure = | "decode"; const probeFailures = new WeakMap(); +/** The first refused ownership predicate only; never a claim about its external cause. */ +export type NativeComposeOwnershipRefusal = + | "resource-label" + | "generation" + | "state" + | "volume-birth" + | "bridge-policy" + | "topology" + | "endpoint" + | "cross-scan-drift" + | "unknown"; +const TOPOLOGY_PREDICATES = [ + "network-members-shape", + "network-member-id", + "member-container-endpoint", + "workload-policy", + "workload-endpoint-keyset", + "created-endpoint-membership", + "live-endpoint-membership", +] as const; +export type NativeComposeTopologyPredicate = + (typeof TOPOLOGY_PREDICATES)[number]; +const ownershipRefusals = new WeakMap< + object, + Readonly<{ + reason: NativeComposeOwnershipRefusal; + topologyPredicate: NativeComposeTopologyPredicate | null; + }> +>(); + +/** Only captured-reply replay reads this diagnostic; callers cannot mint it from error properties. */ +export function nativeComposeOwnershipRefusal( + error: unknown +): NativeComposeOwnershipRefusal | undefined { + return typeof error === "object" && error !== null + ? ownershipRefusals.get(error)?.reason + : undefined; +} + +/** Exact first topology predicate from this owner, without reading untrusted error fields. */ +export function nativeComposeTopologyPredicate( + error: unknown +): NativeComposeTopologyPredicate | undefined { + return typeof error === "object" && error !== null + ? (ownershipRefusals.get(error)?.topologyPredicate ?? undefined) + : undefined; +} + +export function isNativeComposeTopologyPredicate( + value: unknown +): value is NativeComposeTopologyPredicate { + return TOPOLOGY_PREDICATES.some((predicate) => predicate === value); +} + /** Copies, prototypes and caller-created errors cannot acquire an observed probe classification. */ export function nativeComposeProbeFailure( error: unknown @@ -196,12 +250,22 @@ export class NativeComposeOwnershipError extends Error { this.code = code; } } -function refuse(code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP"): never { - throw new NativeComposeOwnershipError(code); +function refuse( + code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP", + reason: NativeComposeOwnershipRefusal = "unknown", + topologyPredicate: NativeComposeTopologyPredicate | null = null +): never { + const error = new NativeComposeOwnershipError(code); + ownershipRefusals.set(error, Object.freeze({ reason, topologyPredicate })); + throw error; } -function requireValue(value: unknown): asserts value { +function requireValue( + value: unknown, + reason: NativeComposeOwnershipRefusal = "unknown", + topologyPredicate: NativeComposeTopologyPredicate | null = null +): asserts value { if (!value) { - refuse(); + refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason, topologyPredicate); } } @@ -229,7 +293,7 @@ export function mergeNativeComposeNetworkPolicies(opts: { next.internal === previous.internal ) ) { - return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION"); + return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION", "bridge-policy"); } } } @@ -484,7 +548,8 @@ async function queryNativeComposeOwned( current.storage === volume.storage && (volume.createdAt === undefined || current.createdAt === volume.createdAt) - ) + ), + "volume-birth" ); } } @@ -522,21 +587,25 @@ async function queryNativeComposeOwned( first.name === second.name && first.generationId === second.generationId && first.service === second.service && - first.oneoff === second.oneoff + first.oneoff === second.oneoff, + "cross-scan-drift" ); } requireValue( JSON.stringify(topologySnapshot(observations, restarting)) === - JSON.stringify(topologySnapshot(rechecked, restarting)) + JSON.stringify(topologySnapshot(rechecked, restarting)), + "cross-scan-drift" ); requireValue( JSON.stringify(volumeSnapshot(observations)) === - JSON.stringify(volumeSnapshot(rechecked)) + JSON.stringify(volumeSnapshot(rechecked)), + "cross-scan-drift" ); for (const kind of ["container", "volume", "network"] as const) { requireValue( JSON.stringify(await inventory(kind)) === - JSON.stringify(selected.get(kind)) + JSON.stringify(selected.get(kind)), + "cross-scan-drift" ); } return { @@ -594,19 +663,22 @@ async function collectInspections(input: { remaining.delete(row.id); requireValue( row.name === - (kind === "container" ? `/${resource.name}` : resource.name) + (kind === "container" ? `/${resource.name}` : resource.name), + "resource-label" ); requireValue( - row.project === opts.composeProject && row.project === resource.project + row.project === opts.composeProject && row.project === resource.project, + "resource-label" ); requireValue( row.version === "1" && row.instance === opts.runtimeIdentity && - row.owner === opts.ownerToken + row.owner === opts.ownerToken, + "resource-label" ); if (kind === "container") { containers.push(containerObservation(row, opts)); - requireValue(isRecord(row.networks)); + requireValue(isRecord(row.networks), "endpoint"); observations.endpoints.set(resource.id, row.networks); } else if (kind === "volume") { requireValue( @@ -622,18 +694,25 @@ async function collectInspections(input: { ]) ); requireValue( - typeof row.storage === "string" && SERVICE.test(row.storage) + typeof row.storage === "string" && SERVICE.test(row.storage), + "resource-label" ); const expectedVolume = opts.expectedVolumes?.find( (volume) => volume.name === resource.name ); requireValue( - expectedVolume !== undefined && expectedVolume.storage === row.storage + expectedVolume !== undefined && + expectedVolume.storage === row.storage, + "resource-label" + ); + requireValue( + nativeComposeVolumeCreatedAt(row.createdAt), + "volume-birth" ); - requireValue(nativeComposeVolumeCreatedAt(row.createdAt)); requireValue( expectedVolume.createdAt === undefined || - expectedVolume.createdAt === row.createdAt + expectedVolume.createdAt === row.createdAt, + "volume-birth" ); volumes.push({ name: resource.name, @@ -660,11 +739,20 @@ async function collectInspections(input: { requireValue( expected !== undefined && row.driver === expected.driver && - row.internal === expected.internal + row.internal === expected.internal, + "bridge-policy" + ); + requireValue( + isRecord(row.containers), + "topology", + "network-members-shape" ); - requireValue(isRecord(row.containers)); const members = Object.keys(row.containers); - requireValue(members.every((id) => ID.test(id))); + requireValue( + members.every((id) => ID.test(id)), + "topology", + "network-member-id" + ); observations.members.set(resource.name, members.sort()); networks.push({ id: resource.id, name: resource.name }); } @@ -695,17 +783,20 @@ function containerObservation( ); requireValue( typeof row.generation === "string" && - opts.generationIds.includes(row.generation) + opts.generationIds.includes(row.generation), + "generation" ); requireValue( typeof row.service === "string" && - opts.expectedServices.includes(row.service) + opts.expectedServices.includes(row.service), + "resource-label" ); requireValue( row.oneoff === "True" || row.oneoff === "False" || row.oneoff === "true" || - row.oneoff === "false" + row.oneoff === "false", + "resource-label" ); requireValue( row.state === "created" || @@ -714,18 +805,21 @@ function containerObservation( row.state === "removing" || row.state === "paused" || row.state === "exited" || - row.state === "dead" + row.state === "dead", + "state" ); requireValue( typeof row.exitCode === "number" && Number.isSafeInteger(row.exitCode) && - row.exitCode >= 0 + row.exitCode >= 0, + "state" ); requireValue( row.health === null || row.health === "starting" || row.health === "healthy" || - row.health === "unhealthy" + row.health === "unhealthy", + "state" ); requireValue(typeof row.id === "string" && ID.test(row.id)); requireValue(typeof row.name === "string"); @@ -745,7 +839,8 @@ function endpointAliases(value: unknown): readonly string[] { requireValue( Array.isArray(value) && value.every((alias) => typeof alias === "string" && NAME.test(alias)) && - new Set(value).size === value.length + new Set(value).size === value.length, + "endpoint" ); return [...new Set(value as string[])].sort(); } @@ -767,7 +862,8 @@ function validateEndpointIdentity(opts: { if (unrealizedCreatedEndpoint) { requireValue( id !== undefined && - (endpoint.NetworkID === undefined || endpoint.NetworkID === "") + (endpoint.NetworkID === undefined || endpoint.NetworkID === ""), + "endpoint" ); if ( endpoint.Aliases === undefined || @@ -777,16 +873,20 @@ function validateEndpointIdentity(opts: { return; } } else if (absentOwnedRecovery) { - requireValue(endpoint.NetworkID === undefined || endpoint.NetworkID === ""); + requireValue( + endpoint.NetworkID === undefined || endpoint.NetworkID === "", + "endpoint" + ); if (endpoint.Aliases === undefined || endpoint.Aliases === null) { return; } } else { - requireValue(id !== undefined && endpoint.NetworkID === id); + requireValue(id !== undefined && endpoint.NetworkID === id, "endpoint"); } requireValue( JSON.stringify(endpointAliases(endpoint.Aliases)) === - JSON.stringify(expectedAliases) + JSON.stringify(expectedAliases), + "endpoint" ); } @@ -869,7 +969,9 @@ function requireLiveMember(opts: { networkPolicies(selection).length === 1 && networkPolicies(selection)[0]?.internal === false && networkId !== undefined && - endpoint.NetworkID === networkId + endpoint.NetworkID === networkId, + "topology", + "live-endpoint-membership" ); return true; } @@ -894,7 +996,9 @@ function validateTopology( (id) => containers.has(id) && Object.hasOwn(observations.endpoints.get(id) ?? {}, name) - ) + ), + "topology", + "member-container-endpoint" ); } for (const container of observations.containers) { @@ -903,18 +1007,20 @@ function validateTopology( workload.generationId === container.generationId && workload.service === container.service ); - requireValue(policy !== undefined); + requireValue(policy !== undefined, "topology", "workload-policy"); const endpoints = observations.endpoints.get(container.id); requireValue( endpoints !== undefined && hasKeys( endpoints, policy.networks.map((network) => network.name) - ) + ), + "topology", + "workload-endpoint-keyset" ); for (const attachment of policy.networks) { const endpoint = endpoints[attachment.name]; - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); const id = attachment.externalId ?? owned.get(attachment.name); const absentOwnedRecovery = opts.recovery === "down" && @@ -930,7 +1036,9 @@ function validateTopology( }); if (unrealizedCreatedEndpoint) { requireValue( - !observations.members.get(attachment.name)?.includes(container.id) + !observations.members.get(attachment.name)?.includes(container.id), + "topology", + "created-endpoint-membership" ); } const expected = container.oneoff @@ -984,7 +1092,7 @@ function topologySnapshot( id, Object.entries(endpoints) .map(([name, endpoint]) => { - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); return [ name, endpoint.NetworkID, diff --git a/src/lib/native-compose-proxy-routes.ts b/src/lib/native-compose-proxy-routes.ts index cac5a1c51..7633b531c 100644 --- a/src/lib/native-compose-proxy-routes.ts +++ b/src/lib/native-compose-proxy-routes.ts @@ -39,6 +39,9 @@ type HostScope = { type ExpectedRoute = NativeComposeProxyRoute & { readonly dials: readonly string[]; }; + +/** Read-only route projection type; supplying it grants no resource or effect authority. */ +export type BoundNativeComposeProxyRoute = ExpectedRoute; const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; async function readActiveProxy(opts: { readonly binding: NativeComposeIngressBinding; @@ -712,3 +715,92 @@ export async function assertNativeComposeProxyRoutes(opts: { refused(); } } + +/** + * Read-only dispatch/TLS check for a separately issued retained-resource owner. + * The callback must obtain current exact original IDs, labels and ingress IPs + * under its source/receipt lease. It is reread after each successful proxy proof; + * no native owner label or caller-provided old IP substitutes for that owner. + */ +export async function assertBoundNativeComposeProxyRoutes(opts: { + readonly binding: NativeComposeIngressBinding; + readonly observeExpected: () => Promise< + readonly BoundNativeComposeProxyRoute[] + >; + readonly absentHostnames: readonly string[]; + readonly signal?: AbortSignal; + readonly deadline: number; +}): Promise { + try { + const binding = Object.freeze({ ...opts.binding }); + const observeExpected = opts.observeExpected; + const absentHostnames = Object.freeze([...opts.absentHostnames]); + const deadline = opts.deadline; + const remaining = deadline - Date.now(); + if ( + !Number.isFinite(remaining) || + remaining <= 0 || + typeof observeExpected !== "function" + ) { + return refused(); + } + const bounded = AbortSignal.timeout(Math.ceil(remaining)); + const signal = opts.signal + ? AbortSignal.any([opts.signal, bounded]) + : bounded; + const readExpected = async () => { + const value = await observeExpected(); + return value.map((route) => + Object.freeze({ + ...route, + hostnames: Object.freeze([...route.hostnames]), + origins: Object.freeze([...route.origins]), + dials: Object.freeze([...route.dials]), + }) + ); + }; + const readActive = async ( + expected: readonly BoundNativeComposeProxyRoute[] + ) => { + const selected = { binding, signal }; + const servers = await readActiveProxy(selected); + const projection = { servers, expected, absentHostnames }; + if (!projectedRoutesMatch(projection, true)) { + return false; + } + const origins = automaticHttpsOrigins(servers, expected); + if (origins.length) { + try { + await verifyAutomaticHttps({ ...selected, origins }); + } catch { + return false; + } + } + return projectedRoutesMatch( + { ...projection, servers: await readActiveProxy(selected) }, + true + ); + }; + while (!signal.aborted && Date.now() < deadline) { + await observeNativeComposeIngress({ expected: binding, signal }); + const expected = await readExpected(); + if (await readActive(expected)) { + const after = await readExpected(); + if (JSON.stringify(expected) !== JSON.stringify(after)) { + return refused(); + } + await observeNativeComposeIngress({ expected: binding, signal }); + if (await readActive(after)) { + if (signal.aborted || Date.now() >= deadline) { + return refused(); + } + return; + } + } + await Bun.sleep(Math.min(500, Math.max(0, deadline - Date.now()))); + } + refused(); + } catch { + refused(); + } +} diff --git a/src/lib/native-compose-route-claims.ts b/src/lib/native-compose-route-claims.ts index b7c1448dc..0c8cbd6ad 100644 --- a/src/lib/native-compose-route-claims.ts +++ b/src/lib/native-compose-route-claims.ts @@ -257,6 +257,16 @@ function snapshotReference( }), }); } + +/** Strict private reference decoder shared with retained-generation receipts. */ +export function parseNativeComposeRouteReference( + value: unknown +): NativeComposeRouteReference { + if (!referenceValid(value)) { + refuse(); + } + return snapshotReference(value); +} function freezeAttempt( attempt: NativeComposeRouteAttempt ): NativeComposeRouteAttempt { @@ -448,6 +458,8 @@ export type NativeComposeRouteClaims = { reopen( reference: NativeComposeRouteReference ): Promise; + /** Read-only: the referenced original claim tokens and inodes remain active. */ + assertHeld(reference: NativeComposeRouteReference): Promise; /** Synchronize uncertain intent before invoking any engine child/effect. */ markEffectsPossible(attempt: NativeComposeRouteAttempt): Promise; /** Only the live armed attempt can complete; reopened uncertainty cannot. */ @@ -490,6 +502,22 @@ export type NativeComposeRouteClaims = { NativeComposeRouteClaims["release"] >[0]["assertAbsent"]; }): Promise; + /** Retained legacy owner only: exact original containers remain stopped. The + * saved generation must prove known child settlement and route absence. This + * marks referenced uncertainty stopped while keeping every claim held. */ + recoverRetainedStopped(opts: { + readonly references: readonly NativeComposeRouteReference[]; + readonly assertStopped: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; + /** Retained rollback handoff only, after exact stopped-original, known-child, + * route-absence and restored-source proofs. Uncertain journals still veto. */ + releaseRetained(opts: { + readonly assertStoppedAndRestored: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; close(): Promise; }; @@ -1058,6 +1086,7 @@ export async function openNativeComposeRouteClaims(opts: { }; const retire = async (options: { readonly verifyOnly?: boolean; + readonly keepClaims?: boolean; readonly keepHostnames?: readonly string[]; readonly references?: readonly NativeComposeRouteReference[]; readonly assertAbsent: Parameters< @@ -1068,6 +1097,7 @@ export async function openNativeComposeRouteClaims(opts: { const assertAbsent = options.assertAbsent; const references = options.references?.map(snapshotReference); const verifyOnly = options.verifyOnly === true; + const keepClaims = options.keepClaims === true; if (typeof assertAbsent !== "function") { refuse(); } @@ -1112,6 +1142,9 @@ export async function openNativeComposeRouteClaims(opts: { ); } } + if (keepClaims) { + return; + } await publish(join(releasesRoot, `${hash(token())}.json`), { version: 1, binding, @@ -1191,6 +1224,28 @@ export async function openNativeComposeRouteClaims(opts: { await activeEntries(records); return result; }), + assertHeld: (reference) => { + const snapshot = snapshotReference(reference); + return guard(async () => { + const records = await journals(); + const record = find(records, snapshot); + const current = await activeEntries(records); + if ( + record.aborted || + record.intent.claims.some((claim) => { + const expected = record.entries?.find((entry) => + equal(entry.claim, claim) + ); + return !( + expected && equal(current.entries.get(claim.hostname), expected) + ); + }) + ) { + refuse(); + } + await check(); + }); + }, markEffectsPossible: (attempt) => guard(async () => { const { reference } = capability(attempt); @@ -1278,6 +1333,17 @@ export async function openNativeComposeRouteClaims(opts: { assertAbsent: options.assertAbsent, }) ), + recoverRetainedStopped: (options) => { + const references = options.references.map(snapshotReference); + const assertAbsent = options.assertStopped; + return guard(() => + retire({ references, assertAbsent, keepClaims: true }) + ); + }, + releaseRetained: (options) => { + const assertAbsent = options.assertStoppedAndRestored; + return guard(() => retire({ assertAbsent })); + }, close: async () => { if (!closed) { closed = true; diff --git a/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 60ba1e863..8d6259226 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -8,6 +8,7 @@ import { mapLegacyComposeBuild } from "./native-config-import-build.ts"; import { mapLegacyComposeFileDeclaration, mapLegacyComposeFileGrant, + type RetainedFilePermissionPolicy, } from "./native-config-import-files.ts"; import { legacyComposeJobNames, @@ -26,6 +27,10 @@ import { mapLegacyComposeDependencies, mapLegacyComposeHealthcheck, } from "./native-config-import-readiness.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { mapLegacyComposeSourceBindStorage, mapLegacyComposeStorage, @@ -47,6 +52,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)) { @@ -149,9 +165,12 @@ type NativeImportPurpose = | "adoption-baseline" | "completed-job-adoption" | "retained-basic-build" + | "retained-routing" | "retained-source-bind" | "storage-adoption" - | "branch-storage-adoption"; + | "branch-storage-adoption" + | "retained-file-baseline" + | "retained-file-storage"; /** * Closed, pure conversion. Every raw field starts refused until explicitly mapped. @@ -198,6 +217,16 @@ function mapLegacyNativeInput(opts: { } } const context = { config: config.value, candidate, mark, refuse }; + const routing = + opts.purpose === "retained-routing" + ? mapLegacyComposeRouting({ + config: config.value, + compose: compose.value, + }) + : undefined; + if (opts.purpose === "retained-routing" && !routing) { + refuse("config", "/dev_host", "explicit_retained_routing_required"); + } mapOverlay(context); mapWorktree(context); if (opts.purpose === "branch-storage-adoption") { @@ -220,18 +249,41 @@ function mapLegacyNativeInput(opts: { jobPreview: opts.purpose !== "adoption-baseline" && opts.purpose !== "retained-source-bind" && - opts.purpose !== "retained-basic-build", - }); - mapOwnedNetwork({ - project: name, - compose: compose.value, - candidate, - mark, - refuse, - purpose: opts.purpose, + opts.purpose !== "retained-basic-build" && + opts.purpose !== "retained-routing" && + opts.purpose !== "retained-file-baseline" && + opts.purpose !== "retained-file-storage", }); + if (routing) { + Object.assign(candidate, routing.candidate); + for (const pointer of routing.pointers) { + mark( + pointer.document, + pointer.source, + pointer.target, + "existing_routing_binding", + true + ); + } + } else { + mapOwnedNetwork({ + project: name, + compose: compose.value, + candidate, + mark, + refuse, + purpose: opts.purpose, + }); + } if (opts.purpose === "preview") { - mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); + mapFileCandidate({ + compose: compose.value, + candidate, + mark, + refuse, + retainedPermissions: false, + policies: [], + }); mapSourceBindCandidate({ ...context, compose: compose.value, @@ -248,15 +300,39 @@ function mapLegacyNativeInput(opts: { if ( opts.purpose === "storage-adoption" || opts.purpose === "branch-storage-adoption" || - opts.purpose === "retained-basic-build" + opts.purpose === "retained-basic-build" || + opts.purpose === "retained-routing" || + opts.purpose === "retained-file-storage" ) { mapStorageCandidate({ config: config.value, + compose: routing + ? legacyRoutingStorageDocument(compose.value) + : compose.value, + candidate, + mark, + refuse, + }); + } + if ( + opts.purpose === "retained-file-baseline" || + opts.purpose === "retained-file-storage" + ) { + const policies: RetainedFilePermissionPolicy[] = []; + mapFileCandidate({ compose: compose.value, candidate, mark, refuse, + retainedPermissions: true, + policies, }); + policies.sort( + (a, b) => + a.service.localeCompare(b.service) || a.target.localeCompare(b.target) + ); + freezeImportValue(policies); + retainedFilePolicies.set(candidate, policies); } return nativeImportResult({ fields, candidate }); } @@ -282,6 +358,13 @@ function mapOwnedNetwork( opts.refuse("compose", mapping.pointer, mapping.code); return; } + if ( + opts.purpose === "retained-file-baseline" || + opts.purpose === "retained-file-storage" + ) { + opts.refuse("compose", "/networks", "retained_file_network_unsupported"); + return; + } if ( opts.purpose === "retained-basic-build" || opts.purpose === "retained-source-bind" @@ -438,7 +521,19 @@ export function mapLegacyNativeRetainedBasicBuild(opts: { }); } -/** Private closed directory-plus-named-storage candidate; no path creation or ownership grant. */ +/** Literal legacy origins only. A complete map grants no ingress or retained resource authority. */ +export function mapLegacyNativeRetainedRouting(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-routing", + }); +} + +/** Private directory-plus-named-storage map; no path creation or ownership grant. */ export function mapLegacyNativeRetainedSourceBind(opts: { readonly configText: string; readonly composeText: string; @@ -608,7 +703,37 @@ function mapSourceBindCandidate( } } -type FileMappingContext = Pick; +/** 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< + MappingContext, + "candidate" | "mark" | "refuse" +> & { + readonly retainedPermissions: boolean; + readonly policies: RetainedFilePermissionPolicy[]; +}; function mapFileDeclarations( opts: FileMappingContext & { @@ -658,6 +783,7 @@ function mapFileGrantEntries( readonly pointer: string; readonly targetPointer: string; readonly mounts: unknown[]; + readonly serviceName: string; } ): void { if (!Array.isArray(opts.grants)) { @@ -672,13 +798,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", @@ -720,6 +860,7 @@ function mapServiceFileGrants( pointer: importPointer(servicePointer, namespace), targetPointer, mounts, + serviceName: opts.name, }); } } diff --git a/src/lib/native-config-import-routing.ts b/src/lib/native-config-import-routing.ts new file mode 100644 index 000000000..3faf89873 --- /dev/null +++ b/src/lib/native-config-import-routing.ts @@ -0,0 +1,349 @@ +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import { importPointer } from "./native-config-import-parser.ts"; +import { resolveProjectOauthAliasHost } from "./project.ts"; + +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const UPSTREAM = /^\{\{upstreams (?:http )?([1-9][0-9]{0,4})\}\}$/; +const ROUTE_KEYS = [ + "caddy", + "caddy.reverse_proxy", + "caddy.tls", + "caddy_ingress_network", +]; + +export type LegacyComposeRoutingIntent = { + readonly version: 14; + readonly devHost: string; + readonly aliasHost: string | null; + readonly openPreference: "auto" | "alias" | "dev"; + readonly openOrigin: string; + readonly routes: readonly { + readonly service: string; + readonly hostname: string; + readonly port: number; + readonly origins: readonly string[]; + readonly labels: Readonly>; + }[]; +}; +type Pointer = { + readonly document: "config" | "compose"; + readonly source: string; + readonly target: string; +}; + +function exact( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} +function host(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 253 && + value.includes(".") && + value.split(".").every((part) => LABEL.test(part)) + ); +} +function labels(value: unknown): Readonly> | undefined { + const result: Record = Object.create(null); + let entries: unknown[][] = []; + if (isRecord(value)) { + entries = Object.entries(value); + } else if (Array.isArray(value)) { + entries = value.map((item) => + typeof item === "string" && item.includes("=") + ? [item.slice(0, item.indexOf("=")), item.slice(item.indexOf("=") + 1)] + : [] + ); + } + if (!entries.length) { + return undefined; + } + for (const entry of entries) { + const [key, raw] = entry; + if ( + typeof key !== "string" || + typeof raw !== "string" || + !ROUTE_KEYS.includes(key) || + Object.hasOwn(result, key) + ) { + return undefined; + } + result[key] = raw; + } + return result; +} +function attached(value: unknown, routed: boolean): boolean { + if (value === undefined) { + return !routed; + } + const expected = routed + ? [DEFAULT_INGRESS_NETWORK, "default"].sort() + : ["default"]; + return ( + Array.isArray(value) && + value.every((item) => typeof item === "string") && + JSON.stringify([...value].sort()) === JSON.stringify(expected) + ); +} +function network(source: unknown): boolean { + if ( + !(isRecord(source) && exact(source, [DEFAULT_INGRESS_NETWORK, "default"])) + ) { + return false; + } + const ingress = source[DEFAULT_INGRESS_NETWORK]; + const local = source.default; + return ( + isRecord(ingress) && + exact(ingress, ["external"]) && + ingress.external === true && + (!Object.hasOwn(source, "default") || + local === null || + (isRecord(local) && Object.keys(local).length === 0)) + ); +} + +/** Closed static route conversion. Literal origins preserve the original host even + * when it differs from the Compose name. This grants no ingress/resource authority. */ +export function mapLegacyComposeRouting(opts: { + readonly config: Record | undefined; + readonly compose: Record | undefined; +}): + | { + readonly intent: LegacyComposeRoutingIntent; + readonly candidate: Readonly>; + readonly pointers: readonly Pointer[]; + } + | undefined { + const { config, compose } = opts; + if ( + !( + config && + compose && + host(config.dev_host) && + isRecord(compose.services) && + network(compose.networks) + ) + ) { + return undefined; + } + const devHost = config.dev_host; + let aliasHost: string | null = null; + if (Object.hasOwn(config, "oauth")) { + if ( + !( + isRecord(config.oauth) && + exact(config.oauth, ["enabled", "tld"]) && + (!Object.hasOwn(config.oauth, "enabled") || + typeof config.oauth.enabled === "boolean") && + (!Object.hasOwn(config.oauth, "tld") || + (typeof config.oauth.tld === "string" && + LABEL.test(config.oauth.tld))) + ) + ) { + return undefined; + } + aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { + ...(config.oauth.enabled === true ? { enabled: true } : {}), + ...(typeof config.oauth.tld === "string" + ? { tld: config.oauth.tld } + : {}), + }, + }); + if (aliasHost !== null && !host(aliasHost)) { + return undefined; + } + } + let openPreference: "auto" | "alias" | "dev" = "auto"; + if (Object.hasOwn(config, "open")) { + if (!(isRecord(config.open) && exact(config.open, ["prefer"]))) { + return undefined; + } + if (Object.hasOwn(config.open, "prefer")) { + const value = config.open.prefer; + if (value !== "auto" && value !== "alias" && value !== "dev") { + return undefined; + } + openPreference = value; + } + } + if (openPreference === "alias" && aliasHost === null) { + return undefined; + } + const routes: LegacyComposeRoutingIntent["routes"][number][] = []; + const pointers: Pointer[] = [ + { document: "config", source: "/dev_host", target: "/routes/origin" }, + { document: "compose", source: "/networks", target: "/existing_ingress" }, + ]; + if (Object.hasOwn(config, "oauth")) { + pointers.push({ + document: "config", + source: "/oauth", + target: "/routes/aliases", + }); + } + if (Object.hasOwn(config, "open")) { + pointers.push({ document: "config", source: "/open", target: "/open" }); + } + const occupied = new Set(); + for (const [service, raw] of Object.entries(compose.services).sort( + ([a], [b]) => a.localeCompare(b) + )) { + if ( + !(isRecord(raw) && LABEL.test(service)) || + [ + "build", + "profiles", + "depends_on", + "healthcheck", + "ports", + "deploy", + "configs", + "secrets", + ].some((key) => Object.hasOwn(raw, key)) + ) { + return undefined; + } + const selected = Object.hasOwn(raw, "labels") + ? labels(raw.labels) + : undefined; + if (Object.hasOwn(raw, "labels") && !selected) { + return undefined; + } + if (!attached(raw.networks, selected !== undefined)) { + return undefined; + } + const servicePointer = importPointer("/services", service); + if (Object.hasOwn(raw, "networks")) { + pointers.push({ + document: "compose", + source: `${servicePointer}/networks`, + target: "/existing_ingress/attachments", + }); + } + if (!selected) { + continue; + } + const sites = selected.caddy?.split(",").map((site) => site.trim()); + const upstream = + typeof selected["caddy.reverse_proxy"] === "string" + ? UPSTREAM.exec(selected["caddy.reverse_proxy"]) + : null; + const port = Number(upstream?.[1]); + if ( + !( + sites?.length && + sites.every(host) && + new Set(sites).size === sites.length && + upstream && + port <= 65_535 && + selected["caddy.tls"] === "internal" && + (!Object.hasOwn(selected, "caddy_ingress_network") || + selected.caddy_ingress_network === DEFAULT_INGRESS_NETWORK) + ) + ) { + return undefined; + } + const primary = sites.find( + (site) => site === devHost || site.endsWith(`.${devHost}`) + ); + if (!primary) { + return undefined; + } + const prefix = + primary === devHost ? "" : primary.slice(0, -(devHost.length + 1)); + // The compiler reserves "project" as its apex sentinel, not a literal prefix. + if (prefix === "project" || (prefix !== "" && !LABEL.test(prefix))) { + return undefined; + } + const aliases = aliasHost + ? [prefix ? `${prefix}.${aliasHost}` : aliasHost] + : []; + if ( + JSON.stringify([...sites].sort()) !== + JSON.stringify([primary, ...aliases].sort()) || + sites.some((site) => occupied.has(site)) + ) { + return undefined; + } + for (const site of sites) { + occupied.add(site); + } + routes.push({ + service, + hostname: prefix || "project", + port, + origins: sites.map((site) => `https://${site}`).sort(), + labels: Object.freeze({ ...selected }), + }); + pointers.push({ + document: "compose", + source: `${servicePointer}/labels`, + target: `/routes/http/${service}`, + }); + } + if (!routes.some((route) => route.hostname === "project")) { + return undefined; + } + const openOrigin = `https://${openPreference !== "dev" && aliasHost ? aliasHost : devHost}`; + return { + intent: Object.freeze({ + version: 14, + devHost, + aliasHost, + openPreference, + openOrigin, + routes: Object.freeze(routes), + }), + candidate: Object.freeze({ + routes: { + origin: `https://${devHost}`, + ...(aliasHost + ? { + aliases: { oauth: { origin: `https://${aliasHost}` } }, + oauth_alias: "oauth", + } + : {}), + http: Object.fromEntries( + routes.map((route) => [ + route.service, + { + service: route.service, + port: route.port, + hostname: route.hostname, + }, + ]) + ), + }, + open: { prefer: openPreference }, + }), + pointers: Object.freeze(pointers), + }; +} + +/** Storage admission remains the existing default-bridge/named-volume contract; + * the separately verified routing owner owns the removed shared attachment. */ +export function legacyRoutingStorageDocument( + compose: Record +): Record { + const result: Record = { ...compose }; + Reflect.deleteProperty(result, "networks"); + if (isRecord(compose.services)) { + result.services = Object.fromEntries( + Object.entries(compose.services).map(([name, value]) => { + if (!isRecord(value)) { + return [name, value]; + } + const service = { ...value }; + Reflect.deleteProperty(service, "networks"); + return [name, service]; + }) + ); + } + return result; +} diff --git a/src/lib/native-config-import-storage.ts b/src/lib/native-config-import-storage.ts index 868889183..7e714cefb 100644 --- a/src/lib/native-config-import-storage.ts +++ b/src/lib/native-config-import-storage.ts @@ -10,6 +10,7 @@ import { mapLegacyOwnedNetwork, } from "./native-config-import-network.ts"; import { importPointer } from "./native-config-import-parser.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SELECTED_PROJECT = /^[a-z0-9]+(?:-+[a-z0-9]+)*$/; @@ -21,6 +22,8 @@ export type LegacyComposeStorageIntent = { readonly services: readonly string[]; readonly ownedNetwork?: LegacyOwnedNetworkIntent; readonly ownedNetworks?: LegacyOwnedNetworksIntent; + /** Required only by the distinct private retained-routing owner. */ + readonly routing?: LegacyComposeRoutingIntent; readonly volumes: readonly { readonly storage: string; readonly name: string; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 89b89ccb6..796668347 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -181,6 +181,50 @@ HACK_E2E_KEEP=1 HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_ These maintained scenarios are prepared source, not evidence of a successful live run. Source/whole/CI and compiled synthetic engine acceptance remain separate. +`native-config-protected-files` is a separate explicit selector for synthetic +0444 configs and protected 0400/0600 file secrets. It requires kept fixture roots, +the exact current compiled CLI/compiler and source hashes, an explicitly pinned +Compose plugin, cached Bun/PostgreSQL images and an inherited exact-source +`HACK_E2E_PROTECTED_FILES_SLOT_RELEASED` grant. A private launcher must supply those +pins after source and artifact qualification; the selector does not manufacture +the grant or use caller Docker credentials. It publishes no ports and performs no +pulls, builds, DNS changes or trust changes. + +Retained fixture inputs use only the closed file8 source subset: no worktree +policy or authored pull policy. Cached image selection is fenced independently, +and the controlled Compose bootstrap keeps its fixed `--pull never` option. +The pre-effect mode control first refuses an explicit 0444 grant on its owned +0400 source, then changes only the declaration to 0400 and must reach the +rejecting, never-forwarding Docker shim. Canonical mapper and shim controls cover +this prerequisite separately from guest/runtime acceptance. + +This selector runs only retained adoption. Ordinary new-material delivery uses +`native-config-vm-files`; no host snapshot-version oracle is applied here. +Two real linked retained checkouts bind their original readonly sources, observed guest UID/GID and +0400/0600 mode, original container/bridge/volume identities and distinct SQL +markers. Guest checks compare exact synthetic bytes through private stdin, prove +access by the observed owner, EACCES for a different non-root UID, ENOENT in an +ungranted workload and EROFS for an explicitly privileged write. No original file +is chmodded; only newly created synthetic files receive their initial modes. + +A closed Docker forwarder refuses the exact whole-ID start only after the real +pending receipt is durable. Source material is then withheld while shipping saved +down recovery and rollback settle the original resources. This finite interrupted +operation control does not claim guest cancellation after arbitrary parent death. +An earlier active-source control withholds only the synthetic material directory: +restart and exec must refuse with the exact state diagnostic before effects or +journal changes, then restored source incarnation must permit normal exec. +Each SQL marker is seeded once, reads survive restart, and stopping/rolling back +one checkout preserves its sibling. Saved stop/rollback precedes exact non-force +removal: every original ID, creation birth, label, mount, policy and endpoint is +rechecked; volumes also require zero consumers. Final inventory compares the +original daemon, stopped containers, networks, volume births, image IDs and tags. +Unknown effects or cleanup failures retain private captures and fail the selector. + +This selector is maintained acceptance source, with live guest/adoption evidence +still required. It does not replace the existing version-1/0444 managed-file +fixtures or admit file/build/job/health/custom-network intersections. + ## Native config process-policy qualification `native-config-process-policy` is registered in required Docker CI. It needs the @@ -404,3 +448,9 @@ and retained; there is no general cache prune or cache reclamation claim. Failed exact cleanup retains the private fixture and recovery identities. Host DNS/trust, registry network denial and advanced builder features are separate gates. + +The protected-file selector admits Darwin's exact root-owned `/usr/bin/git` with +positive shared system link count, while every private artifact and alternate Git +selection remains single-link. It pins and rechecks that exact link count, owner +IDs, physical path, mode, inode and bytes throughout the attempt. This prerequisite +does not add engine authority or prove guest/lifecycle acceptance. diff --git a/tests/e2e/native-file-permission-command.ts b/tests/e2e/native-file-permission-command.ts new file mode 100644 index 000000000..2541dad14 --- /dev/null +++ b/tests/e2e/native-file-permission-command.ts @@ -0,0 +1,172 @@ +import { + lstat, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; +import { captureCompletedJobFixtureCommand } from "./scenarios/native-compose-adoption-job-worktrees.ts"; + +const LIMIT = 256 * 1024; +function refuse(): never { + throw new Error( + "Owned file fixture command did not settle within its contract; values omitted." + ); +} +export type NativeFileFixtureCommandResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; +type NativeFileFixtureCommandOptions = { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + readonly stdin?: Uint8Array; + readonly capturePrefix?: string; + readonly onUnconfirmed?: () => void; +}; +function captureCommandInputs(opts: NativeFileFixtureCommandOptions) { + // Freeze selection before the first await. In particular, the validated + // capture parent must never authorize a later caller-supplied output path. + const privateInput = opts.stdin; + const captured = { + argv: [...opts.argv], + cwd: opts.cwd, + env: { ...opts.env }, + timeoutMs: opts.timeoutMs, + signal: opts.signal, + stdin: privateInput === undefined ? undefined : Buffer.from(privateInput), + capturePrefix: opts.capturePrefix, + onUnconfirmed: opts.onUnconfirmed, + }; + const binary = captured.argv[0]; + if ( + !( + binary && + isAbsolute(binary) && + isAbsolute(captured.cwd) && + Number.isSafeInteger(captured.timeoutMs) && + captured.timeoutMs > 0 && + captured.timeoutMs <= 90_000 && + !captured.signal?.aborted && + (captured.stdin === undefined || captured.stdin.byteLength <= 8192) + ) + ) { + refuse(); + } + return captured; +} +/** Reuses the maintained finite exit/EOF/group-absence owner; unknown settlement retains the fixture. */ +export async function runNativeFileFixtureCommand( + opts: NativeFileFixtureCommandOptions +): Promise { + const captured = captureCommandInputs(opts); + const deadline = Date.now() + captured.timeoutMs; + const temporary = + captured.capturePrefix === undefined + ? await realpath( + await mkdtemp(resolve(tmpdir(), "protected-file-command-")) + ) + : undefined; + const prefix = captured.capturePrefix ?? resolve(temporary!, "capture"); + const captureDirectory = dirname(prefix); + let captureIdentity: Awaited> | undefined; + if (captureDirectory !== undefined) { + if ( + !isAbsolute(prefix) || + resolve(prefix) !== prefix || + !/^[a-z0-9-]+$/.test(basename(prefix)) || + (await realpath(captureDirectory)) !== captureDirectory + ) { + refuse(); + } + captureIdentity = await lstat(captureDirectory); + if ( + !captureIdentity.isDirectory() || + captureIdentity.isSymbolicLink() || + captureIdentity.uid !== process.getuid?.() || + (captureIdentity.mode & 0o777) !== 0o700 + ) { + refuse(); + } + } + const checkCapture = async () => { + if (captureDirectory === undefined || captureIdentity === undefined) { + return; + } + const current = await lstat(captureDirectory); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== captureIdentity.dev || + current.ino !== captureIdentity.ino || + current.uid !== captureIdentity.uid || + current.mode !== captureIdentity.mode || + (await realpath(captureDirectory)) !== captureDirectory + ) { + refuse(); + } + }; + let unconfirmed = false; + const onUnconfirmed = () => { + unconfirmed = true; + captured.onUnconfirmed?.(); + }; + try { + await checkCapture(); + const remaining = deadline - Date.now(); + if (captured.signal?.aborted || remaining <= 0) { + refuse(); + } + const result = await captureCompletedJobFixtureCommand({ + argv: captured.argv, + cwd: captured.cwd, + env: captured.env, + stdin: captured.stdin, + signal: captured.signal, + captures: prefix, + timeoutMs: remaining, + maxStreamBytes: LIMIT, + onUnconfirmed, + }); + await checkCapture(); + if (captured.signal?.aborted || Date.now() >= deadline) { + refuse(); + } + const output = await readFile(`${prefix}.stdout`); + const diagnostics = await readFile(`${prefix}.stderr`); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const stdout = decoder.decode(output), + stderr = decoder.decode(diagnostics); + await writeFile( + `${prefix}.json`, + JSON.stringify({ + exitCode: result.exitCode, + outputBytes: output.byteLength, + diagnosticBytes: diagnostics.byteLength, + settled: true, + stopRequested: false, + aborted: false, + }), + { mode: 0o600, flag: "wx" } + ); + await checkCapture(); + if (captured.signal?.aborted || Date.now() >= deadline) { + refuse(); + } + return { exitCode: result.exitCode, stdout, stderr }; + } catch { + return refuse(); + } finally { + if (temporary !== undefined && !unconfirmed) { + await checkCapture(); + await rm(temporary, { recursive: true }); + } + } +} diff --git a/tests/e2e/native-file-permission-control.ts b/tests/e2e/native-file-permission-control.ts new file mode 100644 index 000000000..d8241ab4b --- /dev/null +++ b/tests/e2e/native-file-permission-control.ts @@ -0,0 +1,234 @@ +import { isRecord } from "../../src/lib/guards.ts"; +import { + type Checkout, + parseLegacyComposeAdoptionReceipt, +} from "../../src/lib/native-compose-adoption-receipt.ts"; +import { keys } from "../../src/lib/native-compose-private-state.ts"; +import { adoptionDependencyReadAllowed } from "./scenarios/native-compose-adoption-dependency-inputs.ts"; + +function fixtureIdentity(value: unknown): boolean { + return ( + isRecord(value) && + Number.isSafeInteger(value.dev) && + typeof value.dev === "number" && + value.dev >= 0 && + Number.isSafeInteger(value.ino) && + typeof value.ino === "number" && + value.ino > 0 + ); +} +function fixtureSource(value: unknown): boolean { + return ( + isRecord(value) && + keys(value, "dev,hash,ino") && + fixtureIdentity(value) && + typeof value.hash === "string" && + /^[a-f0-9]{64}$/.test(value.hash) + ); +} +function fixtureCheckout(value: unknown): value is Checkout { + const directory = (entry: unknown) => + isRecord(entry) && keys(entry, "dev,ino") && fixtureIdentity(entry); + if ( + !( + isRecord(value) && + keys(value, "git,project,root") && + directory(value.root) && + directory(value.project) + ) + ) { + return false; + } + const git = value.git; + return ( + directory(git) || + (isRecord(git) && + keys(git, "admin,backlink,common,commonLink,kind,marker,primary") && + git.kind === "linked-worktree" && + directory(git.admin) && + directory(git.common) && + directory(git.primary) && + fixtureSource(git.marker) && + fixtureSource(git.backlink) && + fixtureSource(git.commonLink)) + ); +} +function cleanupRefused(): never { + throw new Error( + "Protected fixture cleanup selection refused; values omitted." + ); +} +function cleanupDownArgs(value: unknown): readonly string[] { + if (!(isRecord(value) && fixtureCheckout(value.checkout))) { + return cleanupRefused(); + } + let receipt: ReturnType; + try { + receipt = parseLegacyComposeAdoptionReceipt(value, value.checkout); + } catch { + return cleanupRefused(); + } + if ( + !( + receipt.adoption_receipt_version === 8 && + receipt.prepared !== null && + receipt.publication?.phase === "active" + ) + ) { + return cleanupRefused(); + } + if (receipt.pendingOperation === null) { + return Object.freeze(["down", "--json"]); + } + const pending = receipt.pendingOperation; + if ( + !( + pending.services.length === 3 && + [...pending.services].sort().join() === "db,reader,ungranted" + ) + ) { + return cleanupRefused(); + } + return Object.freeze(["down", "--recover", "--json"]); +} + +/** Select only the fixture's validated active file8 receipt and whole pending + * selection. The production CLI still revalidates all receipt/effect authority. */ +export async function nativeProtectedFileCleanupDown(opts: { + readonly receipt: unknown; + readonly invoke: (args: readonly string[]) => Promise; +}): Promise { + const invoke = opts.invoke; + const args = cleanupDownArgs(opts.receipt); + return await invoke(args); +} + +/** Current material admission has a fixed redacted generation-state diagnostic. */ +export function nativeProtectedFileStateRefused(value: unknown): boolean { + if ( + !(isRecord(value) && Object.hasOwn(value, "ok")) || + value.ok !== false || + !Object.hasOwn(value, "error") || + !isRecord(value.error) + ) { + return false; + } + const error = value.error; + return ( + Object.hasOwn(error, "code") && + error.code === "E_CONFIG_INVALID" && + Object.hasOwn(error, "message") && + error.message === + "Legacy adoption generation state is invalid, unsafe or changed; values omitted." + ); +} + +/** Extend the existing closed metadata/config-hash owner only for fixed file proof queries. */ +export function nativeProtectedFileReadAllowed(opts: { + readonly args: readonly string[]; + readonly projectRoot: string; + readonly project: string; + readonly containerIds: readonly string[]; + readonly networkId: string; + readonly volumeName: string; + readonly generationId: string; + readonly reader: string; + readonly targets: readonly string[]; +}): boolean { + if (adoptionDependencyReadAllowed(opts)) { + return true; + } + const target = opts.args.at(-1); + if ( + typeof target !== "string" || + !opts.targets.includes(target) || + opts.args[0] !== "exec" || + opts.args[1] !== opts.reader || + !opts.containerIds.includes(opts.reader) + ) { + return false; + } + return ( + JSON.stringify(opts.args) === + JSON.stringify([ + "exec", + opts.reader, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + target, + ]) || + JSON.stringify(opts.args) === + JSON.stringify(["exec", opts.reader, "sha256sum", "--", target]) + ); +} +/** A refusal marker proves the original whole-ID start reached its real pending journal; it does no engine effect. */ +export function nativeProtectedFileStartAllowed(opts: { + readonly args: readonly string[]; + readonly ids: readonly string[]; + readonly prepared: unknown; + readonly receipt: unknown; +}): boolean { + const row = opts.receipt, + pending = isRecord(row) ? row.pendingOperation : undefined, + publication = isRecord(row) ? row.publication : undefined; + return ( + opts.ids.length === 3 && + new Set(opts.ids).size === 3 && + opts.ids.every((id) => /^[a-f0-9]{64}$/.test(id)) && + opts.args[0] === "container" && + opts.args[1] === "start" && + opts.args.length === 5 && + new Set(opts.args.slice(2)).size === 3 && + opts.args.slice(2).every((id) => opts.ids.includes(id)) && + isRecord(row) && + row.adoption_receipt_version === 8 && + JSON.stringify(row.prepared) === JSON.stringify(opts.prepared) && + isRecord(publication) && + publication.phase === "active" && + JSON.stringify(publication.generation) === JSON.stringify(opts.prepared) && + isRecord(pending) && + pending.operation === "start" && + JSON.stringify(pending.generation) === JSON.stringify(opts.prepared) && + Array.isArray(pending.services) && + pending.services.length === 3 && + [...pending.services].sort().join() === "db,reader,ungranted" + ); +} + +/** Only Darwin's canonical root-owned OS Git may have shared system hard links. Private copies remain single-link. */ +export function nativeProtectedFileToolAllowed(opts: { + readonly role: "artifact" | "git"; + readonly platform: string; + readonly selected: string; + readonly physical: string; + readonly regular: boolean; + readonly symlink: boolean; + readonly uid: number; + readonly mode: number; + readonly nlink: number; +}): boolean { + if ( + !( + opts.regular && + !opts.symlink && + (opts.mode & 0o111) !== 0 && + Number.isSafeInteger(opts.nlink) && + opts.nlink > 0 + ) + ) { + return false; + } + if (opts.nlink === 1) { + return true; + } + return ( + opts.role === "git" && + opts.platform === "darwin" && + opts.selected === "/usr/bin/git" && + opts.physical === "/usr/bin/git" && + opts.uid === 0 && + (opts.mode & 0o022) === 0 + ); +} diff --git a/tests/e2e/native-file-permission-guest.ts b/tests/e2e/native-file-permission-guest.ts new file mode 100644 index 000000000..003ef6292 --- /dev/null +++ b/tests/e2e/native-file-permission-guest.ts @@ -0,0 +1,160 @@ +import { isRecord } from "../../src/lib/guards.ts"; +import type { NativeComposeFileMode } from "../../src/lib/native-compose-file-permissions.ts"; + +export type NativeFileFixtureGrant = { + readonly target: string; + readonly mode: NativeComposeFileMode; + readonly bytes: readonly number[]; +}; +export type NativeFileFixtureGuest = { + readonly uid: number; + readonly gid: number; + readonly members: readonly { + readonly target: string; + readonly mode: NativeComposeFileMode; + readonly uid: number; + readonly gid: number; + }[]; +}; + +/** Private stdin carries only fixture-authored bytes; stdout exposes permission facts, never content. */ +export const nativeFileFixtureReadProgram = ` +import {lstat,readFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(31); + const rows=JSON.parse(new TextDecoder().decode(raw)); + if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(32); + const members=[]; + for(const row of rows){ + if(typeof row.target!=="string"||!row.target.startsWith("/")||!["0444","0400","0600"].includes(row.mode)||!Array.isArray(row.bytes))process.exit(33); + const before=await lstat(row.target); + const actual=new Uint8Array(await readFile(row.target)); + const after=await lstat(row.target); + if(!before.isFile()||before.isSymbolicLink()||(before.mode&4095)!==Number.parseInt(row.mode,8)||JSON.stringify(Array.from(actual))!==JSON.stringify(row.bytes)||before.dev!==after.dev||before.ino!==after.ino||before.uid!==after.uid||before.gid!==after.gid||before.mode!==after.mode||before.size!==after.size)process.exit(34); + members.push({target:row.target,mode:row.mode,uid:before.uid,gid:before.gid}); + } + process.stdout.write(JSON.stringify({version:1,marker:"granted-files-exact",uid:process.getuid(),gid:process.getgid(),members})); +} catch {process.exit(35)} +`; + +/** An explicitly privileged observer tests the readonly mount, independently of the reader's DAC rights. */ +export const nativeFileFixtureWriteProgram = ` +import {writeFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + if(process.getuid()!==0)process.exit(41); + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(42); + for(const row of rows){try{await writeFile(row.target,"unexpected-fixture-write");process.exit(43)}catch(error){if(error?.code!=="EROFS")process.exit(44)}} + process.stdout.write("exact-readonly-files"); +} catch {process.exit(45)} +`; + +/** The chosen non-root guest UID must differ from every observed protected file owner. */ +export const nativeFileFixtureDeniedProgram = ` +import {lstat,readFile} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8||process.getuid()===0)process.exit(51); + for(const row of rows){ + const info=await lstat(row.target); + if(!["0400","0600"].includes(row.mode)||(info.mode&4095)!==Number.parseInt(row.mode,8)||info.uid!==row.uid||info.gid!==row.gid||info.uid===process.getuid())process.exit(52); + try{await readFile(row.target);process.exit(53)}catch(error){if(error?.code!=="EACCES")process.exit(54)} + } + process.stdout.write("exact-nonowner-read-refused"); +} catch {process.exit(55)} +`; + +/** Missing targets must be ENOENT, rather than an unreadable or hidden mount. */ +export const nativeFileFixtureAbsentProgram = ` +import {lstat} from "node:fs/promises"; +const deadline=setTimeout(()=>process.exit(89),5000);deadline.unref(); +try { + const raw=await Bun.stdin.arrayBuffer();if(raw.byteLength>8192)process.exit(88);const rows=JSON.parse(new TextDecoder().decode(raw));if(!Array.isArray(rows)||rows.length<1||rows.length>8)process.exit(61); + for(const row of rows){try{await lstat(row.target);process.exit(62)}catch(error){if(error?.code!=="ENOENT")process.exit(63)}} + process.stdout.write("exact-ungranted-files-absent"); +} catch {process.exit(64)} +`; + +function keys(value: Record, expected: readonly string[]) { + if (Object.keys(value).sort().join() !== [...expected].sort().join()) { + throw new Error("Fixture guest observation shape refused; values omitted"); + } +} +function identity(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +/** Decode only bounded JSON and bind every mode/target in order. This grants no engine authority. */ +export function readNativeFileFixtureGuest(opts: { + readonly text: string; + readonly expected: readonly NativeFileFixtureGrant[]; +}): NativeFileFixtureGuest { + if (opts.text.length > 4096) { + throw new Error("Fixture guest observation budget refused; values omitted"); + } + const value: unknown = JSON.parse(opts.text); + if (!isRecord(value)) { + throw new Error("Fixture guest observation refused; values omitted"); + } + keys(value, ["version", "marker", "uid", "gid", "members"]); + if ( + !( + value.version === 1 && + value.marker === "granted-files-exact" && + identity(value.uid) && + identity(value.gid) && + Array.isArray(value.members) && + value.members.length === opts.expected.length + ) + ) { + throw new Error("Fixture guest permissions refused; values omitted"); + } + const members = value.members.map((entry: unknown, index: number) => { + if (!isRecord(entry)) { + throw new Error("Fixture guest member refused; values omitted"); + } + keys(entry, ["target", "mode", "uid", "gid"]); + const expected = opts.expected[index]; + if ( + !( + expected && + entry.target === expected.target && + entry.mode === expected.mode && + identity(entry.uid) && + identity(entry.gid) + ) + ) { + throw new Error("Fixture guest member changed; values omitted"); + } + return Object.freeze({ + target: expected.target, + mode: expected.mode, + uid: entry.uid, + gid: entry.gid, + }); + }); + return Object.freeze({ + uid: value.uid, + gid: value.gid, + members: Object.freeze(members), + }); +} + +/** Select a fixed non-root observer UID only after seeing actual file ownership. Never infer an image default. */ +export function nativeFileFixtureNonowner( + guest: NativeFileFixtureGuest +): number { + const protectedMembers = guest.members.filter( + (member) => member.mode !== "0444" + ); + if (protectedMembers.length === 0) { + throw new Error("Fixture has no protected target; values omitted"); + } + for (const uid of [65_534, 65_533]) { + if (protectedMembers.every((member) => member.uid !== uid)) { + return uid; + } + } + throw new Error("Fixture nonowner selection refused; values omitted"); +} diff --git a/tests/e2e/native-process-policy-initial-replay.ts b/tests/e2e/native-process-policy-initial-replay.ts index 5f103492e..17aaba4cb 100644 --- a/tests/e2e/native-process-policy-initial-replay.ts +++ b/tests/e2e/native-process-policy-initial-replay.ts @@ -3,16 +3,17 @@ import { join } from "node:path"; import { isRecord } from "../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../src/lib/native-compose-generation.ts"; import { readNativeComposeNetworkTopology } from "../../src/lib/native-compose-network-topology.ts"; -import type { NativeComposeOwnershipOptions } from "../../src/lib/native-compose-ownership.ts"; +import type { + NativeComposeOwnershipOptions, + NativeComposeOwnershipRefusal, + NativeComposeTopologyPredicate, +} from "../../src/lib/native-compose-ownership.ts"; +import { isNativeComposeTopologyPredicate } from "../../src/lib/native-compose-ownership.ts"; import { exec } from "../../src/lib/shell.ts"; import { type ProcessPolicyInitialTraceQuery, readProcessPolicyInitialTrace, } from "./native-process-policy-initial-trace.ts"; -import { - isProcessPolicyReplayReason, - type ProcessPolicyReplayReason, -} from "./native-process-policy-replay-reason.ts"; const REFUSAL = "Initial process-policy replay unavailable; values omitted"; const SERVICES = ["forced", "graceful", "reaper", "retry"] as const; @@ -26,13 +27,29 @@ const CODES = [ type Replay = { readonly outcome: "owned" | "unready" | "refused"; readonly code: (typeof CODES)[number] | null; + /** First replayed ownership predicate only; absent for success or unclassified probe errors. */ + readonly reason: NativeComposeOwnershipRefusal | null; + readonly topologyPredicate: NativeComposeTopologyPredicate | null; readonly consumed: number; readonly protocolMatched: boolean; - readonly reason: ProcessPolicyReplayReason | null; }; function replayCode(value: unknown): value is Replay["code"] { return value === null || CODES.some((code) => code === value); } +function replayReason(value: unknown): value is Replay["reason"] { + return ( + value === null || + value === "resource-label" || + value === "generation" || + value === "state" || + value === "volume-birth" || + value === "bridge-policy" || + value === "topology" || + value === "endpoint" || + value === "cross-scan-drift" || + value === "unknown" + ); +} /** Run the real ownership policy against original recorded replies, with no engine access. */ export async function replayProcessPolicyInitialOwnership(opts: { @@ -70,16 +87,13 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode); ); await chmod(docker, 0o700); const program = ` -import {captureProcessPolicyOwnershipSource,sameProcessPolicyOwnershipSource,processPolicyReplayReason} from ${JSON.stringify(join(import.meta.dir, "native-process-policy-replay-reason.ts"))}; -const sourceBefore=captureProcessPolicyOwnershipSource(); -const {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError}=await import(${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}); +import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal,nativeComposeTopologyPredicate} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; const {selection}=await Bun.file(${JSON.stringify(inputs)}).json(); -let outcome="refused",code=null,reason=null; +let outcome="refused",code=null,reason=null,topologyPredicate=null; try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";} -catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=code==="E_NATIVE_COMPOSE_OWNERSHIP"&&sourceBefore?processPolicyReplayReason({stack:error.stack,sourcePath:sourceBefore.path,sourceSha256:sourceBefore.sha256}):"unavailable";} -if(outcome==="refused"&&!sameProcessPolicyOwnershipSource(sourceBefore,captureProcessPolicyOwnershipSource()))reason="unavailable"; +catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;topologyPredicate=nativeComposeTopologyPredicate(error)??null;} const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0; -process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); +process.stdout.write(JSON.stringify({outcome,code,reason,topologyPredicate,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); `; const result = await exec( [process.execPath, "--no-env-file", "-e", program], @@ -101,13 +115,15 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche value.outcome === "unready" || value.outcome === "refused") && replayCode(value.code) && - (value.reason === null || isProcessPolicyReplayReason(value.reason)) && - (value.outcome === "refused" - ? value.reason !== null - : value.reason === null) && - (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || - value.reason === null || - value.reason === "unavailable") && + replayReason(value.reason) && + (value.topologyPredicate === null || + isNativeComposeTopologyPredicate(value.topologyPredicate)) && + (value.topologyPredicate === null || + (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" && + value.reason === "topology" && + value.outcome === "refused")) && + (value.outcome === "refused" || value.reason === null) && + (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || value.reason === null) && Number.isInteger(value.consumed) && typeof value.consumed === "number" && value.consumed >= 0 && @@ -121,6 +137,7 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche outcome: value.outcome, code: value.code, reason: value.reason, + topologyPredicate: value.topologyPredicate, consumed: value.consumed, protocolMatched: value.protocolMatched, }; @@ -276,3 +293,61 @@ export async function summarizeProcessPolicyInitialTrace(opts: { await store.close(); } } + +/** Explicit opt-in persists only the first owner-issued refusal from matching after-Compose + * recorded replies. This cannot identify the original caller mode or timing. */ +export async function persistProcessPolicyFirstAfterComposeRefusal(opts: { + readonly directory: string; + readonly enabled: string | undefined; + readonly summary: Awaited< + ReturnType + >; +}) { + if (opts.enabled !== "1") { + return null; + } + const row = opts.summary.observations.find( + (observation) => + observation.phase === "after-compose" && + observation.startup.protocolMatched && + observation.strict.protocolMatched && + [observation.startup, observation.strict].some( + (mode) => + mode.outcome === "refused" && + mode.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ) + ); + if (!row) { + return null; + } + const capsule = { + version: 1 as const, + kind: "native-process-policy-after-compose-replay-refusal" as const, + observationIndex: row.index, + replayUsesRecordedReplies: true as const, + replaysWallTiming: false as const, + originalCallerModeKnown: false as const, + startupReason: + row.startup.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.startup.reason + : null, + strictReason: + row.strict.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.strict.reason + : null, + startupTopologyPredicate: row.startup.topologyPredicate, + strictTopologyPredicate: row.strict.topologyPredicate, + }; + const handle = await open( + join(opts.directory, "first-after-compose-replay-refusal.json"), + "wx", + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(capsule)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + return capsule; +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index a2efcf06f..9e60f36e8 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -15,6 +15,7 @@ import { initScenario } from "./scenarios/init.ts"; import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process.ts"; import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; import { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; +import { nativeComposeAdoptionRoutingWorktreesScenario } from "./scenarios/native-compose-adoption-routing-worktrees.ts"; import { nativeComposeAdoptionSourceBindWorktreesScenario } from "./scenarios/native-compose-adoption-source-bind-worktrees.ts"; import { nativeComposeAdoptionBranchWorktreesScenario, @@ -36,6 +37,7 @@ import { nativeConfigFileUnknownStopScenario, } from "./scenarios/native-config-files.ts"; import { nativeConfigProcessPolicyScenario } from "./scenarios/native-config-process-policy.ts"; +import { nativeConfigProtectedFilesScenario } from "./scenarios/native-config-protected-files.ts"; import { nativeConfigRoutingScenario } from "./scenarios/native-config-routing.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; import { upDownScenario } from "./scenarios/up-down.ts"; @@ -86,6 +88,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeConfigDownHooksScenario, nativeConfigFilesScenario, nativeConfigFileUnknownStopScenario, + nativeConfigProtectedFilesScenario, nativeConfigProcessPolicyScenario, nativeConfigRoutingScenario, nativeConfigNetworksScenario, @@ -100,6 +103,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeComposeAdoptionDependencyWorktreesScenario, nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionJobWorktreesScenario, + nativeComposeAdoptionRoutingWorktreesScenario, nativeComposeAdoptionSourceBindWorktreesScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, 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/e2e/scenarios/native-compose-adoption-job-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts index 0a70476d8..f638be7b3 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts @@ -22,7 +22,7 @@ import { createAdoptionFixtureProbe, waitForAdoptionFixtureSql, } from "./native-compose-adoption-worktrees.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 180_000; const FULL_ID = /^[a-f0-9]{64}$/; @@ -852,6 +852,9 @@ type CompletedJobCaptureOptions = { readonly env: Readonly>; readonly captures: string; readonly timeoutMs: number; + readonly stdin?: Uint8Array; + readonly signal?: AbortSignal; + readonly maxStreamBytes?: number; readonly beforeInterrupt?: ( admission: InterruptReadAdmission ) => Promise; @@ -888,6 +891,9 @@ async function captureCompletedJobFixtureChild( env: Object.freeze({ ...opts.env }), captures: opts.captures, timeoutMs: opts.timeoutMs, + stdin: opts.stdin === undefined ? undefined : Buffer.from(opts.stdin), + signal: opts.signal, + maxStreamBytes: opts.maxStreamBytes, beforeInterrupt: opts.beforeInterrupt, onUnconfirmed: opts.onUnconfirmed, deadline: Date.now() + opts.timeoutMs, @@ -898,14 +904,20 @@ async function captureCompletedJobFixtureChild( executable.startsWith("/") && Number.isSafeInteger(snapshot.timeoutMs) && snapshot.timeoutMs > 0 && - snapshot.timeoutMs <= TIMEOUT + snapshot.timeoutMs <= TIMEOUT && + !snapshot.signal?.aborted && + (snapshot.stdin === undefined || snapshot.stdin.byteLength <= 8192) && + (snapshot.maxStreamBytes === undefined || + (Number.isSafeInteger(snapshot.maxStreamBytes) && + snapshot.maxStreamBytes > 0 && + snapshot.maxStreamBytes <= 524_288)) ); let ownerStarted = false; const output = await open(`${snapshot.captures}.stdout`, "wx", 0o600); try { const errors = await open(`${snapshot.captures}.stderr`, "wx", 0o600); try { - requireValue(snapshot.deadline > Date.now()); + requireValue(snapshot.deadline > Date.now() && !snapshot.signal?.aborted); ownerStarted = true; const exitCode = await captureInterruptedPipes({ ...snapshot, @@ -951,7 +963,7 @@ async function captureInterruptedPipes( cwd: opts.cwd, env: { ...opts.env }, detached: true, - stdio: ["ignore", "pipe", "pipe"], + stdio: [opts.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); const stdoutStream = child.stdout, stderrStream = child.stderr; @@ -962,6 +974,7 @@ async function captureInterruptedPipes( let openPipes = 2; let settled = false; let timedOut = false; + let cancelled = false; let oversized = false; let groupFailure = false; let captureFailure = false; @@ -1000,15 +1013,31 @@ async function captureInterruptedPipes( resolveExit(code ?? 128); }); }); + const input = new Promise((resolveInput, reject) => { + if (opts.stdin === undefined) { + resolveInput(); + return; + } + const stream = child.stdin; + requireValue(stream !== null); + stream.once("error", () => { + captureFailure = true; + stopOwned(); + reject(new Error("Completed-job input refused; values omitted.")); + }); + stream.end(opts.stdin, resolveInput); + }); const capture = async ( stream: NonNullable, file: Awaited> ) => { + let streamRemaining = opts.maxStreamBytes ?? 524_288; try { for await (const raw of stream) { requireValue(Buffer.isBuffer(raw)); - const count = Math.min(raw.length, remaining); + const count = Math.min(raw.length, remaining, streamRemaining); remaining -= count; + streamRemaining -= count; if (count < raw.length) { oversized = true; stopOwned(); @@ -1029,7 +1058,7 @@ async function captureInterruptedPipes( }; const stdout = capture(stdoutStream, opts.output); const stderr = capture(stderrStream, opts.errors); - const all = Promise.all([exited, stdout, stderr]).then(([code]) => { + const all = Promise.all([exited, stdout, stderr, input]).then(([code]) => { settled = true; return code; }); @@ -1044,6 +1073,16 @@ async function captureInterruptedPipes( ); }); expired.catch(() => undefined); + const cancel = () => { + cancelled = true; + admission.abort(); + stopOwned(); + expire?.(); + }; + opts.signal?.addEventListener("abort", cancel, { once: true }); + if (opts.signal?.aborted) { + cancel(); + } const timer = setTimeout( () => { timedOut = true; @@ -1079,7 +1118,7 @@ async function captureInterruptedPipes( try { await Promise.race([ (async () => { - await Promise.allSettled([exited, stdout, stderr]); + await Promise.allSettled([exited, stdout, stderr, input]); requireValue(settlementDeadline > Date.now()); requireValue( leaderExited && openPipes === 0 && !captureFailure && !groupFailure @@ -1117,7 +1156,7 @@ async function captureInterruptedPipes( capturedLeaderAbsent: true, capturedGroupAbsent: true, scope: "captured-child-group", - interrupted: timedOut || oversized, + interrupted: timedOut || oversized || cancelled, callbackSettled: outstandingPhase === 0, }) ); @@ -1137,6 +1176,7 @@ async function captureInterruptedPipes( opts.onUnconfirmed(); stdoutStream.destroy(); stderrStream.destroy(); + child.stdin?.destroy(); // A FileHandle.write already in progress cannot be canceled by destroying its stream. // Keep its rejection handled, but never extend this settlement deadline to await it. Promise.allSettled([stdout, stderr]) @@ -1187,7 +1227,9 @@ async function captureInterruptedPipes( child.kill("SIGINT"); } code = await Promise.race([all, expired]); - requireValue((!beforeInterrupt || code !== 0) && !timedOut && !oversized); + requireValue( + (!beforeInterrupt || code !== 0) && !timedOut && !oversized && !cancelled + ); } finally { if (outstandingPhase > 0) { opts.onUnconfirmed(); @@ -1198,10 +1240,16 @@ async function captureInterruptedPipes( } finally { admission.abort(); clearTimeout(timer); + opts.signal?.removeEventListener("abort", cancel); } } requireValue( - code !== undefined && !timedOut && !oversized && opts.deadline > Date.now() + code !== undefined && + !timedOut && + !oversized && + !cancelled && + !opts.signal?.aborted && + opts.deadline > Date.now() ); return code; } diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts new file mode 100644 index 000000000..c7a9af923 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts @@ -0,0 +1,132 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { resolveProjectOauthAliasHost } from "../../../src/lib/project.ts"; + +export type RetainedRoutingFixtureSelection = { + readonly image: string; + readonly devHost: string; + readonly aliasHost: string; + readonly prefer: "alias" | "dev"; + readonly marker: string; +}; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/; +const APP = + "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.RETAINED_ROUTE_MARKER)}})"; +function refuse(): never { + throw new Error("Retained routing fixture input refused; values omitted."); +} + +/** Explicit legacy hosts are authored before the original Compose bootstrap. */ +export function retainedRoutingFixtureSelection(opts: { + readonly image: string; + readonly name: string; + readonly marker: string; + readonly prefer: "alias" | "dev"; +}): RetainedRoutingFixtureSelection { + if (!(IMAGE.test(opts.image) && NAME.test(opts.name) && opts.marker)) { + return refuse(); + } + const devHost = `${opts.name}.hack.local`; + const aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { enabled: true }, + }); + if (!aliasHost || aliasHost === devHost) { + return refuse(); + } + return Object.freeze({ + image: opts.image, + devHost, + aliasHost, + prefer: opts.prefer, + marker: opts.marker, + }); +} + +export function retainedRoutingFixtureConfig( + selection: RetainedRoutingFixtureSelection +) { + return { + dev_host: selection.devHost, + oauth: { enabled: true }, + // Alpha's authored dev preference must lose to its checkout-local alias. + open: { prefer: "dev" as const }, + }; +} +export function retainedRoutingFixtureService( + selection: RetainedRoutingFixtureSelection +) { + return { + image: selection.image, + pull_policy: "never", + entrypoint: ["bun", "-e"], + command: [APP], + environment: { RETAINED_ROUTE_MARKER: selection.marker }, + networks: ["default", "hack-dev"], + labels: { + caddy: `${selection.devHost}, ${selection.aliasHost}`, + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + caddy_ingress_network: "hack-dev", + }, + }; +} +export function retainedRoutingFixtureOrigins( + selection: RetainedRoutingFixtureSelection +): readonly string[] { + return [`https://${selection.devHost}`, `https://${selection.aliasHost}`]; +} + +/** The checkout layer wins without changing either already served origin. */ +export async function prepareRetainedRoutingFixtureLocals(opts: { + readonly primary: { readonly root: string }; + readonly instances: readonly { + readonly root: string; + readonly routing?: RetainedRoutingFixtureSelection; + }[]; +}) { + await writeFile( + join(opts.primary.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }), + { mode: 0o600 } + ); + for (const instance of opts.instances) { + if (!instance.routing) { + return refuse(); + } + await writeFile( + join(instance.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: instance.routing.prefer }, + }), + { mode: 0o600 } + ); + } +} + +/** Private exact sidecar identities and bytes; no digests enter scenario output. */ +export async function retainedRoutingFixtureLocalSnapshot(opts: { + readonly primary: { readonly root: string }; + readonly instance: { readonly root: string }; +}) { + const rows: unknown[] = []; + for (const checkout of [opts.primary, opts.instance]) { + const path = join(checkout.root, ".hack/hack.local.json"); + const { info, text } = await readPrivate(path, 4096); + rows.push({ + dev: info.dev, + ino: info.ino, + mode: info.mode, + hash: new Bun.CryptoHasher("sha256").update(text).digest("hex"), + }); + } + return JSON.stringify(rows); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts new file mode 100644 index 000000000..40c539595 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts @@ -0,0 +1,415 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { + readPrivate, + writeExclusive, +} from "../../../src/lib/native-compose-private-state.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import type { CliResult, Scenario } from "../harness.ts"; +import { + retainedRoutingFixtureLocalSnapshot, + retainedRoutingFixtureOrigins, +} from "./native-compose-adoption-routing-inputs.ts"; +import { + bootstrapOriginal, + cleanupOwnedAdoptionFixture, + createFixtureRuntime, + prepareFixtureInputs, + runWithFixtureCleanup, +} from "./native-compose-adoption-worktrees.ts"; +import { nativeRoutedDownClaimSnapshot } from "./native-config-routed-down-hooks.ts"; +import { prepareNativeRoutingFixtureIngress } from "./native-routing-fixture-ingress.ts"; + +type Runtime = ReturnType; +type Instance = Runtime["first"]; +type Ingress = Awaited>; +const SERVICES = ["db", "web", "worker"]; +function refuse(): never { + throw new Error("Retained routing lifecycle check refused; values omitted."); +} +function passed(result: CliResult) { + if (result.timedOut || result.exitCode !== 0) { + return refuse(); + } + return result; +} +function object(text: string) { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return refuse(); + } + if (!isRecord(value)) { + return refuse(); + } + return value; +} +function receiptPath(instance: Instance) { + return join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); +} +async function receipt(instance: Instance) { + const value = object( + (await readPrivate(receiptPath(instance), 128 * 1024)).text + ); + if (!value || value.adoption_receipt_version !== 14) { + return refuse(); + } + return value; +} +function originalIds(h: Runtime, instance: Instance): readonly string[] { + return SERVICES.map((service) => h.container(instance, service)); +} + +/** Closed forwarding control. It performs exactly one known partial stop, then + * the real CLI's routing process owner records the known nonzero disposition. */ +export function retainedRoutingPartialStopScript(opts: { + readonly engine: string; + readonly engineId: string; + readonly receipt: string; + readonly ids: readonly string[]; + readonly stopId: string; + readonly marker: string; +}): string { + if ( + opts.ids.length !== 3 || + new Set(opts.ids).size !== 3 || + !opts.ids.includes(opts.stopId) || + opts.ids.some((id) => !/^[a-f0-9]{64}$/.test(id)) + ) { + return refuse(); + } + return `#!${process.execPath} +import { readPrivate } from ${JSON.stringify(new URL("../../../src/lib/native-compose-private-state.ts", import.meta.url).href)}; +import { writeFile } from 'node:fs/promises'; +const args=process.argv.slice(2), engine=${JSON.stringify(opts.engine)}; +if(args[0]==='container' && args[1]==='stop') { + if(JSON.stringify(args)!==JSON.stringify(['container','stop',...${JSON.stringify(opts.ids)}])) process.exit(99); + let value;try{value=JSON.parse((await readPrivate(${JSON.stringify(opts.receipt)},131072)).text)}catch{process.exit(98)} + if(value.adoption_receipt_version!==14 || value.pendingOperation?.operation!=='stop' || JSON.stringify([...value.pendingOperation.services].sort())!==JSON.stringify(${JSON.stringify(SERVICES)}) || value.routingOperation?.disposition!=='prospective' || value.routingOperation?.code!==null || value.routingHandoff!=='held') process.exit(98); + const observed=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'}); + const observedText=await new Response(observed.stdout).text(); + if(await observed.exited!==0 || observedText.trim()!==${JSON.stringify(opts.engineId)}) process.exit(97); + const child=Bun.spawn([engine,'container','stop',${JSON.stringify(opts.stopId)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); + if(await child.exited!==0) process.exit(96); + await writeFile(${JSON.stringify(opts.marker)},'known-routing-partial-stop',{flag:'wx',mode:0o600}); + process.exit(71); +} +const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited); +`; +} + +async function partialStop(h: Runtime) { + const root = join(h.ctx.tempRoot, "retained-routing-partial-stop"); + await mkdir(root, { mode: 0o700 }); + const marker = join(root, "known-stop"); + const shim = join(root, "docker"); + await writeFile( + shim, + retainedRoutingPartialStopScript({ + engine: h.engine, + engineId: h.engineId, + receipt: receiptPath(h.first), + ids: originalIds(h, h.first), + stopId: h.container(h.first, "db"), + marker, + }), + { mode: 0o700 } + ); + await chmod(shim, 0o700); + const result = await h.cli(h.first, ["config", "adopt", "--stop", "--json"], { + PATH: `${root}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }); + await retainRoutingAdoptStopResult({ root, result }); + if ( + result.timedOut || + result.exitCode !== 71 || + (await readPrivate(marker, 128)).text !== "known-routing-partial-stop" + ) { + return refuse(); + } + const saved = await receipt(h.first); + if ( + !isRecord(saved.pendingOperation) || + saved.pendingOperation.operation !== "stop" || + !isRecord(saved.routingOperation) || + saved.routingOperation.disposition !== "settled" || + saved.routingOperation.code !== 71 || + saved.routingHandoff !== "held" + ) { + return refuse(); + } + const pendingBytes = (await readPrivate(receiptPath(h.first), 128 * 1024)) + .text; + const states = async () => + JSON.stringify( + await Promise.all( + originalIds(h, h.first).map(async (id) => ({ + id, + running: await h.probe([ + "container", + "inspect", + "--format", + "{{.State.Running}}", + id, + ]), + })) + ) + ); + const before = await states(); + const blocked = await h.cli(h.first, ["up", "--detach", "--json"]); + if ( + blocked.timedOut || + blocked.exitCode !== 1 || + !blocked.combined.includes("E_CONFIG_INVALID") || + (await readPrivate(receiptPath(h.first), 128 * 1024)).text !== + pendingBytes || + (await states()) !== before + ) { + return refuse(); + } + passed( + await h.cli(h.first, ["config", "adopt", "--recover", "--stop", "--json"]) + ); + await h.assertStopped(h.first); +} + +/** Preserve the known CLI return before the fixture's stop oracle can refuse. + * These private streams are evidence only; they grant no cleanup authority. */ +export async function retainRoutingAdoptStopResult(opts: { + readonly root: string; + readonly result: CliResult; +}): Promise { + const { exitCode, timedOut, stdout, stderr } = opts.result; + if ( + !Number.isInteger(exitCode) || + exitCode < 0 || + exitCode > 255 || + typeof timedOut !== "boolean" || + Buffer.byteLength(stdout) > 64 * 1024 || + Buffer.byteLength(stderr) > 64 * 1024 + ) { + return refuse(); + } + await writeExclusive( + join(opts.root, "adopt-stop-result.json"), + `${JSON.stringify({ phase: "adopt-stop-return", exitCode, timedOut, stdout, stderr })}\n` + ); +} + +async function savedOpen(h: Runtime, instance: Instance) { + const selected = instance.routing; + if (!selected) { + return refuse(); + } + const automatic = + selected.prefer === "alias" ? selected.aliasHost : selected.devHost; + if ( + object(passed(await h.cli(instance, ["open", "--json"])).stdout).url !== + `https://${automatic}` || + object( + passed(await h.cli(instance, ["open", "--prefer", "dev", "--json"])) + .stdout + ).url !== `https://${selected.devHost}` + ) { + return refuse(); + } + // Alpha's authored and primary-local dev selections differ from its saved + // checkout-local alias, so this public open result proves that precedence. +} +async function routes(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + for (const origin of retainedRoutingFixtureOrigins(instance.routing)) { + await ingress.tls(origin, instance.routing.marker); + } + await ingress.tls(`https://${ingress.canaryHost}`, ingress.canaryMarker); + await ingress.preservedUnchanged(); +} +async function absent(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + const hosts = retainedRoutingFixtureOrigins(instance.routing).map( + (origin) => new URL(origin).hostname + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const matched = nativeComposeProxyRoutesMatch({ + servers: await ingress.admin(), + expected: [], + absentHostnames: hosts, + }); + if (matched && Date.now() < deadline) { + return; + } + await Bun.sleep(250); + } + return refuse(); +} +async function originalBaseline( + h: Runtime, + ingress: Ingress, + instance: Instance, + source = true +) { + await h.waitReady(instance); + await h.check(instance, source); + await routes(ingress, instance); +} +async function roundTrip( + h: Runtime, + ingress: Ingress, + instance: Instance, + sibling: Instance, + prepared: boolean +) { + if (!prepared) { + passed(await h.cli(instance, ["config", "adopt", "--stop", "--json"])); + await h.assertStopped(instance); + } + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + for (let index = 0; index < 2; index++) { + passed(await h.cli(instance, ["up", "--detach", "--json"])); + await originalBaseline(h, ingress, instance, false); + await savedOpen(h, instance); + if ( + passed( + await h.cli(instance, [ + "exec", + "db", + "--", + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + "SELECT value FROM marker WHERE id=1", + ]) + ).stdout.trim() !== instance.marker + ) { + return refuse(); + } + await originalBaseline(h, ingress, sibling); + passed(await h.cli(instance, ["down", "--json"])); + await h.assertStopped(instance); + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + } + passed(await h.cli(instance, ["config", "adopt", "--rollback", "--json"])); + const rolledBack = await receipt(instance); + if ( + !isRecord(rolledBack.publication) || + rolledBack.publication.phase !== "rolled-back" + ) { + return refuse(); + } + await h.effect(["container", "start", ...originalIds(h, instance)]); + await originalBaseline(h, ingress, instance); + await originalBaseline(h, ingress, sibling); +} + +/** Same old instances keep literal browser origins, SQL, resources and local + * precedence. Only the existing temporary ingress owner may serve this fixture. */ +export const nativeComposeAdoptionRoutingWorktreesScenario: Scenario = { + name: "native-compose-adoption-routing-worktrees", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "original routed linked checkouts retain TLS aliases, saved open, SQL and recovery/rollback", + run: async (ctx) => { + const h = createFixtureRuntime( + await prepareFixtureInputs(ctx, { routing: true }) + ); + const ingress = await prepareNativeRoutingFixtureIngress({ + ctx, + docker: h.probe, + }); + if ( + h.first.routing?.image !== ingress.bunImage || + h.second.routing?.image !== ingress.bunImage + ) { + return refuse(); + } + const localPins = new Map(); + let claimsRoot: string | null = null; + let complete = false; + await runWithFixtureCleanup({ + run: async () => { + const binding = await ingress.start(); + claimsRoot = join( + ctx.hackHome, + "compose-routing", + createHash("sha256").update(binding.engineId).digest("hex"), + "claims" + ); + for (const instance of [h.first, h.second]) { + localPins.set( + instance, + await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + }) + ); + await bootstrapOriginal(h, instance); + await originalBaseline(h, ingress, instance); + } + await partialStop(h); + await roundTrip(h, ingress, h.first, h.second, true); + await roundTrip(h, ingress, h.second, h.first, false); + for (const instance of [h.first, h.second]) { + if ( + (await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + })) !== localPins.get(instance) + ) { + return refuse(); + } + } + if ((await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "") { + return refuse(); + } + complete = true; + ctx.log( + "original literal TLS/OAuth origins, checkout-local saved open, SQL/IDs and known partial-stop recovery/rollback verified" + ); + }, + cleanup: async () => { + if ( + !(complete && claimsRoot) || + (await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "" + ) { + return refuse(); + } + // The rolled-back original workloads are stopped and their exact routes + // disappear before any project cleanup or temporary ingress retirement. + for (const instance of [h.first, h.second]) { + await h.check(instance); + await h.effect(["container", "stop", ...originalIds(h, instance)]); + await h.assertStopped(instance); + await absent(ingress, instance); + } + await cleanupOwnedAdoptionFixture({ + ...h, + instances: [h.first, h.second], + }); + await ingress.cleanup(); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Retained routing fixture ownership or cleanup is uncertain" + ), + }); + }, +}; diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index ab42cc4df..019ea1192 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -72,6 +72,13 @@ import { managedAdoptionFixtureSourceSnapshot, prepareManagedAdoptionFixtureSources, } from "./native-compose-adoption-managed-inputs.ts"; +import { + prepareRetainedRoutingFixtureLocals, + type RetainedRoutingFixtureSelection, + retainedRoutingFixtureConfig, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./native-compose-adoption-routing-inputs.ts"; import { assertSourceBindFixtureHostBytes, prepareSourceBindFixtureSources, @@ -139,6 +146,7 @@ type Instance = { readonly ownedNetworks?: true; readonly dependency?: "service_started" | "service_healthy"; readonly basicBuild?: RetainedBuildFixtureMode; + readonly routing?: RetainedRoutingFixtureSelection; readonly sourceBinds?: "first" | "second"; }; type Observation = { @@ -487,26 +495,43 @@ function validateOwnedObservation(opts: { typeof row.id !== "string" || !ID.test(row.id) || typeof row.service !== "string" || - !["db", "worker"].includes(row.service) || + !( + opts.instance.routing ? ["db", "worker", "web"] : ["db", "worker"] + ).includes(row.service) || row.name !== `/${opts.instance.name}-${row.service}-1` || row.workingDir !== join(opts.instance.root, ".hack") || row.configFiles !== fixtureComposeFiles(opts.instance).join(",") || (!opts.instance.sourceBinds && JSON.stringify(row.mounts) !== - JSON.stringify([ - { - type: "volume", - name: `${opts.instance.name}_data`, - target: "/var/lib/postgresql/data", - rw: row.service === "db", - }, - ])) + JSON.stringify( + row.service === "web" && opts.instance.routing + ? [] + : [ + { + type: "volume", + name: `${opts.instance.name}_data`, + target: "/var/lib/postgresql/data", + rw: row.service === "db", + }, + ] + )) + ) { + refused(); + } + if ( + opts.instance.routing && + !( + typeof row.createdAt === "string" && + CREATED.test(row.createdAt) && + Number.isFinite(Date.parse(row.createdAt)) + ) ) { refused(); } return { id: row.id, service: row.service, + ...(opts.instance.routing ? { createdAt: String(row.createdAt) } : {}), ...(opts.instance.sourceBinds ? { mounts: sourceBindFixtureMountObservation({ @@ -721,6 +746,9 @@ async function writeLegacy(instance: Instance, image: string) { join(instance.root, ".hack/hack.config.json"), JSON.stringify({ name: authoredName, + ...(instance.routing + ? retainedRoutingFixtureConfig(instance.routing) + : {}), ...(instance.selectedBranch ? { dev_host: "branch-fixture.test" } : {}), worktree: { auto_branch: Boolean(instance.selectedBranch), @@ -732,6 +760,9 @@ async function writeLegacy(instance: Instance, image: string) { const composeText = JSON.stringify({ name: authoredName, services: { + ...(instance.routing + ? { web: retainedRoutingFixtureService(instance.routing) } + : {}), db: { ...(instance.basicBuild ? { build: retainedBuildFixtureDefinition(instance.basicBuild) } @@ -794,6 +825,9 @@ async function writeLegacy(instance: Instance, image: string) { }, }, volumes: { data: { name: `${instance.name}_data` } }, + ...(instance.routing + ? { networks: { "hack-dev": { external: true } } } + : {}), ...(instance.ownedNetworks ? { networks: { @@ -828,12 +862,12 @@ async function writeLegacy(instance: Instance, image: string) { ); } } -function formats(kind: Kind, sourceBinds = false): string { +function formats(kind: Kind, routing = false, sourceBinds = false): string { if (kind === "container") { if (sourceBinds) { return SOURCE_BIND_FIXTURE_INSPECT_FORMAT; } - return `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; + return `{"id":{{json .Id}},${routing ? '"createdAt":{{json .Created}},' : ""}"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; } if (kind === "network") { return `{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"project":{{json (index .Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"logical":{{json (index .Labels "com.docker.compose.network")}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}}}`; @@ -894,6 +928,7 @@ export async function prepareFixtureInputs( readonly ownedNetworks?: boolean; readonly dependencies?: boolean; readonly basicBuild?: boolean; + readonly routing?: boolean; readonly sourceBinds?: boolean; readonly branchAdoption?: boolean; } = {} @@ -906,11 +941,12 @@ export async function prepareFixtureInputs( ownedNetworks = false, dependencies = false, basicBuild = false, + routing = false, sourceBinds = false, branchAdoption = false, } = options; if ( - branchAdoption && + (routing || branchAdoption) && (generated || typedLocal || stringArgv || @@ -918,7 +954,8 @@ export async function prepareFixtureInputs( ownedNetworks || dependencies || basicBuild || - sourceBinds) + sourceBinds || + (routing && branchAdoption)) ) { refused(); } @@ -942,7 +979,8 @@ export async function prepareFixtureInputs( ownedNetworks || dependencies || basicBuild || - branchAdoption) + branchAdoption || + routing) ) { refused(); } @@ -972,6 +1010,18 @@ export async function prepareFixtureInputs( ctx.skip("Docker executable unavailable"); } const engineId = await probe(["info", "--format", "{{json .ID}}"]); + const routingImage = routing + ? await probe([ + "image", + "inspect", + "oven/bun:1.4.2-slim", + "--format", + "{{.Id}}", + ]) + : null; + if (routingImage !== null && !IMAGE.test(routingImage)) { + refused(); + } const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: false, @@ -980,6 +1030,16 @@ export async function prepareFixtureInputs( root: fixture.root, name: `${fixture.name}-main`, marker: "unused-primary", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-main`, + marker: "unused-primary", + prefer: "alias", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -1027,6 +1087,16 @@ export async function prepareFixtureInputs( ? `${fixture.name}-alpha--adoption-alpha` : `${fixture.name}-alpha`, marker: "alpha-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-alpha`, + marker: "alpha-existing-http-marker", + prefer: "alias", + }), + } + : {}), ...(branchAdoption ? { authoredName: `${fixture.name}-alpha`, @@ -1049,6 +1119,16 @@ export async function prepareFixtureInputs( ? `${fixture.name}-beta--adoption-beta` : `${fixture.name}-beta`, marker: "beta-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-beta`, + marker: "beta-existing-http-marker", + prefer: "dev", + }), + } + : {}), ...(branchAdoption ? { authoredName: `${fixture.name}-beta`, @@ -1097,6 +1177,13 @@ export async function prepareFixtureInputs( }); } + if (routing) { + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [first, second], + }); + } + return { ctx, engine, @@ -1162,7 +1249,11 @@ export function createFixtureRuntime( kind, "inspect", "--format", - formats(kind, Boolean(instance.sourceBinds)), + formats( + kind, + instance.routing !== undefined, + Boolean(instance.sourceBinds) + ), id, ]) ), @@ -2149,6 +2240,9 @@ async function requireFixtureNamesAbsent( ...fixtureNetworkNames(instance).map((name) => ["network", name] as const), ["container", `${instance.name}-db-1`], ["container", `${instance.name}-worker-1`], + ...(instance.routing + ? [["container", `${instance.name}-web-1`] as const] + : []), ] as const) { if ( ( @@ -2247,7 +2341,7 @@ export async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { }); successful(started); if ( - captured.container.length !== 2 || + captured.container.length !== (instance.routing ? 3 : 2) || captured.volume.length !== 1 || captured.network.length !== (instance.ownedNetworks ? 2 : 1) ) { diff --git a/tests/e2e/scenarios/native-config-build.ts b/tests/e2e/scenarios/native-config-build.ts index 45269491f..ef16cf21c 100644 --- a/tests/e2e/scenarios/native-config-build.ts +++ b/tests/e2e/scenarios/native-config-build.ts @@ -14,7 +14,7 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 120_000; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; diff --git a/tests/e2e/scenarios/native-config-process-policy.ts b/tests/e2e/scenarios/native-config-process-policy.ts index 88a20ae75..aa52125fe 100644 --- a/tests/e2e/scenarios/native-config-process-policy.ts +++ b/tests/e2e/scenarios/native-config-process-policy.ts @@ -16,7 +16,10 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { summarizeProcessPolicyInitialTrace } from "../native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + summarizeProcessPolicyInitialTrace, +} from "../native-process-policy-initial-replay.ts"; import { prepareProcessPolicyInitialTrace } from "../native-process-policy-initial-trace.ts"; import { isKnownUncertainProcessPolicyStartup, @@ -733,6 +736,14 @@ export const nativeConfigProcessPolicyScenario: Scenario = { ctx.log( `fixed-field original-query replay: ${JSON.stringify(replay)}` ); + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: ctx.tempRoot, + enabled: process.env.HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE, + summary: replay, + }); + ctx.log( + `fixed-field first after-compose replay refusal: ${JSON.stringify(capsule)}` + ); } catch { stage( "fixed-field original-query replay unavailable; no cause inferred" diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts new file mode 100644 index 000000000..586a8d84d --- /dev/null +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -0,0 +1,1911 @@ +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { + lstat, + mkdir, + open, + readFile, + realpath, + rename, + writeFile, +} from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { bindNativeComposeEngineSocketPath } from "../../../src/lib/native-compose-engine-identity.ts"; +import { + buildCliEnv, + REPO_ROOT, + type Scenario, + type ScenarioContext, +} from "../harness.ts"; +import { runWithOwnedCleanup } from "../native-compose-owned-fixture.ts"; +import { provisionNativeNetworkFixtureComposePlugin } from "../native-config-networks-acceptance.ts"; +import { + type NativeFileFixtureCommandResult, + runNativeFileFixtureCommand, +} from "../native-file-permission-command.ts"; +import { + nativeProtectedFileCleanupDown, + nativeProtectedFileStateRefused, + nativeProtectedFileToolAllowed, +} from "../native-file-permission-control.ts"; +import { + type NativeFileFixtureGrant, + nativeFileFixtureAbsentProgram, + nativeFileFixtureDeniedProgram, + nativeFileFixtureNonowner, + nativeFileFixtureReadProgram, + nativeFileFixtureWriteProgram, + readNativeFileFixtureGuest, +} from "../native-file-permission-guest.ts"; + +import { createCompletedJobFixtureSettlement } from "./native-compose-adoption-job-worktrees.ts"; + +const ID = /^[a-f0-9]{64}$/; +const HASH = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const PROJECT = "com.docker.compose.project"; +const BUN_TAG = "oven/bun:1.4.2-slim"; +const DB_TAG = "postgres:17.6-alpine"; +const LOOP = ["bun", "-e", "setInterval(()=>{},1000)"]; +const FORMATS = { + container: + '{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"image":{{json .Image}},"labels":{{json .Config.Labels}},"command":{{json .Config.Cmd}},"entrypoint":{{json .Config.Entrypoint}},"running":{{json .State.Running}},"status":{{json .State.Status}},"paused":{{json .State.Paused}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{$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}},"aliases":{{json $n.Aliases}}}{{end}}]}', + network: + '{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}},"labels":{{json .Labels}},"members":[{{$first := true}}{{range $id,$c := .Containers}}{{if not $first}},{{end}}{{$first = false}}{{json $id}}{{end}}]}', + volume: + '{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver":{{json .Driver}},"scope":{{json .Scope}},"labels":{{json .Labels}},"options":{{json .Options}},"mountpoint":{{json .Mountpoint}}}', +} as const; +/** Fixed observation text only; absent bind Name defaults empty, present values remain exact. */ +export const nativeProtectedFileContainerInspectFormat = FORMATS.container; +type Kind = keyof typeof FORMATS; +type Row = Record; +type Inventory = { + readonly engine: string; + readonly container: readonly Row[]; + readonly network: readonly Row[]; + readonly volume: readonly Row[]; + readonly images: readonly string[]; + readonly tags: Readonly>; +}; +type SourcePin = { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly uid: number; + readonly gid: number; + readonly size: number; + readonly hash: string; +}; +type Checkout = { + readonly root: string; + readonly name: string; + readonly grants: readonly NativeFileFixtureGrant[]; + readonly material: readonly SourcePin[]; + readonly marker: string; + original?: { + readonly resources: Record; + readonly sources: readonly SourcePin[]; + }; + rolledBack?: boolean; + bootstrapped?: boolean; +}; +function requireValue(value: unknown): asserts value { + if (!value) { + throw new Error("Protected file acceptance refused; values omitted."); + } +} +function object(text: string): Row { + const value: unknown = JSON.parse(text); + requireValue(isRecord(value)); + return value; +} +function canonical(value: unknown): string { + if (Array.isArray(value)) { + return JSON.stringify(value.map((entry) => JSON.parse(canonical(entry)))); + } + if (isRecord(value)) { + return JSON.stringify( + Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, JSON.parse(canonical(value[key]))]) + ) + ); + } + return JSON.stringify(value); +} +function rows(value: readonly unknown[]): unknown[] { + return [...value].sort((a, b) => canonical(a).localeCompare(canonical(b))); +} +function hash(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} +async function sourcePin(path: string): Promise { + const before = await lstat(path); + requireValue( + before.isFile() && + !before.isSymbolicLink() && + before.nlink === 1 && + before.size <= 8192 && + before.uid === process.getuid?.() + ); + const descriptor = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const held = await descriptor.stat(); + requireValue( + held.dev === before.dev && + held.ino === before.ino && + held.mode === before.mode && + held.isFile() && + held.nlink === 1 && + held.uid === before.uid && + held.gid === before.gid && + held.size === before.size + ); + const buffer = Buffer.alloc(8193), + { bytesRead } = await descriptor.read(buffer, 0, buffer.length, 0); + const after = await lstat(path), + final = await descriptor.stat(); + for (const current of [after, final]) { + requireValue( + current.isFile() && + !current.isSymbolicLink() && + current.nlink === 1 && + before.dev === current.dev && + before.ino === current.ino && + before.mode === current.mode && + before.size === bytesRead && + current.size === bytesRead && + before.uid === current.uid && + before.gid === current.gid + ); + } + requireValue(bytesRead <= 8192); + return { + path, + dev: before.dev, + ino: before.ino, + mode: before.mode & 0o7777, + uid: before.uid, + gid: before.gid, + size: bytesRead, + hash: hash(buffer.subarray(0, bytesRead)), + }; + } finally { + await descriptor.close(); + } +} +async function checkSources(pins: readonly SourcePin[]) { + for (const pin of pins) { + requireValue(canonical(await sourcePin(pin.path)) === canonical(pin)); + } +} +async function createMaterial(path: string, bytes: Uint8Array, mode: number) { + // Initialize only a newly created exclusive synthetic file. No chmod is allowed + // after bootstrap or against any original/caller source. + const file = await open(path, "wx", 0o600); + try { + await file.writeFile(bytes); + await file.chmod(mode); + await file.sync(); + } finally { + await file.close(); + } + const pin = await sourcePin(path); + requireValue(pin.mode === mode); + return pin; +} +function noPorts(row: Row) { + return ( + row.publishAll === false && + (row.ports === null || + (isRecord(row.ports) && Object.keys(row.ports).length === 0)) && + (row.runtimePorts === null || + (isRecord(row.runtimePorts) && + Object.values(row.runtimePorts).every( + (value) => + value === null || (Array.isArray(value) && value.length === 0) + ))) + ); +} +/** Compare immutable container facts, preserving every mount and label. State is separately inspected. */ +export function nativeProtectedFileContainerIdentity(value: unknown): string { + requireValue( + isRecord(value) && + Array.isArray(value.mounts) && + Array.isArray(value.networks) + ); + const { + running: _running, + status: _status, + paused: _paused, + networks: _networks, + ...identity + } = value; + return canonical({ ...identity, mounts: rows(value.mounts) }); +} +/** Only a stopped original may lose its null-valued exposed-port keys. Configured + * publication and every other immutable field remain part of the comparison. */ +export function nativeProtectedFileContainerMatches(opts: { + readonly pin: Row; + readonly current: Row; +}): boolean { + const { pin, current } = opts; + const stoppedPortKeysRemoved = + current.running === false && + current.paused === false && + current.status === "exited" && + noPorts(pin) && + noPorts(current) && + isRecord(pin.runtimePorts) && + Object.values(pin.runtimePorts).every((value) => value === null) && + isRecord(current.runtimePorts) && + Object.keys(current.runtimePorts).length === 0; + return ( + nativeProtectedFileContainerIdentity(pin) === + nativeProtectedFileContainerIdentity( + stoppedPortKeysRemoved + ? { ...current, runtimePorts: pin.runtimePorts } + : current + ) + ); +} +/** Fixture cleanup cannot target an added resource, changed creation birth, or a live container. */ +export function nativeProtectedFileRemovalMatches(opts: { + readonly pin: Row; + readonly current: Row; + readonly kind: Kind; +}): boolean { + if (opts.kind === "container") { + return ( + opts.current.running === false && + opts.current.paused === false && + opts.current.status === "exited" && + Array.isArray(opts.pin.networks) && + Array.isArray(opts.current.networks) && + canonical(rows(opts.pin.networks)) === + canonical(rows(opts.current.networks)) && + nativeProtectedFileContainerMatches(opts) + ); + } + if (opts.kind === "network") { + const { members: _before, ...pin } = opts.pin, + { members, ...current } = opts.current; + return ( + Array.isArray(members) && + members.length === 0 && + canonical(pin) === canonical(current) + ); + } + return canonical(opts.pin) === canonical(opts.current); +} +/** Authored retained inputs stay inside the shipped file8 family; cached-image admission uses fixed --pull never. */ +export function nativeProtectedFileRetainedInputs(opts: { + readonly instance: Pick; + readonly bun: string; + readonly db: string; +}) { + const { instance, bun, db } = opts; + return { + config: { name: instance.name }, + compose: legacy(instance, bun, db), + }; +} +function legacy( + instance: Pick, + bun: string, + db: string +) { + return { + name: instance.name, + services: { + db: { + image: db, + environment: { + POSTGRES_HOST_AUTH_METHOD: "trust", + POSTGRES_DB: "fixture", + }, + volumes: ["data:/var/lib/postgresql/data"], + restart: "no", + stop_grace_period: "1s", + }, + reader: { + image: bun, + command: LOOP, + restart: "no", + stop_grace_period: "1s", + configs: [ + { + source: "settings", + target: instance.grants[0]?.target, + mode: "0444", + }, + ], + secrets: [ + { source: "owner", target: instance.grants[1]?.target }, + { + source: "private", + target: instance.grants[2]?.target, + mode: "0600", + }, + ], + }, + ungranted: { + image: bun, + command: LOOP, + restart: "no", + stop_grace_period: "1s", + }, + }, + configs: { settings: { file: "../material/settings" } }, + secrets: { + owner: { file: "../material/owner" }, + private: { file: "../material/private" }, + }, + volumes: { data: { name: `${instance.name}_data` } }, + }; +} +/** Same controlled source for the mismatched-mode negative and legal0400 engine tripwire. */ +export function nativeProtectedFileModeRefusalInputs(opts: { + readonly name: string; + readonly bun: string; + readonly db: string; +}) { + return { + config: { name: opts.name }, + compose: { + name: opts.name, + services: { + db: { + image: opts.db, + environment: { + POSTGRES_HOST_AUTH_METHOD: "trust", + POSTGRES_DB: "fixture", + }, + volumes: ["data:/var/lib/postgresql/data"], + restart: "no", + stop_grace_period: "1s", + }, + reader: { + image: opts.bun, + command: LOOP, + restart: "no", + stop_grace_period: "1s", + secrets: [{ source: "private", mode: "0444" }], + }, + }, + secrets: { private: { file: "../material/private" } }, + volumes: { data: { name: `${opts.name}_data` } }, + }, + }; +} +async function setup(ctx: ScenarioContext) { + requireValue(process.env.HACK_E2E_KEEP === "1"); + const deadline = Date.now() + 600_000; + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + const remaining = (max = 90_000) => { + const time = deadline - Date.now(); + requireValue(time > 0 && !controller.signal.aborted); + return Math.min(max, time); + }; + const cli = process.env.HACK_E2E_CLI_BIN, + compiler = process.env.HACK_CONFIG_COMPILER_BINARY; + const expectedCli = process.env.HACK_E2E_CLI_SHA256, + expectedCompiler = process.env.HACK_E2E_COMPILER_SHA256, + expectedSource = process.env.HACK_E2E_SOURCE_REVISION; + const pluginPath = process.env.HACK_E2E_COMPOSE_PLUGIN_PATH, + pluginHash = process.env.HACK_E2E_COMPOSE_PLUGIN_SHA256; + requireValue( + process.env.HACK_E2E_PROTECTED_FILES_SLOT_RELEASED === expectedSource + ); + requireValue( + cli && + compiler && + expectedCli && + expectedCompiler && + expectedSource && + pluginPath && + pluginHash && + isAbsolute(cli) && + isAbsolute(compiler) && + HASH.test(expectedCli) && + HASH.test(expectedCompiler) && + /^[a-f0-9]{40}$/.test(expectedSource) + ); + const docker = Bun.which("docker"), + git = Bun.which("git"); + requireValue(docker && git && isAbsolute(docker) && isAbsolute(git)); + const requestedSocket = process.env.DOCKER_HOST; + requireValue( + typeof requestedSocket === "string" && requestedSocket.startsWith("unix://") + ); + const socketBinding = bindNativeComposeEngineSocketPath( + requestedSocket.slice(7) + ); + const socket = socketBinding.path, + socketInfo = await lstat(socket); + requireValue(socketInfo.isSocket()); + const artifacts = await Promise.all( + [ + { path: cli, role: "artifact" as const }, + { path: compiler, role: "artifact" as const }, + { path: docker, role: "artifact" as const }, + { path: git, role: "git" as const }, + { path: process.execPath, role: "artifact" as const }, + ].map(async ({ path, role }) => { + const physical = await realpath(path); + return { + path, + role, + physical, + info: await lstat(physical), + hash: hash(await readFile(physical)), + }; + }) + ); + requireValue( + artifacts[0]?.hash === expectedCli && + artifacts[1]?.hash === expectedCompiler && + artifacts.every((pin) => + nativeProtectedFileToolAllowed({ + role: pin.role, + platform: process.platform, + selected: pin.path, + physical: pin.physical, + regular: pin.info.isFile(), + symlink: pin.info.isSymbolicLink(), + uid: pin.info.uid, + mode: pin.info.mode, + nlink: pin.info.nlink, + }) + ) + ); + const home = join(ctx.tempRoot, "protected-home"), + captures = join(ctx.tempRoot, "protected-captures"); + await mkdir(home, { mode: 0o700 }); + await mkdir(join(home, ".docker"), { mode: 0o700 }); + await mkdir(captures, { mode: 0o700 }); + await writeFile(join(home, ".docker/config.json"), "{}\n", { + mode: 0o600, + flag: "wx", + }); + await provisionNativeNetworkFixtureComposePlugin({ + dockerConfig: join(home, ".docker"), + path: pluginPath, + expectedHash: pluginHash, + }); + const env = buildCliEnv({ + hackHome: join(home, "hack"), + extra: { + HOME: home, + DOCKER_HOST: `unix://${socket}`, + DOCKER_CONFIG: join(home, ".docker"), + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + HACK_CONFIG_COMPILER_BINARY: compiler, + HACK_RUNTIME_BACKEND: "compose", + HACK_DAEMON_AUTO_START: "0", + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "45000", + }, + }); + const settlement = createCompletedJobFixtureSettlement(); + let serial = 0, + remainingOutput = 16 * 1024 * 1024; + const command = async ( + argv: readonly string[], + cwd = ctx.tempRoot, + stdin?: unknown, + extra?: Readonly> + ) => { + settlement.assertConfirmed(); + requireValue(serial < 1000 && remainingOutput > 0); + const result = await runNativeFileFixtureCommand({ + argv, + cwd, + env: { ...env, ...extra }, + timeoutMs: remaining(), + signal: controller.signal, + stdin: + stdin === undefined ? undefined : Buffer.from(JSON.stringify(stdin)), + capturePrefix: join(captures, String(++serial).padStart(5, "0")), + onUnconfirmed: settlement.markUnconfirmed, + }); + remainingOutput -= + Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr); + requireValue(remainingOutput >= 0); + return result; + }; + const successful = (result: NativeFileFixtureCommandResult) => { + requireValue(result.exitCode === 0); + return result.stdout.trim(); + }; + const probe = async (args: readonly string[]) => + successful(await command([docker, ...args])); + const invoke = async ( + instance: Checkout, + args: readonly string[], + stdin?: unknown, + extra?: Readonly> + ) => { + await fence(); + return command( + [cli, "--path", instance.root, ...args], + instance.root, + stdin, + extra + ); + }; + const fence = async () => { + settlement.assertConfirmed(); + remaining(); + socketBinding.assertFresh(); + const currentSocket = await lstat(socket); + requireValue( + currentSocket.isSocket() && + currentSocket.dev === socketInfo.dev && + currentSocket.ino === socketInfo.ino && + currentSocket.mode === socketInfo.mode && + currentSocket.uid === socketInfo.uid && + currentSocket.gid === socketInfo.gid + ); + for (const pin of artifacts) { + requireValue((await realpath(pin.path)) === pin.physical); + const latest = await lstat(pin.physical); + requireValue( + latest.dev === pin.info.dev && + latest.ino === pin.info.ino && + latest.size === pin.info.size && + latest.mode === pin.info.mode && + latest.uid === pin.info.uid && + latest.gid === pin.info.gid && + latest.nlink === pin.info.nlink && + hash(await readFile(pin.physical)) === pin.hash + ); + } + requireValue( + successful(await command([git, "-C", REPO_ROOT, "rev-parse", "HEAD"])) === + expectedSource + ); + requireValue( + successful( + await command([ + git, + "-C", + REPO_ROOT, + "status", + "--porcelain", + "--untracked-files=no", + ]) + ) === "" + ); + remaining(); + }; + await fence(); + const inspect = async (kind: Kind, id: string) => { + const row = object( + await probe([kind, "inspect", "--format", FORMATS[kind], id]) + ); + if (kind === "container") { + requireValue(Array.isArray(row.networks) && Array.isArray(row.mounts)); + row.mounts = rows(row.mounts); + row.networks = rows( + row.networks.map((entry: unknown) => { + requireValue( + isRecord(entry) && + (entry.aliases === null || Array.isArray(entry.aliases)) + ); + if (Array.isArray(entry.aliases)) { + requireValue( + entry.aliases.every( + (alias: unknown) => typeof alias === "string" + ) && new Set(entry.aliases).size === entry.aliases.length + ); + return { ...entry, aliases: [...entry.aliases].sort() }; + } + return entry; + }) + ); + } + return row; + }; + const list = async (kind: Kind, project?: string) => { + const text = await probe([ + kind, + "ls", + ...(kind === "container" ? ["--all"] : []), + ...(kind === "volume" ? [] : ["--no-trunc"]), + "--format", + kind === "volume" ? "{{.Name}}" : "{{.ID}}", + ...(project ? ["--filter", `label=${PROJECT}=${project}`] : []), + ]); + const ids = text.split(/\s+/).filter(Boolean).sort(); + requireValue( + new Set(ids).size === ids.length && + (kind === "volume" || ids.every((id) => ID.test(id))) + ); + return ids; + }; + const engine = JSON.parse(await probe(["info", "--format", "{{json .ID}}"])); + requireValue(typeof engine === "string" && engine.length > 0); + const assertEngine = async () => + requireValue( + JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) === engine + ); + const tags = { + [BUN_TAG]: await probe([ + "image", + "inspect", + BUN_TAG, + "--format", + "{{.Id}}", + ]), + [DB_TAG]: await probe(["image", "inspect", DB_TAG, "--format", "{{.Id}}"]), + }; + requireValue(Object.values(tags).every((id) => IMAGE.test(id))); + const inventory = async (): Promise => { + await assertEngine(); + const resources: Record = { + container: [], + network: [], + volume: [], + }; + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind)) { + const row = await inspect(kind, id); + if (kind === "container") { + requireValue( + Array.isArray(row.mounts) && Array.isArray(row.networks) + ); + row.mounts = rows(row.mounts); + row.networks = rows(row.networks); + } else if (kind === "network") { + requireValue(Array.isArray(row.members)); + row.members = rows(row.members); + } + resources[kind].push(row); + } + } + const images = [ + ...new Set( + ( + await probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--format", + "{{.ID}}", + ]) + ) + .trim() + .split(/\s+/) + .filter(Boolean) + ), + ].sort(); + requireValue(images.every((id) => IMAGE.test(id))); + const currentTags = Object.fromEntries( + await Promise.all( + Object.keys(tags).map(async (tag) => [ + tag, + await probe(["image", "inspect", tag, "--format", "{{.Id}}"]), + ]) + ) + ); + await assertEngine(); + return { engine, ...resources, images, tags: currentTags }; + }; + const baseline = await inventory(); + requireValue(baseline.container.every((row) => row.running === false)); + const make = async ( + root: string, + name: string, + marker: string + ): Promise => { + await mkdir(join(root, ".hack"), { mode: 0o700, recursive: true }); + await mkdir(join(root, "material"), { mode: 0o700 }); + const grants: NativeFileFixtureGrant[] = [ + { + target: "/etc/fixture-settings", + mode: "0444", + bytes: Array.from(Buffer.from(`fixture-config-${marker}`)), + }, + { + target: "/run/secrets/owner", + mode: "0400", + bytes: Array.from(Buffer.from(`fixture-owner-${marker}`)), + }, + { + target: "/run/secrets/private", + mode: "0600", + bytes: Array.from(Buffer.from(`fixture-private-${marker}`)), + }, + ]; + const material: SourcePin[] = []; + for (const [index, filename] of [ + "settings", + "owner", + "private", + ].entries()) { + const grant = grants[index]; + requireValue(grant); + material.push( + await createMaterial( + join(root, "material", filename), + Buffer.from(grant.bytes), + Number.parseInt(grant.mode, 8) + ) + ); + } + return { root, name, marker, grants, material }; + }; + const suffix = randomBytes(4).toString("hex"); + const primaryRoot = join(ctx.tempRoot, "retained-primary"); + await mkdir(primaryRoot, { mode: 0o700 }); + successful(await command([git, "init", "--quiet", primaryRoot])); + successful( + await command([git, "-C", primaryRoot, "config", "user.name", "Fixture"]) + ); + successful( + await command([ + git, + "-C", + primaryRoot, + "config", + "user.email", + "fixture@example.invalid", + ]) + ); + await writeFile( + join(primaryRoot, ".gitignore"), + "material/\n.hack/.internal/\n.hack/hack.project.json\n", + { mode: 0o600 } + ); + successful(await command([git, "-C", primaryRoot, "add", ".gitignore"])); + successful( + await command([ + git, + "-C", + primaryRoot, + "commit", + "--quiet", + "-m", + "fixture: initial retained checkout", + ]) + ); + const secondRoot = join(ctx.tempRoot, "retained-second"); + successful( + await command([ + git, + "-C", + primaryRoot, + "worktree", + "add", + "--quiet", + "-b", + "file-second", + secondRoot, + ]) + ); + const first = await make(primaryRoot, `file-first-${suffix}`, "first"), + second = await make(secondRoot, `file-second-${suffix}`, "second"); + for (const instance of [first, second]) { + const inputs = nativeProtectedFileRetainedInputs({ + instance, + bun: tags[BUN_TAG], + db: tags[DB_TAG], + }); + await writeFile( + join(instance.root, ".hack/hack.config.json"), + JSON.stringify(inputs.config), + { mode: 0o600, flag: "wx" } + ); + await writeFile( + join(instance.root, ".hack/docker-compose.yml"), + JSON.stringify(inputs.compose), + { mode: 0o600, flag: "wx" } + ); + successful( + await command([ + git, + "-C", + instance.root, + "add", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ]) + ); + successful( + await command([ + git, + "-C", + instance.root, + "commit", + "--quiet", + "-m", + "fixture: isolated retained file inputs", + ]) + ); + } + const stop = () => { + process.off("SIGINT", abort); + process.off("SIGTERM", abort); + }; + return { + ctx, + deadline, + remaining, + env, + cli, + git, + docker, + home, + command, + successful, + probe, + invoke, + fence, + inspect, + list, + assertEngine, + inventory, + baseline, + first, + second, + stop, + assertChildrenSettled: settlement.assertConfirmed, + }; +} +type Fixture = Awaited>; +async function fixtureEnvironment( + h: Fixture, + run: () => Promise +): Promise { + const prior = new Map(); + for (const [key, value] of Object.entries(h.env)) { + prior.set(key, process.env[key]); + process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of prior) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} +function containerTopology(entries: readonly unknown[]) { + return entries.map((entry) => { + requireValue(isRecord(entry)); + return { name: entry.name, aliases: entry.aliases }; + }); +} +function requireOriginalResourceIdentity(kind: Kind, pin: Row, current: Row) { + if (kind === "container") { + requireValue( + nativeProtectedFileContainerMatches({ pin, current }) && + Array.isArray(pin.networks) && + Array.isArray(current.networks) + ); + requireValue( + canonical(rows(containerTopology(pin.networks))) === + canonical(rows(containerTopology(current.networks))) + ); + } else if (kind === "network") { + const { members: _a, ...a } = pin, + { members: _b, ...b } = current; + requireValue(canonical(a) === canonical(b)); + } else { + requireValue(canonical(pin) === canonical(current)); + } +} +function requireOriginalResources( + current: Record, + original: Record +) { + for (const kind of ["container", "network", "volume"] as const) { + const before = original[kind]; + requireValue(before.length === current[kind].length); + for (const pin of before) { + const identity = kind === "volume" ? "name" : "id"; + const row = current[kind].find( + (value) => value[identity] === pin[identity] + ); + requireValue(row); + requireOriginalResourceIdentity(kind, pin, row); + } + } +} +async function resources( + h: Fixture, + instance: Checkout +): Promise> { + await h.assertEngine(); + const result: Record = { + container: [], + network: [], + volume: [], + }; + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await h.list(kind, instance.name)) { + result[kind].push(await h.inspect(kind, id)); + } + } + requireValue( + result.container.length === 3 && + result.network.length === 1 && + result.volume.length === 1 + ); + const roles = new Set(); + for (const row of result.container) { + requireValue( + typeof row.id === "string" && + ID.test(row.id) && + isRecord(row.labels) && + row.labels[PROJECT] === instance.name && + typeof row.labels["com.docker.compose.service"] === "string" && + ["db", "reader", "ungranted"].includes( + row.labels["com.docker.compose.service"] + ) && + row.labels["com.docker.compose.container-number"] === "1" && + row.labels["com.docker.compose.oneoff"] === "False" && + row.labels["com.docker.compose.project.working_dir"] === + join(instance.root, ".hack") && + row.labels["com.docker.compose.project.config_files"] === + join(instance.root, ".hack/docker-compose.yml") && + !Object.keys(row.labels).some((key) => + key.startsWith("io.hack.native-config.") + ) && + noPorts(row) && + Array.isArray(row.mounts) && + Array.isArray(row.networks) && + row.networks.length === 1 + ); + const service = row.labels["com.docker.compose.service"]; + requireValue(!roles.has(service)); + roles.add(service); + const mounts = + service === "db" + ? [ + { + type: "volume", + name: `${instance.name}_data`, + target: "/var/lib/postgresql/data", + rw: true, + }, + ] + : service === "reader" + ? instance.grants.map((grant, index) => ({ + type: "bind", + name: "", + source: instance.material[index]?.path, + target: grant.target, + rw: false, + })) + : []; + const projected = row.mounts.map((mount: unknown) => { + requireValue(isRecord(mount)); + return service === "db" + ? { + type: mount.type, + name: mount.name, + target: mount.target, + rw: mount.rw, + } + : mount; + }); + requireValue(canonical(rows(projected)) === canonical(rows(mounts))); + const endpoint = row.networks[0]; + requireValue( + isRecord(endpoint) && + endpoint.name === `${instance.name}_default` && + (endpoint.id === result.network[0]?.id || + (!row.running && endpoint.id === "")) + ); + } + const network = result.network[0], + volume = result.volume[0]; + requireValue( + network && + typeof network.id === "string" && + ID.test(network.id) && + network.name === `${instance.name}_default` && + network.driver === "bridge" && + network.scope === "local" && + network.internal === false && + isRecord(network.labels) && + network.labels[PROJECT] === instance.name && + network.labels["com.docker.compose.network"] === "default" && + Array.isArray(network.members) && + canonical(rows(network.members)) === + canonical( + rows( + result.container.filter((row) => row.running).map((row) => row.id) + ) + ) + ); + requireValue( + volume && + volume.name === `${instance.name}_data` && + volume.driver === "local" && + volume.scope === "local" && + (volume.options === null || + (isRecord(volume.options) && + Object.keys(volume.options).length === 0)) && + isRecord(volume.labels) && + volume.labels[PROJECT] === instance.name && + volume.labels["com.docker.compose.volume"] === "data" && + typeof volume.createdAt === "string" && + volume.createdAt.length > 0 + ); + if (instance.original) { + requireOriginalResources(result, instance.original.resources); + } + await h.assertEngine(); + return result; +} +function container( + rows: Record, + service: string +): string { + const row = rows.container.find( + (item) => + isRecord(item.labels) && + item.labels["com.docker.compose.service"] === service + ); + requireValue(row && typeof row.id === "string" && ID.test(row.id)); + return row.id; +} +async function sourcePair(instance: Checkout) { + return [ + await sourcePin(join(instance.root, ".hack/hack.config.json")), + await sourcePin(join(instance.root, ".hack/docker-compose.yml")), + ]; +} +async function sql(h: Fixture, instance: Checkout, query: string) { + const rows = await resources(h, instance); + return h.successful( + await h.command([ + h.docker, + "container", + "exec", + container(rows, "db"), + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + query, + ]) + ); +} +async function waitSql(h: Fixture, instance: Checkout) { + const limit = Date.now() + Math.min(30_000, h.remaining()); + while (true) { + h.remaining(); + const rows = await resources(h, instance), + result = await h.command([ + h.docker, + "container", + "exec", + container(rows, "db"), + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + "SELECT 1", + ]); + if (result.exitCode === 0 && result.stdout.trim() === "1") { + return; + } + requireValue(Date.now() < limit); + await Bun.sleep(100); + } +} +async function retainedReceipt(instance: Checkout) { + const path = join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const before = await lstat(path); + requireValue( + before.isFile() && + !before.isSymbolicLink() && + before.nlink === 1 && + before.size <= 64 * 1024 && + (before.mode & 0o777) === 0o600 + ); + const raw = await readFile(path, "utf8"), + after = await lstat(path); + requireValue( + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size + ); + const row = object(raw); + requireValue( + row.adoption_receipt_version === 8 && + isRecord(row.prepared) && + isRecord(row.publication) && + row.publication.phase === "active" && + canonical(row.publication.generation) === canonical(row.prepared) + ); + return row; +} +async function savedRetained(h: Fixture, instance: Checkout) { + const receipt = await retainedReceipt(instance); + requireValue(receipt.pendingOperation === null); + await fixtureEnvironment(h, async () => { + const { openLegacyComposeAdoptedGenerationStore } = await import( + "../../../src/lib/native-compose-adoption-generation.ts" + ); + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot: instance.root, + mode: "saved", + }); + try { + const generation = await store.loadActive(); + requireValue(generation?.report.adoption_generation_version === 8); + await store.withLease({ + generation, + material: "saved", + run: async (input) => { + const sourceHashes = [ + hash(Buffer.from(input.configText)), + hash(Buffer.from(input.composeText)), + ]; + requireValue( + canonical(sourceHashes) === + canonical(instance.original?.sources.map((pin) => pin.hash)) + ); + const rows = await resources(h, instance); + requireValue( + canonical( + [...input.binding.containers.map((row) => row.id)].sort() + ) === canonical([...rows.container.map((row) => row.id)].sort()) + ); + }, + }); + } finally { + await store.close(); + } + }); + return receipt; +} +async function permissionRead(h: Fixture, instance: Checkout) { + await checkSources(instance.material); + await h.fence(); + const rows = await resources(h, instance); + requireValue(rows.container.every((row) => row.running === true)); + const reader = container(rows, "reader"), + ungranted = container(rows, "ungranted"); + await savedRetained(h, instance); + const read = h.successful( + await h.invoke( + instance, + ["exec", "reader", "--", "bun", "-e", nativeFileFixtureReadProgram], + instance.grants + ) + ); + const guest = readNativeFileFixtureGuest({ + text: read, + expected: instance.grants, + }); + requireValue(guest.members.length === 3); + const protectedMembers = guest.members.filter( + (member) => member.mode !== "0444" + ), + nonowner = nativeFileFixtureNonowner(guest); + const protectedOwners = new Set(protectedMembers.map((member) => member.uid)); + requireValue(protectedOwners.size === 1); + const ownerUid = protectedMembers[0]?.uid; + requireValue(ownerUid !== undefined); + await h.fence(); + const ownerRead = h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + String(ownerUid), + reader, + "bun", + "-e", + nativeFileFixtureReadProgram, + ], + instance.root, + instance.grants + ) + ); + const authorized = readNativeFileFixtureGuest({ + text: ownerRead, + expected: instance.grants, + }); + requireValue( + authorized.uid === ownerUid && + canonical(authorized.members) === canonical(guest.members) + ); + await h.fence(); + requireValue( + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + "0", + reader, + "bun", + "-e", + nativeFileFixtureWriteProgram, + ], + instance.root, + guest.members + ) + ) === "exact-readonly-files" + ); + await h.fence(); + requireValue( + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + "--interactive", + "--user", + String(nonowner), + reader, + "bun", + "-e", + nativeFileFixtureDeniedProgram, + ], + instance.root, + protectedMembers + ) + ) === "exact-nonowner-read-refused" + ); + await h.fence(); + requireValue( + h.successful( + await h.invoke( + instance, + [ + "exec", + "ungranted", + "--", + "bun", + "-e", + nativeFileFixtureAbsentProgram, + ], + guest.members + ) + ) === "exact-ungranted-files-absent" + ); + // Independently bind the ungranted ID; no config/secret target is delivered there. + const absent = await h.inspect("container", ungranted); + requireValue( + Array.isArray(absent.mounts) && + absent.mounts.every( + (value: unknown) => + isRecord(value) && + !instance.grants.some((grant) => grant.target === value.target) + ) + ); + await checkSources(instance.material); + await h.fence(); + return guest; +} +async function bootstrap(h: Fixture, instance: Checkout) { + for (const kind of ["container", "network", "volume"] as const) { + requireValue((await h.list(kind, instance.name)).length === 0); + } + await h.fence(); + await checkSources(instance.material); + const sources = await sourcePair(instance); + const result = await h.command( + [ + h.docker, + "compose", + "--project-name", + instance.name, + "--project-directory", + join(instance.root, ".hack"), + "--env-file", + "/dev/null", + "--file", + join(instance.root, ".hack/docker-compose.yml"), + "up", + "--detach", + "--no-build", + "--pull", + "never", + ], + instance.root + ); + // Latch the complete exact inventory before any later readiness or permission assertion. + const observed = await resources(h, instance); + instance.original = { resources: observed, sources }; + instance.bootstrapped = true; + h.successful(result); + await waitSql(h, instance); + requireValue( + (await sql( + h, + instance, + "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename='marker'" + )) === "0" + ); + // Exactly one seed call. No upsert, polling write or automatic retry. + await h.fence(); + h.successful( + await h.command( + [ + h.docker, + "container", + "exec", + container(observed, "db"), + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + "fixture", + "-c", + `CREATE TABLE marker(id integer primary key,value text not null); INSERT INTO marker VALUES (1,'${instance.marker}');`, + ], + instance.root + ) + ); + requireValue( + (await sql(h, instance, "SELECT value FROM marker WHERE id=1")) === + instance.marker + ); + await checkSources(instance.material); + await checkSources(sources); +} +async function retainedRunning(h: Fixture, instance: Checkout) { + requireValue( + (await sql(h, instance, "SELECT value FROM marker WHERE id=1")) === + instance.marker + ); + await savedRetained(h, instance); + return permissionRead(h, instance); +} +async function activeSourceRefusal(h: Fixture) { + const instance = h.first, + before = await savedRetained(h, instance), + originals = await resources(h, instance), + sibling = await resources(h, h.second); + requireValue(originals.container.every((row) => row.running === true)); + const directory = join(h.ctx.tempRoot, "source-readonly-guard"), + marker = join(directory, "unexpected-effect.json"); + await mkdir(directory, { mode: 0o700 }); + const prepared = before.prepared; + requireValue(isRecord(prepared) && typeof prepared.id === "string"); + const scope = { + projectRoot: instance.root, + project: instance.name, + containerIds: originals.container.map((row) => row.id), + networkId: originals.network[0]?.id, + volumeName: originals.volume[0]?.name, + generationId: prepared.id, + reader: container(originals, "reader"), + targets: instance.grants.map((grant) => grant.target), + }; + const control = fileURLToPath( + new URL("../native-file-permission-control.ts", import.meta.url) + ); + await writeFile( + join(directory, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';\nimport {nativeProtectedFileReadAllowed} from ${JSON.stringify(control)};\nconst args=process.argv.slice(2),engine=${JSON.stringify(h.docker)},scope=${JSON.stringify(scope)};\nconst proof=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'});const text=await new Response(proof.stdout).text();if(text.length>4096||await proof.exited!==0||JSON.parse(text)!==${JSON.stringify(h.baseline.engine)})process.exit(95);\nif(!nativeProtectedFileReadAllowed({...scope,args})){const file=await open(${JSON.stringify(marker)},'wx',0o600);try{await file.writeFile(JSON.stringify({version:1,stage:'effect-refused-before-forwarding'}));await file.sync()}finally{await file.close()}process.exit(98)}\nconst child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);\n`, + { mode: 0o700, flag: "wx" } + ); + const path = h.env.PATH; + requireValue(path); + const withheld = join(h.ctx.tempRoot, "active-material-withheld"); + await h.fence(); + await rename(join(instance.root, "material"), withheld); + try { + for (const args of [ + ["restart", "--json"], + ["--json", "exec", "reader", "--", "true"], + ]) { + const refused = await h.invoke(instance, args, undefined, { + PATH: `${directory}:${path}`, + }); + // Always inspect the journal and physical originals independently of the + // diagnostic, so an arbitrary earlier refusal cannot hide a mutation. + const receipt = await retainedReceipt(instance), + observed = await resources(h, instance), + siblingAfter = await resources(h, h.second); + requireValue( + refused.exitCode !== 0 && + nativeProtectedFileStateRefused(object(refused.stdout)) + ); + requireValue( + canonical(receipt) === canonical(before) && + receipt.pendingOperation === null && + canonical(observed) === canonical(originals) && + canonical(siblingAfter) === canonical(sibling) + ); + try { + await lstat(marker); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + } + } finally { + h.assertChildrenSettled(); + await rename(withheld, join(instance.root, "material")); + } + await checkSources(instance.material); + await h.fence(); + h.successful(await h.invoke(instance, ["exec", "reader", "--", "true"])); + requireValue( + canonical(await resources(h, instance)) === canonical(originals) && + canonical(await resources(h, h.second)) === canonical(sibling) + ); + h.ctx.log( + "Missing current source refused restart and exec before effects or journal changes; original source incarnation restored" + ); +} +async function interruptedStart(h: Fixture) { + const instance = h.first, + sibling = h.second; + h.successful(await h.invoke(instance, ["down", "--json"])); + const stopped = await resources(h, instance); + requireValue(stopped.container.every((row) => row.running === false)); + const before = await savedRetained(h, instance), + siblingBefore = await resources(h, sibling); + const directory = join(h.ctx.tempRoot, "interrupted-start"), + marker = join(directory, "armed.json"); + await mkdir(directory, { mode: 0o700 }); + const ids = stopped.container.map((row) => row.id); + const wrapper = join(directory, "docker"); + const control = fileURLToPath( + new URL("../native-file-permission-control.ts", import.meta.url) + ); + const prepared = before.prepared; + requireValue(isRecord(prepared) && typeof prepared.id === "string"); + const scope = { + projectRoot: instance.root, + project: instance.name, + containerIds: ids, + networkId: stopped.network[0]?.id, + volumeName: stopped.volume[0]?.name, + generationId: prepared.id, + reader: container(stopped, "reader"), + targets: instance.grants.map((grant) => grant.target), + }; + await writeFile( + wrapper, + `#!${process.execPath}\nimport {open,lstat,readFile} from 'node:fs/promises';\nimport {nativeProtectedFileReadAllowed,nativeProtectedFileStartAllowed} from ${JSON.stringify(control)};\nconst args=process.argv.slice(2),engine=${JSON.stringify(h.docker)},scope=${JSON.stringify(scope)};\nconst proof=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'});const engineText=await new Response(proof.stdout).text();if(engineText.length>4096||await proof.exited!==0||JSON.parse(engineText)!==${JSON.stringify(h.baseline.engine)})process.exit(95);\nconst receiptPath=${JSON.stringify(join(instance.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"))};const info=await lstat(receiptPath);if(!info.isFile()||info.isSymbolicLink()||info.nlink!==1||info.size>65536||(info.mode&511)!==384)process.exit(98);const receipt=JSON.parse(await readFile(receiptPath,'utf8'));\nif(args[0]==='container'&&args[1]==='start'){\nif(!nativeProtectedFileStartAllowed({args,ids:${JSON.stringify(ids)},prepared:${JSON.stringify(prepared)},receipt}))process.exit(98);\nconst file=await open(${JSON.stringify(marker)},'wx',0o600);try{await file.writeFile(JSON.stringify({version:1,stage:'journaled-start-before-effect',ids:${JSON.stringify(ids)}}));await file.sync();}finally{await file.close()}process.exit(71);}\nif(!nativeProtectedFileReadAllowed({...scope,args}))process.exit(98);\nconst child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);\n`, + { mode: 0o700, flag: "wx" } + ); + const originalPath = h.env.PATH; + requireValue(originalPath); + const failed = await h.invoke( + instance, + ["up", "--detach", "--json"], + undefined, + { PATH: `${directory}:${originalPath}` } + ); + requireValue(failed.exitCode !== 0); + const admitted = object(await readFile(marker, "utf8")); + requireValue( + admitted.stage === "journaled-start-before-effect" && + canonical(admitted.ids) === canonical(ids) + ); + const pending = await retainedReceipt(instance); + requireValue( + isRecord(pending.pendingOperation) && + pending.pendingOperation.operation === "start" && + canonical(pending.pendingOperation.generation) === + canonical( + before.publication && isRecord(before.publication) + ? before.publication.generation + : null + ) && + canonical(pending.prepared) === canonical(before.prepared) + ); + requireValue(canonical(await resources(h, instance)) === canonical(stopped)); + requireValue( + canonical(await resources(h, sibling)) === canonical(siblingBefore) + ); + // Only this synthetic source directory is withheld. Saved stop/recovery and rollback must not read it. + const withheld = join(h.ctx.tempRoot, "first-material-withheld"); + await rename(join(instance.root, "material"), withheld); + try { + const exec = await h.invoke(instance, ["exec", "reader", "--", "true"]); + requireValue(exec.exitCode !== 0); + h.successful(await h.invoke(instance, ["down", "--recover", "--json"])); + requireValue((await retainedReceipt(instance)).pendingOperation === null); + requireValue( + (await resources(h, instance)).container.every( + (row) => row.running === false + ) + ); + h.successful( + await h.invoke(instance, ["config", "adopt", "--rollback", "--json"]) + ); + instance.rolledBack = true; + await checkSources(instance.original?.sources ?? []); + requireValue( + canonical(await resources(h, sibling)) === canonical(siblingBefore) + ); + } finally { + h.assertChildrenSettled(); + await rename(withheld, join(instance.root, "material")); + } + await checkSources(instance.material); + h.ctx.log( + "Journaled retained start interruption kept exact originals; source-free saved stop recovery and rollback preserved sibling" + ); +} +async function retainedFlow(h: Fixture) { + await bootstrap(h, h.first); + await bootstrap(h, h.second); + for (const instance of [h.first, h.second]) { + const before = await resources(h, instance); + const preview = object( + h.successful( + await h.invoke(instance, [ + "config", + "adopt", + "--dry-run", + "--stop", + "--json", + ]) + ) + ); + requireValue(preview.complete === true); + requireValue(canonical(await resources(h, instance)) === canonical(before)); + h.successful( + await h.invoke(instance, ["config", "adopt", "--stop", "--json"]) + ); + requireValue( + (await resources(h, instance)).container.every( + (row) => row.running === false + ) + ); + h.successful(await h.invoke(instance, ["up", "--detach", "--json"])); + await waitSql(h, instance); + const guest = await retainedRunning(h, instance); + h.successful(await h.invoke(instance, ["restart", "--json"])); + await waitSql(h, instance); + requireValue( + canonical(await retainedRunning(h, instance)) === canonical(guest) + ); + await h.fence(); + } + requireValue( + h.first.original && + h.second.original && + h.first.original.resources.volume[0]?.name !== + h.second.original.resources.volume[0]?.name && + h.first.material.every((pin) => + h.second.material.every( + (other) => pin.dev !== other.dev || pin.ino !== other.ino + ) + ) + ); + await activeSourceRefusal(h); + await interruptedStart(h); + h.successful(await h.invoke(h.second, ["down", "--json"])); + requireValue( + (await resources(h, h.second)).container.every( + (row) => row.running === false + ) + ); + h.successful( + await h.invoke(h.second, ["config", "adopt", "--rollback", "--json"]) + ); + h.second.rolledBack = true; + await checkSources(h.second.original.sources); + await checkSources(h.second.material); +} +async function removeRetained(h: Fixture, instance: Checkout) { + if (!instance.bootstrapped) { + return; + } + requireValue(instance.original); + // Never dispatch a cleanup down through the legacy path if adoption did not + // reach this exact active file8 receipt. Unknown bootstrap/publication is + // retained for explicit inspection instead of inferred or replayed. + if (!instance.rolledBack) { + h.successful( + await nativeProtectedFileCleanupDown({ + receipt: await retainedReceipt(instance), + invoke: (args) => h.invoke(instance, args), + }) + ); + h.successful( + await h.invoke(instance, ["config", "adopt", "--rollback", "--json"]) + ); + instance.rolledBack = true; + } + const stopped = await resources(h, instance); + requireValue( + stopped.container.every( + (row) => + row.running === false && row.paused === false && row.status === "exited" + ) + ); + await checkSources(instance.original.sources); + await checkSources(instance.material); + await h.fence(); + const remainingIds = stopped.container.map((row) => row.id); + requireValue( + remainingIds.every((id) => typeof id === "string" && ID.test(id)) + ); + for (const pin of stopped.container) { + await h.fence(); + await h.assertEngine(); + requireValue( + canonical(await h.list("container", instance.name)) === + canonical([...remainingIds].sort()) + ); + requireValue( + typeof pin.id === "string" && + nativeProtectedFileRemovalMatches({ + pin, + current: await h.inspect("container", pin.id), + kind: "container", + }) + ); + h.successful(await h.command([h.docker, "container", "rm", pin.id])); + remainingIds.splice(remainingIds.indexOf(pin.id), 1); + } + requireValue((await h.list("container", instance.name)).length === 0); + const network = stopped.network[0], + volume = stopped.volume[0]; + requireValue( + network && + volume && + typeof network.id === "string" && + typeof volume.name === "string" + ); + await h.fence(); + await h.assertEngine(); + requireValue( + canonical(await h.list("network", instance.name)) === + canonical([network.id]) + ); + requireValue( + nativeProtectedFileRemovalMatches({ + pin: network, + current: await h.inspect("network", network.id), + kind: "network", + }) + ); + h.successful(await h.command([h.docker, "network", "rm", network.id])); + await h.fence(); + await h.assertEngine(); + requireValue( + (await h.list("network", instance.name)).length === 0 && + canonical(await h.list("volume", instance.name)) === + canonical([volume.name]) + ); + requireValue( + ( + await h.probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--filter", + `volume=${volume.name}`, + "--format", + "{{.ID}}", + ]) + ).trim() === "" + ); + requireValue( + nativeProtectedFileRemovalMatches({ + pin: volume, + current: await h.inspect("volume", volume.name), + kind: "volume", + }) + ); + h.successful(await h.command([h.docker, "volume", "rm", volume.name])); + for (const kind of ["container", "network", "volume"] as const) { + requireValue((await h.list(kind, instance.name)).length === 0); + } + await checkSources(instance.original.sources); + await checkSources(instance.material); +} +async function refusalBeforeEngine(h: Fixture) { + const root = join(h.ctx.tempRoot, "invalid-mode"), + name = `file-invalid-${randomBytes(4).toString("hex")}`; + await mkdir(join(root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(root, "material"), { mode: 0o700 }); + h.successful(await h.command([h.git, "init", "--quiet", root])); + const source = await createMaterial( + join(root, "material/private"), + Buffer.from("synthetic-private-refusal"), + 0o400 + ); + const bun = h.baseline.tags[BUN_TAG], + db = h.baseline.tags[DB_TAG]; + requireValue(typeof bun === "string" && typeof db === "string"); + const { config, compose } = nativeProtectedFileModeRefusalInputs({ + name, + bun, + db, + }); + await writeFile( + join(root, ".hack/hack.config.json"), + JSON.stringify(config), + { flag: "wx", mode: 0o600 } + ); + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify(compose), + { flag: "wx", mode: 0o600 } + ); + h.successful( + await h.command([h.git, "-C", root, "config", "user.name", "Fixture"]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "config", + "user.email", + "fixture@example.invalid", + ]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "add", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ]) + ); + h.successful( + await h.command([ + h.git, + "-C", + root, + "commit", + "--quiet", + "-m", + "fixture: explicit-mode source refusal", + ]) + ); + const shimRoot = join(h.ctx.tempRoot, "no-engine-mode-refusal"), + marker = join(shimRoot, "invoked"); + await mkdir(shimRoot, { mode: 0o700 }); + await writeFile( + join(shimRoot, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';const file=await open(${JSON.stringify(marker)},'wx',0o600);await file.writeFile('unexpected-engine-query');await file.close();process.exit(98);\n`, + { flag: "wx", mode: 0o700 } + ); + const result = await h.invoke( + { root, name, marker: "invalid", material: [source], grants: [] }, + ["config", "adopt", "--dry-run", "--stop", "--json"], + undefined, + { PATH: `${shimRoot}:${h.env.PATH}` } + ); + requireValue(result.exitCode !== 0); + const report = object(result.stdout); + requireValue(report.complete === false); + try { + await lstat(marker); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + for (const path of [ + join(root, ".hack/.internal/legacy-compose-adoption-v1"), + join(root, ".hack/hack.project.json"), + ]) { + try { + await lstat(path); + requireValue(false); + } catch (error: unknown) { + requireValue(isRecord(error) && error.code === "ENOENT"); + } + } + // Positive control changes only the explicit declaration. Same source mode/owner + // now passes the material gate and reaches the rejecting (never-forwarding) shim. + compose.services.reader.secrets[0]!.mode = "0400"; + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify(compose) + ); + const permitted = await h.invoke( + { root, name, marker: "valid-source", material: [source], grants: [] }, + ["config", "adopt", "--dry-run", "--stop", "--json"], + undefined, + { PATH: `${shimRoot}:${h.env.PATH}` } + ); + requireValue(permitted.exitCode !== 0); + requireValue((await readFile(marker, "utf8")) === "unexpected-engine-query"); + await checkSources([source]); + requireValue(canonical(await h.inventory()) === canonical(h.baseline)); +} +/** + * Explicit, synthetic-only retained-only permission qualification. No ports, + * pulls, builds, global DNS/trust, caller material or broad cleanup. Every original + * remains its exact engine/ID/birth; stop/rollback precede non-forced removals. + * A failure preserves bounded private captures and cannot be converted into pass. + */ +export const nativeConfigProtectedFilesScenario: Scenario = { + name: "native-config-protected-files", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "two original retained worktrees preserve protected guest access, readonly binds, SQL and saved recovery", + run: async (ctx) => { + const h = await setup(ctx); + try { + await runWithOwnedCleanup({ + run: async () => { + await writeFile( + join(ctx.tempRoot, "protected-intent.json"), + JSON.stringify({ + version: 1, + source: process.env.HACK_E2E_SOURCE_REVISION, + scope: "two original retained synthetic file checkouts", + replay: "refused", + }), + { mode: 0o600, flag: "wx" } + ); + await refusalBeforeEngine(h); + await retainedFlow(h); + }, + cleanup: async () => { + h.assertChildrenSettled(); + await removeRetained(h, h.first); + await removeRetained(h, h.second); + await h.fence(); + requireValue( + canonical(await h.inventory()) === canonical(h.baseline) + ); + h.remaining(); + ctx.log( + "Exact stopped original cleanup and full daemon/container/network/volume-birth/image/tag baseline verified" + ); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Protected file exact cleanup is incomplete; original failure, private captures and saved anchors retained" + ), + }); + await h.fence(); + h.remaining(); + ctx.log("Retained protected-file access/lifecycle qualification passed"); + } finally { + h.stop(); + } + }, +}; diff --git a/tests/e2e/scenarios/native-config-routing.ts b/tests/e2e/scenarios/native-config-routing.ts index 089415317..bf21fcbfc 100644 --- a/tests/e2e/scenarios/native-config-routing.ts +++ b/tests/e2e/scenarios/native-config-routing.ts @@ -1,11 +1,9 @@ -import { createHash, randomBytes, X509Certificate } from "node:crypto"; +import { createHash } from "node:crypto"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import type { Project } from "../../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "../../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../../src/lib/native-compose-generation.ts"; -import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; -import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; import { type NativeRoutingResolution, parseNativeRoutingResolution, @@ -33,45 +31,20 @@ import { ROUTED_RUN_LITERAL, } from "./native-config-routed-run.ts"; +import { + NATIVE_ROUTING_FIXTURE_TMPFS as PRIVATE_TMPFS, + prepareNativeRoutingFixtureIngress, +} from "./native-routing-fixture-ingress.ts"; + const TIMEOUT = 180_000; -const OBSERVATION_WINDOW = 30_000; const OBJECT_ID = /^[a-f0-9]{64}$/; -const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; const PROJECT_LABEL = "com.docker.compose.project"; -const SERVICE_LABEL = "com.docker.compose.service"; const OWNER_LABEL = "io.hack.native-config.owner"; const INSTANCE_LABEL = "io.hack.native-config.instance"; const STORAGE_LABEL = "io.hack.native-config.storage"; -const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; -const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; -const PROXY_PROJECT = "hack-dev-proxy"; -const PROXY_SERVICE = "caddy"; -const NETWORK = "hack-dev"; -const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; -const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; -const PRIVATE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; const APP = "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.BRANCH_MARKER)}})"; -const PRESERVED_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; -const PROXY_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; - -/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ -export function proxyHasNoPublishedPorts( - info: Readonly> -): boolean { - return ( - info.publishAll === false && - (info.ports === null || - (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && - (info.runtimePorts === null || - (isRecord(info.runtimePorts) && - Object.values(info.runtimePorts).every((value) => value === null))) - ); -} - type Docker = (args: readonly string[]) => Promise; type Runtime = { readonly composeProject: string; @@ -362,58 +335,19 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result.stdout.trim(); }; - const selectors = [ - "--filter", - `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--filter", - `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, - ]; - // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, - // start, rename or remove them merely to make this scenario runnable. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse native routing fixture while any global Caddy selector is running", - }); - const preserved = ids( - await docker(["ps", "--no-trunc", "-aq", ...selectors]) - ); - const preservedSnapshots = new Map(); - for (const id of preserved) { - const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); - expect({ - that: object(text).running === false, - message: "Pre-existing proxy must be stopped", - }); - preservedSnapshots.set(id, text); - } - expect({ - that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", - message: "Native routing fixture requires a Linux Docker daemon", - }); - await docker(["compose", "version"]); - const networkId = await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ]); - expect({ - that: OBJECT_ID.test(networkId), - message: "Existing hack-dev network identity is required", - }); - const image = async (tag: string): Promise => { - const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); - expect({ - that: IMAGE_ID.test(id), - message: - "Fixture images must already be cached; never pull during acceptance", - }); - return id; - }; - const bunImage = await image("oven/bun:1.4.2-slim"); - const caddyImage = await image(CADDY_IMAGE); + const ingress = await prepareNativeRoutingFixtureIngress({ ctx, docker }); + const { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + admin, + tls, + absent, + preservedUnchanged, + } = ingress; const nativeInventory = async (): Promise => ids( await docker([ @@ -425,12 +359,7 @@ export const nativeConfigRoutingScenario: Scenario = { ]) ).join("\n"); const nativeBefore = await nativeInventory(); - const token = randomBytes(16).toString("hex"); - const proxyName = `e2e-native-routing-proxy-${token}`; - const canaryHost = `canary-${token}.test`; - const canaryMarker = `proxy-canary-${token}`; const privateRoot = await realpath(ctx.tempRoot); - let proxyId: string | null = null; let claimsRoot: string | null = null; const attempted = new Set(); const successfulStarts = new Set(); @@ -469,163 +398,6 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result; }; - const currentProxy = (): string => { - if (!(proxyId && OBJECT_ID.test(proxyId))) { - throw new Error("Exact owned fixture proxy ID is unavailable"); - } - return proxyId; - }; - const preservedUnchanged = async (): Promise => { - expect({ - that: - (await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ])) === networkId, - message: "External hack-dev network must retain its exact identity", - }); - for (const [id, before] of preservedSnapshots) { - expect({ - that: - (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === - before, - message: "Stopped user Caddy selectors must remain unchanged", - }); - } - }; - const proxyOwned = async (): Promise => { - const info = object( - await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) - ); - expect({ - that: - info.id === currentProxy() && - info.name === `/${proxyName}` && - info.owner === token && - info.project === PROXY_PROJECT && - info.service === PROXY_SERVICE && - info.networkMode === networkId && - (info.running === false || info.network === networkId) && - proxyHasNoPublishedPorts(info) && - Array.isArray(info.mounts) && - info.mounts.length === 1 && - info.mounts.every( - (mount: unknown) => - isRecord(mount) && - mount.Type === "bind" && - mount.Destination === "/var/run/docker.sock" && - mount.Source === "/var/run/docker.sock" && - mount.RW === false - ) && - isRecord(info.tmpfs) && - Object.keys(info.tmpfs).length === 2 && - info.tmpfs["/data"] === PRIVATE_TMPFS && - info.tmpfs["/config"] === PRIVATE_TMPFS, - message: - "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", - }); - }; - const admin = async (): Promise => { - await proxyOwned(); - const text = await docker([ - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=http", - "--max-time", - "10", - "--max-redirs", - "0", - "--write-out", - "\n%{http_code}", - "--url", - ADMIN_URL, - ]); - expect({ - that: text.endsWith("\n200"), - message: - "Read-only live Caddy configuration probe must return HTTP 200", - }); - return JSON.parse(text.slice(0, -4)); - }; - const absent = async (hosts: readonly string[]): Promise => { - expect({ - that: nativeComposeProxyRoutesMatch({ - servers: await admin(), - expected: [], - absentHostnames: hosts, - }), - message: - "Retired exact fixture origins must be absent from active Caddy routing", - }); - }; - const tls = async (origin: string, marker: string): Promise => { - const url = new URL(origin); - expect({ - that: - url.protocol === "https:" && url.port === "" && url.pathname === "/", - message: "Fixture TLS probes require exact standard HTTPS origins", - }); - const deadline = Date.now() + OBSERVATION_WINDOW; - while (Date.now() < deadline) { - await proxyOwned(); - const result = await runCommand({ - argv: [ - "docker", - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=https", - "--max-redirs", - "0", - "--connect-timeout", - "2", - "--max-time", - "5", - "--cacert", - ROOT_CA, - "--resolve", - `${url.hostname}:443:127.0.0.1`, - "--url", - `${origin}/`, - ], - cwd: ctx.tempRoot, - timeoutMs: TIMEOUT, - }); - if ( - result.exitCode === 0 && - !result.timedOut && - result.stdout === marker - ) { - return; - } - await Bun.sleep(250); - } - throw new Error( - "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" - ); - }; const plan = async (root: string): Promise => { const payload = object( (await cli(root, ["config", "plan", "--json"])).stdout @@ -801,99 +573,17 @@ export const nativeConfigRoutingScenario: Scenario = { "Native hostname claims must be absent before fixture ingress removal", }); } - if (proxyId) { - await proxyOwned(); - await docker(["container", "stop", currentProxy()]); - await proxyOwned(); - await docker(["container", "rm", currentProxy()]); - proxyId = null; - } - expect({ - that: - (await docker([ - "ps", - "--no-trunc", - "-aq", - "--filter", - `label=${FIXTURE_LABEL}=${token}`, - ])) === "", - message: - "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", - }); - await preservedUnchanged(); + await ingress.cleanup(); }; await runWithOwnedCleanup({ run: async () => { - // Repeat ingress absence at the only fixture-global creation boundary. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse a newly appeared running global Caddy before fixture creation", - }); - proxyId = await docker([ - "create", - "--pull=never", - "--name", - proxyName, - "--network", - networkId, - "--label", - `${FIXTURE_LABEL}=${token}`, - "--label", - `${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--label", - `${SERVICE_LABEL}=${PROXY_SERVICE}`, - "--label", - `caddy=https://${canaryHost}`, - "--label", - `caddy.respond=${canaryMarker} 200`, - "--label", - "caddy.tls=internal", - "--env", - `CADDY_INGRESS_NETWORKS=${NETWORK}`, - "--mount", - "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", - // Caddy writes private root-owned files. Keep these in the disposable - // container so Linux cleanup never needs host chown or sudo. - "--tmpfs", - `/data:${PRIVATE_TMPFS}`, - "--tmpfs", - `/config:${PRIVATE_TMPFS}`, - caddyImage, - "docker-proxy", - "--polling-interval", - "1s", - ]); - await proxyOwned(); - await docker(["container", "start", currentProxy()]); - await tls(`https://${canaryHost}`, canaryMarker); - const binding = await observeNativeComposeIngress(); - expect({ - that: - binding.proxyId === currentProxy() && - binding.networkId === networkId, - message: - "Product ingress observer must select exactly the new fixture proxy/network", - }); + const binding = await ingress.start(); claimsRoot = join( ctx.hackHome, "compose-routing", createHash("sha256").update(binding.engineId).digest("hex"), "claims" ); - await admin(); - const certificate = new X509Certificate( - await docker(["exec", currentProxy(), "cat", ROOT_CA]) - ); - expect({ - that: - certificate.ca && - certificate.verify(certificate.publicKey) && - Date.parse(certificate.validFrom) <= Date.now() && - Date.parse(certificate.validTo) > Date.now(), - message: - "Only the current valid self-signed fixture CA may validate routed HTTPS", - }); stage( "isolated Caddy has no host ports and serves a verified TLS canary" ); @@ -1135,7 +825,12 @@ export const nativeConfigRoutingScenario: Scenario = { JSON.stringify( { version: 1, - proxy: { id: proxyId, name: proxyName, token, networkId }, + proxy: { + id: ingress.proxyId, + name: proxyName, + token, + networkId, + }, privateTmpfs: { "/data": PRIVATE_TMPFS, "/config": PRIVATE_TMPFS, diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts new file mode 100644 index 000000000..4cf54c631 --- /dev/null +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -0,0 +1,471 @@ +import { randomBytes, X509Certificate } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import { findExecutableInPath } from "../../../src/lib/shell.ts"; +import { expect, type ScenarioContext } from "../harness.ts"; +import { runNativeNetworkFixtureCommand } from "../native-config-networks-acceptance.ts"; + +const OBSERVATION_WINDOW = 30_000; +const OBJECT_ID = /^[a-f0-9]{64}$/; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const PROJECT_LABEL = "com.docker.compose.project"; +const SERVICE_LABEL = "com.docker.compose.service"; +const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; +const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; +const PROXY_PROJECT = "hack-dev-proxy"; +const PROXY_SERVICE = "caddy"; +const NETWORK = "hack-dev"; +const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; +const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; +export const NATIVE_ROUTING_FIXTURE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; +const PRIVATE_TMPFS = NATIVE_ROUTING_FIXTURE_TMPFS; +const PRESERVED_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; +const PROXY_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; + +/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ +export function proxyHasNoPublishedPorts( + info: Readonly> +): boolean { + return ( + info.publishAll === false && + (info.ports === null || + (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && + (info.runtimePorts === null || + (isRecord(info.runtimePorts) && + Object.values(info.runtimePorts).every((value) => value === null))) + ); +} + +function parsed(text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid fixture JSON response; values omitted"); + } +} + +function object(text: string): Record { + const value = parsed(text); + if (!isRecord(value)) { + throw new Error("Expected a complete JSON object; values omitted"); + } + return value; +} + +function ids(text: string): readonly string[] { + const found = text.split(/\s+/).filter(Boolean).sort(); + expect({ + that: + found.every((id) => OBJECT_ID.test(id)) && + new Set(found).size === found.length, + message: "Docker must return unique complete fixture resource IDs", + }); + return found; +} + +/** Each exact TLS attempt retains private bounded streams even on failure. */ +export async function runNativeRoutingFixtureTlsAttempt(opts: { + readonly docker: string; + readonly proxyId: string; + readonly origin: string; + readonly cwd: string; + readonly captures: string; + readonly env: Readonly>; + readonly timeoutMs: number; +}) { + const url = new URL(opts.origin); + expect({ + that: + isAbsolute(opts.docker) && + OBJECT_ID.test(opts.proxyId) && + url.protocol === "https:" && + url.port === "" && + url.pathname === "/" && + !url.username && + !url.password && + !url.search && + !url.hash && + opts.timeoutMs > 0 && + opts.timeoutMs <= OBSERVATION_WINDOW, + message: "Fixture TLS capture admission refused; values omitted", + }); + const result = await runNativeNetworkFixtureCommand({ + argv: [ + opts.docker, + "exec", + opts.proxyId, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + ROOT_CA, + "--resolve", + `${url.hostname}:443:127.0.0.1`, + "--url", + `${opts.origin}/`, + ], + cwd: opts.cwd, + env: opts.env, + captures: opts.captures, + timeoutMs: opts.timeoutMs, + outputLimit: 64 * 1024, + }); + await writeFile( + join(opts.captures, "attempt.json"), + `${JSON.stringify({ + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + })}\n`, + { flag: "wx", mode: 0o600 } + ); + return result; +} + +/** Shared same-engine fixture ingress: no host ports, DNS or trust writes. */ +export async function prepareNativeRoutingFixtureIngress(opts: { + readonly ctx: ScenarioContext; + readonly docker: (args: readonly string[]) => Promise; +}) { + const { ctx, docker } = opts; + const executable = findExecutableInPath("docker"); + const tlsEnv = Object.freeze({ ...process.env }) as Readonly< + Record + >; + let tlsAttempt = 0; + expect({ + that: Boolean(executable), + message: "Fixed fixture Docker executable is required", + }); + const selectors = [ + "--filter", + `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--filter", + `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, + ]; + // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, + // start, rename or remove them merely to make this scenario runnable. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse native routing fixture while any global Caddy selector is running", + }); + const preserved = ids( + await docker(["ps", "--no-trunc", "-aq", ...selectors]) + ); + const preservedSnapshots = new Map(); + for (const id of preserved) { + const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); + expect({ + that: object(text).running === false, + message: "Pre-existing proxy must be stopped", + }); + preservedSnapshots.set(id, text); + } + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "Native routing fixture requires a Linux Docker daemon", + }); + await docker(["compose", "version"]); + const networkId = await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ]); + expect({ + that: OBJECT_ID.test(networkId), + message: "Existing hack-dev network identity is required", + }); + const image = async (tag: string): Promise => { + const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); + expect({ + that: IMAGE_ID.test(id), + message: + "Fixture images must already be cached; never pull during acceptance", + }); + return id; + }; + const bunImage = await image("oven/bun:1.4.2-slim"); + const caddyImage = await image(CADDY_IMAGE); + const token = randomBytes(16).toString("hex"); + const proxyName = `e2e-native-routing-proxy-${token}`; + const canaryHost = `canary-${token}.test`; + const canaryMarker = `proxy-canary-${token}`; + let proxyId: string | null = null; + let creationAttempted = false; + const currentProxy = (): string => { + if (!(proxyId && OBJECT_ID.test(proxyId))) { + throw new Error("Exact owned fixture proxy ID is unavailable"); + } + return proxyId; + }; + const preservedUnchanged = async (): Promise => { + expect({ + that: + (await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ])) === networkId, + message: "External hack-dev network must retain its exact identity", + }); + for (const [id, before] of preservedSnapshots) { + expect({ + that: + (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === + before, + message: "Stopped user Caddy selectors must remain unchanged", + }); + } + }; + const proxyOwned = async (): Promise => { + const info = object( + await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) + ); + expect({ + that: + info.id === currentProxy() && + info.name === `/${proxyName}` && + info.owner === token && + info.project === PROXY_PROJECT && + info.service === PROXY_SERVICE && + info.networkMode === networkId && + (info.running === false || info.network === networkId) && + proxyHasNoPublishedPorts(info) && + Array.isArray(info.mounts) && + info.mounts.length === 1 && + info.mounts.every( + (mount: unknown) => + isRecord(mount) && + mount.Type === "bind" && + mount.Destination === "/var/run/docker.sock" && + mount.Source === "/var/run/docker.sock" && + mount.RW === false + ) && + isRecord(info.tmpfs) && + Object.keys(info.tmpfs).length === 2 && + info.tmpfs["/data"] === PRIVATE_TMPFS && + info.tmpfs["/config"] === PRIVATE_TMPFS, + message: + "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", + }); + }; + const admin = async (): Promise => { + await proxyOwned(); + const text = await docker([ + "exec", + currentProxy(), + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=http", + "--max-time", + "10", + "--max-redirs", + "0", + "--write-out", + "\n%{http_code}", + "--url", + ADMIN_URL, + ]); + expect({ + that: text.endsWith("\n200"), + message: "Read-only live Caddy configuration probe must return HTTP 200", + }); + return parsed(text.slice(0, -4)); + }; + const absent = async (hosts: readonly string[]): Promise => { + expect({ + that: nativeComposeProxyRoutesMatch({ + servers: await admin(), + expected: [], + absentHostnames: hosts, + }), + message: + "Retired exact fixture origins must be absent from active Caddy routing", + }); + }; + const tls = async (origin: string, marker: string): Promise => { + const url = new URL(origin); + expect({ + that: + url.protocol === "https:" && url.port === "" && url.pathname === "/", + message: "Fixture TLS probes require exact standard HTTPS origins", + }); + const deadline = Date.now() + OBSERVATION_WINDOW; + while (Date.now() < deadline) { + await proxyOwned(); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + if (!executable) { + throw new Error("Fixed fixture Docker executable is required"); + } + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: currentProxy(), + origin, + cwd: ctx.tempRoot, + env: tlsEnv, + captures: join( + ctx.tempRoot, + "routing-tls-attempts", + String(tlsAttempt++) + ), + timeoutMs: Math.min(OBSERVATION_WINDOW, remaining), + }); + if ( + result.exitCode === 0 && + !result.timedOut && + result.stdout === marker + ) { + return; + } + await Bun.sleep(250); + } + throw new Error( + "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" + ); + }; + const start = async () => { + if (creationAttempted) { + throw new Error("Fixture ingress may be created only once"); + } + creationAttempted = true; + // Repeat ingress absence at the only fixture-global creation boundary. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse a newly appeared running global Caddy before fixture creation", + }); + proxyId = await docker([ + "create", + "--pull=never", + "--name", + proxyName, + "--network", + networkId, + "--label", + `${FIXTURE_LABEL}=${token}`, + "--label", + `${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--label", + `${SERVICE_LABEL}=${PROXY_SERVICE}`, + "--label", + `caddy=https://${canaryHost}`, + "--label", + `caddy.respond=${canaryMarker} 200`, + "--label", + "caddy.tls=internal", + "--env", + `CADDY_INGRESS_NETWORKS=${NETWORK}`, + "--mount", + "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", + // Caddy writes private root-owned files. Keep these in the disposable + // container so Linux cleanup never needs host chown or sudo. + "--tmpfs", + `/data:${PRIVATE_TMPFS}`, + "--tmpfs", + `/config:${PRIVATE_TMPFS}`, + caddyImage, + "docker-proxy", + "--polling-interval", + "1s", + ]); + await proxyOwned(); + await docker(["container", "start", currentProxy()]); + await tls(`https://${canaryHost}`, canaryMarker); + const binding = await observeNativeComposeIngress(); + expect({ + that: + binding.proxyId === currentProxy() && binding.networkId === networkId, + message: + "Product ingress observer must select exactly the new fixture proxy/network", + }); + await admin(); + const certificate = new X509Certificate( + await docker(["exec", currentProxy(), "cat", ROOT_CA]) + ); + expect({ + that: + certificate.ca && + certificate.verify(certificate.publicKey) && + Date.parse(certificate.validFrom) <= Date.now() && + Date.parse(certificate.validTo) > Date.now(), + message: + "Only the current valid self-signed fixture CA may validate routed HTTPS", + }); + return binding; + }; + const cleanup = async () => { + if (proxyId) { + await proxyOwned(); + await docker(["container", "stop", currentProxy()]); + await proxyOwned(); + await docker(["container", "rm", currentProxy()]); + proxyId = null; + } + expect({ + that: + (await docker([ + "ps", + "--no-trunc", + "-aq", + "--filter", + `label=${FIXTURE_LABEL}=${token}`, + ])) === "", + message: + "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", + }); + await preservedUnchanged(); + }; + return { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + get proxyId() { + return proxyId; + }, + start, + admin, + tls, + absent, + preservedUnchanged, + cleanup, + }; +} diff --git a/tests/helpers/retained-routing-adoption.ts b/tests/helpers/retained-routing-adoption.ts new file mode 100644 index 000000000..34c375733 --- /dev/null +++ b/tests/helpers/retained-routing-adoption.ts @@ -0,0 +1,615 @@ +import { spyOn } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../../src/lib/native-compose-adoption-routing-execution.ts"; +import * as engine from "../../src/lib/native-compose-engine-identity.ts"; +import * as ownership from "../../src/lib/native-compose-ownership.ts"; +import { mapLegacyNativeRetainedRouting } from "../../src/lib/native-config-import-plan.ts"; +import type { NativeRoutingResolution } from "../../src/lib/native-routing-plan-protocol.ts"; +import * as shell from "../../src/lib/shell.ts"; +import { restoreEnv } from "./env.ts"; + +export const ROUTING_CANARY = "synthetic-retained-sql-row"; +export const ROUTING_IDS = { + db: "a".repeat(64), + web: "b".repeat(64), + network: "c".repeat(64), + ingress: "d".repeat(64), + proxy: "e".repeat(64), + foreign: "f".repeat(64), +}; +const CREATED = "2026-01-01T01:02:03Z", + HASH = "1".repeat(64), + GROUP = 987_654; +const LABELS = { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", +}; +const LIST_FORMATS: Readonly> = { + container: + '{"id":{{json .ID}},"name":{{json .Names}},"project":{{json (.Label "com.docker.compose.project")}}}', + network: + '{"id":{{json .ID}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', + volume: + '{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', +}; +function refuse(): never { + throw new Error( + "Synthetic retained route transport refused; values omitted." + ); +} +function identity(value: unknown) { + if ( + !( + isRecord(value) && + typeof value.dev === "number" && + typeof value.ino === "number" + ) + ) { + return refuse(); + } + return { dev: value.dev, ino: value.ino }; +} + +/** A timeout or unfinished continuation permanently retains the fixture's global + * doubles and private environment. Late settlement cannot grant teardown. */ +export function retainedRoutingFixtureLifetime(deadline: number) { + let unknown = false; + const pending = new Set>(); + const canRestore = () => { + if (Date.now() >= deadline || pending.size !== 0) { + unknown = true; + } + return !unknown; + }; + return { + retain: () => { + unknown = true; + }, + canRestore, + track: (work: Promise): Promise => { + pending.add(work); + work.then( + () => pending.delete(work), + () => pending.delete(work) + ); + return work; + }, + }; +} + +/** Private-store model only. Docker metadata and child effects are injected; + * this does not qualify Go formatting, compiler semantics, TLS, SQL or a daemon. */ +export async function retainedRoutingFixture( + lifetime: ReturnType +) { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-routing-owner-")) + ); + const root = join(outer, "checkout"), + home = join(outer, "hack-home"); + await chmod(outer, 0o700); + await mkdir(join(root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(root, ".git"), { mode: 0o700 }); + await mkdir(home, { mode: 0o700 }); + const config = JSON.stringify({ + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }); + const compose = JSON.stringify({ + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + db: { image: "synthetic/db:1", volumes: ["data:/data"] }, + web: { + image: "synthetic/web:1", + networks: ["default", "hack-dev"], + labels: LABELS, + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/hack.config.json"), config, { + mode: 0o600, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), compose, { + mode: 0o600, + }); + const candidate = mapLegacyNativeRetainedRouting({ + configText: config, + composeText: compose, + }).candidate; + if (!candidate) { + return refuse(); + } + const compiler = join(outer, "compiler"); + const resolution: NativeRoutingResolution = { + domain: "hack.local", + domain_origin: "default", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "project", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + const plan = { + plan_version: 1, + name: "fixture", + services: { db: {}, web: {} }, + jobs: {}, + routes: { + origin: resolution.project_origin, + aliases: { oauth: { origin: resolution.aliases.oauth } }, + oauth_alias: "oauth", + http: { + web: { + service: "web", + port: 3000, + protocol: "http", + hostname: "project", + }, + }, + }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }; + await writeFile( + compiler, + `#!${process.execPath} +const operation=process.argv[2]; +if(operation==='--protocol') console.log(JSON.stringify({transport_version:1,authored_version:1,plan_version:1,resolve_version:1,local_version:1,env_plan_version:1,routing_plan_version:1})); +else { + const raw=JSON.parse(await Bun.stdin.text()), request=operation==='compile'?{}:raw, project=operation==='compile'?raw:JSON.parse(request.project); + if(JSON.stringify(project)!==${JSON.stringify(JSON.stringify(candidate))})process.exit(97); + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{db:'service',web:'service'},plan:${JSON.stringify(plan)}}; + if(operation!=='compile') {result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:false,resolution_hash:'b'.repeat(64)};if(request.routing_probe===true)result.routing_inputs_required=true;else result.routing_resolution=${JSON.stringify(resolution)};} + if(operation==='plan')result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{db:{},web:{}},warnings:[],diagnostics:[]}; + console.log(JSON.stringify(result)); +} +`, + { mode: 0o700 } + ); + const previousHome = process.env.HACK_HOME; + process.env.HACK_HOME = home; + const model = { + running: false, + foreign: false, + wrongDial: false, + partial: false, + proxyAccess: true, + volumeBirth: CREATED, + webBirth: CREATED, + engine: "synthetic-retained-routing", + sqlRow: ROUTING_CANARY, + }; + const commands: string[][] = [], + effects: string[][] = []; + const hooks: { + afterProbe?: (args: readonly string[]) => Promise; + afterEffect?: () => Promise; + } = {}; + const json = (value: unknown) => JSON.stringify(value); + const names = ["db", "web"] as const; + const service = (id: string | undefined) => + names.find((name) => ROUTING_IDS[name] === id) ?? refuse(); + const probe: ReturnType = async ( + input + ) => { + const args = [...input]; + commands.push(args); + const [kind, action] = args, + format = args[args.indexOf("--format") + 1] ?? "", + id = args.at(-1); + let result: string; + if (kind === "info" && action === "--format") { + result = + format === "{{json .ID}}" + ? json(model.engine) + : json({ id: model.engine, os: "linux" }); + } else if ( + kind === "exec" && + args[1] === ROUTING_IDS.proxy && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) { + if (!model.proxyAccess) { + return refuse(); + } + const routes = model.running + ? [ + { + match: [{ host: LABELS.caddy.split(",") }], + handle: [ + { + handler: "reverse_proxy", + upstreams: [ + { + dial: `${model.wrongDial ? "172.28.0.99" : "172.28.0.3"}:3000`, + }, + ], + }, + ], + terminal: true, + }, + ] + : []; + result = `${json({ + srv0: { listen: [":443"], tls_connection_policies: [{}], routes }, + })}\n200`; + } else if (kind === "compose" && args.at(-2) === "--hash" && id === "*") { + const file = args[args.indexOf("--file") + 1]; + if (!file || (await readFile(file, "utf8")) !== compose) { + return refuse(); + } + result = `db ${HASH}\nweb ${HASH}`; + } else if ( + action === "ls" && + kind === "container" && + format === "{{json .ID}}" && + args.includes("label=com.docker.compose.project=hack-dev-proxy") + ) { + result = json(ROUTING_IDS.proxy); + } else if ( + action === "ls" && + ["container", "network", "volume"].includes(kind ?? "") + ) { + if (args.some((arg) => arg.startsWith("label=io.hack.native-config."))) { + result = ""; + } else if (kind === "container" && format === '{"id":{{json .ID}}}') { + result = [ + ROUTING_IDS.db, + ROUTING_IDS.web, + ROUTING_IDS.proxy, + ...(model.foreign ? [ROUTING_IDS.foreign] : []), + ] + .map((value) => json({ id: value })) + .join("\n"); + } else if ( + kind !== undefined && + format === LIST_FORMATS[kind] && + json(args) === + json([ + kind, + "ls", + ...(kind === "container" ? ["--all"] : []), + ...(kind === "volume" ? [] : ["--no-trunc"]), + "--format", + format, + ]) + ) { + result = ( + kind === "container" + ? names.map((name) => ({ + id: ROUTING_IDS[name], + name: `fixture-${name}-1`, + project: "fixture", + })) + : kind === "network" + ? [ + { + id: ROUTING_IDS.network, + name: "fixture_default", + project: "fixture", + }, + ] + : [ + { + id: "fixture_data", + name: "fixture_data", + project: "fixture", + }, + ] + ) + .map(json) + .join("\n"); + } else { + return refuse(); + } + } else if (kind === "network" && action === "inspect") { + if (id === "hack-dev") { + result = json({ id: ROUTING_IDS.ingress, name: "hack-dev" }); + } else if (id === ROUTING_IDS.ingress && format.includes("created")) { + result = json({ id, created: CREATED }); + } else if (id === ROUTING_IDS.network) { + result = json({ + id, + name: "fixture_default", + project: "fixture", + native: "", + logical: "default", + createdAt: CREATED, + driver: "bridge", + scope: "local", + internal: false, + containers: model.running ? [ROUTING_IDS.db, ROUTING_IDS.web] : [], + }); + } else { + return refuse(); + } + } else if ( + kind === "volume" && + action === "inspect" && + id === "fixture_data" + ) { + result = json({ + id, + name: id, + project: "fixture", + native: "", + storage: "data", + createdAt: model.volumeBirth, + driver: "local", + scope: "local", + mountpoint: "/var/lib/docker/volumes/fixture_data/_data", + options: null, + }); + } else if (kind === "container" && action === "inspect") { + if (format.includes('"sites"') && !format.includes('"created"')) { + result = args + .slice(4) + .map((selected) => + json({ + id: selected, + sites: + selected === ROUTING_IDS.web || selected === ROUTING_IDS.foreign + ? [LABELS.caddy, null] + : [null], + }) + ) + .join("\n"); + } else if (id === ROUTING_IDS.proxy) { + result = format.includes('"created"') + ? json({ id, created: CREATED }) + : json({ + id, + project: "hack-dev-proxy", + service: "caddy", + running: true, + network: ROUTING_IDS.ingress, + ip: "172.28.0.2", + }); + } else { + const name = service(id); + if (format.includes('"sites"')) { + result = json({ + id, + created: name === "web" ? model.webBirth : CREATED, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + paused: false, + sites: + name === "web" + ? [ + ...Object.entries(LABELS).map(([key, value]) => ({ + key, + value, + })), + null, + ] + : [null], + networks: [ + { + name: "fixture_default", + id: ROUTING_IDS.network, + ip: model.running ? "172.27.0.3" : "", + }, + ...(name === "web" + ? [ + { + name: "hack-dev", + id: ROUTING_IDS.ingress, + ip: model.running ? "172.28.0.3" : "", + }, + ] + : []), + null, + ], + }); + } else if (format.includes("config-hash")) { + result = json({ id, hash: HASH }); + } else if (format.includes(".Mounts")) { + result = json({ + id, + name: `/fixture-${name}-1`, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + workingDir: join(root, ".hack"), + configFiles: join(root, ".hack/docker-compose.yml"), + mounts: + name === "db" + ? [ + { + type: "volume", + name: "fixture_data", + source: "/var/lib/docker/volumes/fixture_data/_data", + target: "/data", + rw: true, + }, + ] + : [], + networks: [ + { name: "fixture_default", id: ROUTING_IDS.network }, + ...(name === "web" + ? [{ name: "hack-dev", id: ROUTING_IDS.ingress }] + : []), + ], + }); + } else if (format.includes(".State.Running")) { + result = json({ + id, + running: model.running, + paused: false, + status: model.running ? "running" : "exited", + ...(format.includes("Health") ? { health: "" } : {}), + }); + } else { + return refuse(); + } + } + } else { + return refuse(); + } + await hooks.afterProbe?.(args); + return result; + }; + const originalKill = process.kill; + const spies = [ + spyOn(ownership, "createNativeComposeProbe").mockImplementation( + () => probe + ), + spyOn(engine, "createNativeComposeEngineIdentityObserver").mockReturnValue( + null + ), + spyOn(shell, "run").mockImplementation(async (args, opts = {}) => { + opts.beforeSpawn?.(); + if ( + JSON.stringify(args) !== + json([ + "docker", + "container", + args[2], + ROUTING_IDS.db, + ROUTING_IDS.web, + ]) || + !["start", "restart", "stop"].includes(args[2] ?? "") + ) { + return refuse(); + } + await opts.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + effects.push([...args]); + model.running = args[2] !== "stop"; + await hooks.afterEffect?.(); + return model.partial ? 7 : 0; + }), + spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === -GROUP && signal === 0) { + throw Object.assign(new Error("synthetic absent"), { code: "ESRCH" }); + } + return originalKill.call(process, pid, signal); + }), + ]; + const receiptPath = join( + root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + return { + root, + outer, + home, + compiler, + config, + compose, + model, + resolution, + hooks, + commands, + effects, + receiptPath, + canRestore: lifetime.canRestore, + track: lifetime.track, + receipt: async () => { + const value: unknown = JSON.parse(await readFile(receiptPath, "utf8")); + if (!(isRecord(value) && isRecord(value.checkout))) { + return refuse(); + } + return parseLegacyComposeAdoptionReceipt(value, { + root: identity(value.checkout.root), + project: identity(value.checkout.project), + git: identity(value.checkout.git), + }); + }, + store: () => + openLegacyComposeAdoptedGenerationStore({ + projectRoot: root, + timeoutMs: 15_000, + }), + operation: async ( + store: Awaited< + ReturnType + >, + generation: Parameters[0]["generation"], + operation: "start" | "stop", + opts: { + readonly recover?: boolean; + readonly numeric?: boolean; + readonly preparation?: boolean; + } = {} + ) => { + const deadline = Date.now() + 15_000; + const run = async ( + input: Parameters[0]["run"]>[0] + ) => + opts.numeric + ? 0 + : await runLegacyComposeRetainedRoutingOperation({ + input, + operation, + deadline, + }); + return opts.preparation + ? await store.withPreparationStop({ + generation, + binary: compiler, + deadline, + recover: opts.recover, + run, + }) + : await store.withMutation({ + generation, + operation, + services: [], + binary: compiler, + deadline, + recover: opts.recover, + run, + }); + }, + cleanup: async () => { + if (!lifetime.canRestore()) { + return refuse(); + } + for (const spy of spies) { + spy.mockRestore(); + } + restoreEnv("HACK_HOME", previousHome); + await rm(outer, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index 799451bbd..50aa6b414 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -1048,6 +1048,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-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts index fc9a7b939..1a83852ef 100644 --- a/tests/native-compose-adoption-build-generation.test.ts +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -217,7 +217,7 @@ test("foreign receipt versions cannot label a build manifest", async () => { parseLegacyComposeAdoptionReceipt(receipt, receipt.checkout) .adoption_receipt_version ).toBe(9); - for (const version of [1, 2, 3, 4, 5, 6, 7, 10, 11]) { + for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14]) { await writeReceipt({ ...receipt, adoption_receipt_version: version }); await red(store.loadPrepared()); } 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-file-generation.test.ts b/tests/native-compose-adoption-file-generation.test.ts new file mode 100644 index 000000000..d635d2a9a --- /dev/null +++ b/tests/native-compose-adoption-file-generation.test.ts @@ -0,0 +1,765 @@ +import { + afterEach, + beforeEach, + test as boundedTest, + expect, + spyOn, +} from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + 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"; +import { retainedRoutingFixtureLifetime } from "./helpers/retained-routing-adoption.ts"; + +// The two compound workflows include preparation, publication, multiple bounded +// operations and saved rollback; their outer budget is separate from each 15s +// product mutation limit. Unknown callbacks/children permanently retain globals. +let lifetime: ReturnType | undefined; +let uncertain = false; +let spawnSpy: ReturnType> | undefined; +const originalSpawn = Bun.spawn; +function ownedTest(name: string, run: () => Promise, timeoutMs = 5000) { + boundedTest( + name, + async () => { + lifetime = retainedRoutingFixtureLifetime(Date.now() + timeoutMs); + const owner = lifetime; + spawnSpy = spyOn(Bun, "spawn").mockImplementation((...args) => { + const child = Reflect.apply(originalSpawn, Bun, args); + owner.track(child.exited); + return child; + }); + await owner.track(run()); + }, + timeoutMs + ); +} +const test = Object.assign(ownedTest, { + each: + (rows: readonly T[]) => + (name: string, run: (value: T) => Promise, timeoutMs = 5000) => { + for (const value of rows) { + ownedTest( + name.replace("%s", String(value)), + () => run(value), + timeoutMs + ); + } + }, +}); + +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; + guestMode?: "400" | "600"; +}; +beforeEach(async () => { + if (uncertain) { + throw new Error( + "Prior retained-file fixture settlement is unknown; values omitted." + ); + } + lifetime = undefined; + 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]==='--') {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); +} +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 () => { + if (!lifetime?.canRestore()) { + uncertain = true; + throw new Error( + "Retained-file fixture callback or child is unsettled; preserved values omitted." + ); + } + spawnSpy?.mockRestore(); + spawnSpy = undefined; + 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(); + } +}, 60_000); + +test("file8 refuses foreign receipt families and extra proof fields before engine observation", async () => { + const { store, generation } = await prepared(); + const receiptPath = join( + projectRoot, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const originalReceipt = await state(); + const manifestPath = join( + projectRoot, + ".hack/.internal/legacy-compose-adoption-v1/generations", + originalReceipt.prepared.id, + "manifest.json" + ); + const originalManifest = await readFile(manifestPath, "utf8"); + const meta = JSON.parse(originalManifest); + const before = await commands(); + try { + for (const version of [1, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14]) { + await writeFile( + receiptPath, + JSON.stringify({ + ...originalReceipt, + adoption_receipt_version: version, + }) + ); + await expect(store.loadPrepared()).rejects.toThrow(); + expect(await commands()).toEqual(before); + } + for (const field of [ + "buildProof", + "sourceBindProof", + "branchProof", + "projectionProof", + "routingClaims", + ]) { + const changed = JSON.stringify({ ...meta, [field]: {} }); + await writeFile(manifestPath, changed); + // Authenticate the changed bytes so closed schema refusal, rather than a + // stale manifest hash, discriminates proof mixing between valid families. + await writeFile( + receiptPath, + JSON.stringify({ + ...originalReceipt, + prepared: { + ...originalReceipt.prepared, + manifest: { + ...originalReceipt.prepared.manifest, + hash: createHash("sha256").update(changed).digest("hex"), + }, + }, + }) + ); + await expect(store.loadPrepared()).rejects.toThrow(); + expect(await commands()).toEqual(before); + } + await writeFile(manifestPath, originalManifest); + await writeFile(receiptPath, JSON.stringify(originalReceipt)); + expect( + (await store.loadPrepared())?.report.adoption_generation_version + ).toBe(generation.report.adoption_generation_version); + expect(await mutations()).toEqual([]); + } finally { + await store.close(); + } +}, 15_000); + +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" + ); + 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([ + "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(); + } +}, 60_000); +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..2a090643b --- /dev/null +++ b/tests/native-compose-adoption-files.test.ts @@ -0,0 +1,664 @@ +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 { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; +import { mapLegacyOwnedNetwork } from "../src/lib/native-config-import-network.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", + }, + ], + }, + }); +}); + +test.each([ + { networks: { private: { internal: true } }, attached: ["private"] }, + { + networks: { private: { internal: true }, outward: { internal: false } }, + attached: { private: {}, outward: {} }, + }, +])("file8 preparation stays separate from one or two canonical owned bridges: %j", ({ + networks, + attached, +}) => { + const composeText = JSON.stringify({ + name: "fixture", + networks, + configs: { settings: { file: "../material/config" } }, + volumes: { data: { name: "fixture_data" } }, + services: { + app: { + image: "fixture:pinned", + networks: attached, + configs: ["settings"], + volumes: ["data:/data"], + }, + }, + }); + const source = { configText: '{"name":"fixture"}', composeText }; + expect( + mapLegacyOwnedNetwork({ + project: "fixture", + compose: JSON.parse(composeText), + }).kind + ).toBe(Object.keys(networks).length === 1 ? "owned" : "multiple"); + for (const mapped of [ + mapLegacyNativeRetainedFileAdoptionBaseline(source), + mapLegacyNativeRetainedFileStorage(source), + ]) { + expect(mapped.candidate).toBeUndefined(); + expect(mapped.report.complete).toBe(false); + expect(mapped.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/networks", + status: "refused", + code: "retained_file_network_unsupported", + }) + ); + } + const planned = planLegacyComposeRetainedFileAdoption(source); + expect(planned.report.supported).toBe(false); + expect(planned.intent).toBeUndefined(); + expect(JSON.stringify(planned)).not.toContain("material/config"); +}); + +test("shared receipt decoder preserves closed file8 and plural bridge11 versions without granting a mixed owner", () => { + const checkout = { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, + }; + const value = { + kind: "legacy-compose-adopted", + checkout, + prepared: null, + publication: null, + pendingOperation: null, + }; + for (const version of [1, 3, 4, 5, 6, 8, 9, 10, 11, 13] as const) { + expect( + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version }, + checkout + ).adoption_receipt_version + ).toBe(version); + } + for (const version of [7, 8, 12] as const) { + const prepared = { + id: "b".repeat(32), + manifest: { dev: 1, ino: 5, hash: "c".repeat(64) }, + }; + expect( + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version, prepared }, + checkout + ).adoption_receipt_version + ).toBe(version); + } + for (const version of [2, 7, 12, 14, 15, null, "8", "11"]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version }, + checkout + ) + ).toThrow(); + } + expect(() => + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: 8, networks: [] }, + checkout + ) + ).toThrow(); +}); + +test("current job7 and file8 purposes remain separate before any material or engine acquisition", () => { + const compose = { + name: "fixture", + services: { + initialize: { image: "fixture:1", command: ["true"], restart: "no" }, + app: { + image: "fixture:1", + depends_on: { + initialize: { condition: "service_completed_successfully" }, + }, + volumes: ["data:/data"], + }, + }, + volumes: { data: { name: "fixture_data" } }, + }; + const source = { + configText: '{"name":"fixture"}', + composeText: JSON.stringify(compose), + }; + const ordinary = planLegacyComposeAdoption(source); + expect(ordinary.report.supported).toBe(true); + expect(ordinary.intent).toBeDefined(); + for (const mapped of [ + mapLegacyNativeRetainedFileAdoptionBaseline(source), + mapLegacyNativeRetainedFileStorage(source), + ]) { + expect(mapped.report.complete).toBe(false); + expect(mapped.candidate).toBeUndefined(); + } + const mixed = { + ...source, + composeText: JSON.stringify({ + ...compose, + configs: { settings: { file: "../material/config" } }, + services: { + ...compose.services, + app: { ...compose.services.app, configs: ["settings"] }, + }, + }), + }; + for (const plan of [ + planLegacyComposeAdoption(mixed), + planLegacyComposeRetainedFileAdoption(mixed), + ]) { + expect(plan.report.supported).toBe(false); + expect(plan.intent).toBeUndefined(); + expect(JSON.stringify(plan)).not.toContain("material/config"); + } +}); + +// A file proof never expands the newer build, host-directory, branch or routing family. +test.each([ + "build", + "source-bind", + "branch", + "routing", +])("retained file8 mapper/grants reject mixed %s intent", (family) => { + const compose: Record = { + name: "fixture", + configs: { settings: { file: "../material/config" } }, + volumes: { data: { name: "fixture_data" } }, + services: { + app: { + image: "example:fixed", + configs: ["settings"], + volumes: ["data:/data"], + }, + }, + }; + const config: Record = { name: "fixture" }; + const app = (compose.services as Record>) + .app!; + if (family === "build") { + app.build = { context: ".." }; + } else if (family === "source-bind") { + (app.volumes as string[]).push("../source:/app:ro"); + } else if (family === "branch") { + config.worktree = { inherit_local: false }; + } else { + config.dev_host = "fixture.hack.local"; + app.labels = { + caddy: "fixture.hack.local", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }; + app.networks = ["default", "hack-dev"]; + compose.networks = { default: {}, "hack-dev": { external: true } }; + } + const source = { + configText: JSON.stringify(config), + composeText: JSON.stringify(compose), + }; + const mapped = mapLegacyNativeRetainedFileStorage(source); + expect(() => legacyComposeRetainedFileGrants(mapped.candidate)).toThrow(); +}); 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..9a50efa44 --- /dev/null +++ b/tests/native-compose-adoption-mount-format.test.ts @@ -0,0 +1,212 @@ +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, +} 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; +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 () => { + 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); + expect(forwardingAllowed(old)).toBe(false); + expect( + forwardingAllowed(legacyComposeAdoptionContainerInspectFormat) + ).toBe(true); + 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) => { + expect( + forwardingAllowed(legacyComposeAdoptionContainerInspectFormat) + ).toBe(true); + 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 +); +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 +); diff --git a/tests/native-compose-adoption-routing-execution.test.ts b/tests/native-compose-adoption-routing-execution.test.ts new file mode 100644 index 000000000..e0ab0fbe7 --- /dev/null +++ b/tests/native-compose-adoption-routing-execution.test.ts @@ -0,0 +1,242 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import type { LegacyComposeVerifiedBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { + consumeLegacyComposeRoutingCompletion, + runLegacyComposeRetainedRoutingOperation, +} from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as shell from "../src/lib/shell.ts"; + +const ID = "a".repeat(64), + OTHER = "b".repeat(64), + GROUP = 987_654; +const spies: { mockRestore(): void }[] = []; +afterEach(() => { + for (const spy of spies.splice(0)) { + spy.mockRestore(); + } +}); +function input(assertFresh: () => Promise = async () => {}) { + // Transport-only boundary: the generation tests separately issue this binding. + const binding = { + binding_version: 14, + projectRoot: "/synthetic/retained-routing", + containers: [{ id: ID }, { id: OTHER }], + } as unknown as LegacyComposeVerifiedBinding; + return { binding, assertFresh, assertActive: () => {} }; +} +function missing(): never { + throw Object.assign(new Error("absent"), { code: "ESRCH" }); +} +function owner(code = 0, afterAdmission?: () => void) { + const calls: { argv: readonly string[]; options: shell.RunOptions }[] = []; + spies.push( + spyOn(shell, "run").mockImplementation(async (argv, options = {}) => { + options.beforeSpawn?.(); + afterAdmission?.(); + calls.push({ argv: [...argv], options }); + await options.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + return code; + }) + ); + return calls; +} +test("known child return plus ESRCH issues only a one-use completion for the original callback", async () => { + const calls = owner(17), + probes: number[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push(pid); + expect(signal).toBe(0); + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options).toMatchObject({ + cwd: selected.binding.projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(calls[0]?.options.forwardSignals).toBeUndefined(); + expect(probes).toEqual([-GROUP]); + for (const invalid of [17, {}, { ...outcome }]) { + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome: invalid, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + } + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: { ...selected }, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toThrow(/uncertain/); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toBe(17); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); +}); +test("wrapper return does not prove peer absence; bounded observation accepts only later ESRCH", async () => { + owner(); + let reads = 0; + spies.push( + spyOn(process, "kill").mockImplementation(() => { + if (++reads < 3) { + return true; + } + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "stop", + deadline, + }); + expect(reads).toBe(3); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toBe(0); +}); +for (const code of ["EPERM", "EIO"]) { + test(`group ${code} remains unknown and cannot issue a completion`, async () => { + owner(); + spies.push( + spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("private"), { code }); + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow(/uncertain/); + }); +} +test("retained peer at the captured deadline refuses without sending any signal", async () => { + const calls = owner(); + const probes: unknown[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push([pid, signal]); + return true; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 80, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toHaveLength(1); + expect(probes.length).toBeGreaterThan(0); + expect( + probes.every( + (value) => JSON.stringify(value) === JSON.stringify([-GROUP, 0]) + ) + ).toBe(true); +}); +test("cancellation during final source admission has zero child effects", async () => { + const calls = owner(), + controller = new AbortController(); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(async () => { + controller.abort(); + }), + operation: "start", + deadline: Date.now() + 1000, + signal: controller.signal, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toEqual([]); +}); +test("child IDs and working directory are captured before the final source await", async () => { + const selected = input(async () => { + Reflect.set(selected.binding, "projectRoot", "/foreign"); + Reflect.set(selected.binding, "containers", [{ id: "c".repeat(64) }]); + }); + const calls = owner(); + spies.push(spyOn(process, "kill").mockImplementation(missing)); + await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options.cwd).toBe("/synthetic/retained-routing"); +}); +test("the synchronous spawn fence refuses a callback revoked after fresh admission", async () => { + const selected = input(); + let active = true; + let child = 0; + selected.assertActive = () => { + if (!active) { + throw new Error("revoked; values omitted"); + } + }; + spies.push( + spyOn(shell, "run").mockImplementation(async (_argv, options = {}) => { + await Promise.resolve(); + active = false; + options.beforeSpawn?.(); + child++; + return 0; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow("revoked"); + expect(child).toBe(0); +}); diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts new file mode 100644 index 000000000..780177a7f --- /dev/null +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -0,0 +1,381 @@ +import { expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { mapLegacyNativeRetainedRouting } from "../src/lib/native-config-import-plan.ts"; +import { + prepareRetainedRoutingFixtureLocals, + retainedRoutingFixtureConfig, + retainedRoutingFixtureOrigins, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./e2e/scenarios/native-compose-adoption-routing-inputs.ts"; +import { + nativeComposeAdoptionRoutingWorktreesScenario, + retainedRoutingPartialStopScript, + retainRoutingAdoptStopResult, +} from "./e2e/scenarios/native-compose-adoption-routing-worktrees.ts"; +import { ownedAdoptionFixtureObservation } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; + +const IMAGE = `sha256:${"a".repeat(64)}`; +const IDS = ["b".repeat(64), "c".repeat(64), "d".repeat(64)] as const; +test("first adoption refusal preserves bounded private result before the stop oracle", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-result-")); + try { + const result = { + command: "not-persisted", + exitCode: 1, + timedOut: false, + stdout: '{"ok":false,"error":{"code":"E_CONFIG_INVALID"}}', + stderr: "synthetic refusal detail", + combined: "not-persisted", + durationMs: 1, + }; + const path = join(root, "adopt-stop-result.json"); + await expect( + retainRoutingAdoptStopResult({ + root, + result: { ...result, stdout: "x".repeat(64 * 1024 + 1) }, + }) + ).rejects.toThrow(); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + await retainRoutingAdoptStopResult({ root, result }); + expect(JSON.parse((await readPrivate(path, 256 * 1024)).text)).toEqual({ + phase: "adopt-stop-return", + exitCode: 1, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + }); + expect((await lstat(path)).mode & 0o777).toBe(0o600); + await expect( + retainRoutingAdoptStopResult({ root, result }) + ).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +function selection(prefer: "alias" | "dev" = "alias") { + return retainedRoutingFixtureSelection({ + image: IMAGE, + name: "retained-origin", + marker: "synthetic-http-marker", + prefer, + }); +} +test("routing acceptance is explicitly selected and retains uncertain source/resource evidence", () => { + expect( + nativeComposeAdoptionRoutingWorktreesScenario.requiresExplicitSelection + ).toBe(true); + expect( + nativeComposeAdoptionRoutingWorktreesScenario.preserveFixtureOnFailure + ).toBe(true); +}); +test("maintained legacy HTTP fixture has a real apex, existing alias and lossless defined candidate", () => { + const route = selection(); + const result = mapLegacyNativeRetainedRouting({ + configText: JSON.stringify({ + name: "retained-origin", + ...retainedRoutingFixtureConfig(route), + worktree: { auto_branch: false, inherit_local: true }, + }), + composeText: JSON.stringify({ + name: "retained-origin", + services: { + web: retainedRoutingFixtureService(route), + db: { image: IMAGE, volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + networks: { "hack-dev": { external: true } }, + }), + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toBeDefined(); + expect(retainedRoutingFixtureOrigins(route)).toEqual([ + "https://retained-origin.hack.local", + "https://retained-origin.hack.gy", + ]); + expect(retainedRoutingFixtureConfig(selection("dev")).open.prefer).toBe( + "dev" + ); + expect(retainedRoutingFixtureConfig(route).open.prefer).toBe("dev"); + expect(retainedRoutingFixtureService(route).image).toBe(IMAGE); +}); +test("served legacy Caddy origins use separate address tokens", () => { + const route = selection(); + const label = retainedRoutingFixtureService(route).labels.caddy; + const expected = [route.devHost, route.aliasHost]; + expect(label).toBe(expected.join(", ")); + expect(label.split(/,\s+/)).toEqual(expected); + // The retained actual RED used this one-token spelling and Caddy rejected it. + expect(expected.join(",").split(/,\s+/)).not.toEqual(expected); +}); + +test("alpha checkout alias differs from authored and primary-local dev selections", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-locals-")); + try { + const primary = { root: join(root, "primary") }; + const instance = { root: join(root, "checkout"), routing: selection() }; + await mkdir(join(primary.root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(instance.root, ".hack"), { recursive: true, mode: 0o700 }); + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [instance], + }); + expect(retainedRoutingFixtureConfig(instance.routing).open.prefer).toBe( + "dev" + ); + expect( + JSON.parse( + await readFile(join(primary.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }); + expect( + JSON.parse( + await readFile(join(instance.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: "alias" }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +test("new routed web observation requires exact birth, zero mounts and original service/name scope", () => { + const instance = { + root: "/synthetic/retained", + name: "retained-origin", + marker: "synthetic-sql", + routing: selection(), + }; + const row = { + id: IDS[1], + createdAt: "2026-10-09T01:02:03Z", + project: instance.name, + nativeNames: [], + name: `/${instance.name}-web-1`, + service: "web", + workingDir: `${instance.root}/.hack`, + configFiles: `${instance.root}/.hack/docker-compose.yml`, + mounts: [], + }; + expect( + ownedAdoptionFixtureObservation({ instance, kind: "container", row }) + ).toEqual({ id: IDS[1], service: "web", createdAt: row.createdAt }); + for (const change of [ + { createdAt: null }, + { mounts: [{ type: "volume" }] }, + { nativeNames: ["io.hack.native-config.owner"] }, + { service: "other" }, + ]) { + expect(() => + ownedAdoptionFixtureObservation({ + instance, + kind: "container", + row: { ...row, ...change }, + }) + ).toThrow(); + } +}); + +/** Closed child ports, but the emitted marker uses the actual exclusive writer. */ +async function replay( + state: unknown, + args: readonly string[], + existingMarker = false +) { + const root = await mkdtemp(join(tmpdir(), "retained-routing-marker-")); + await chmod(root, 0o700); + const markerPath = join(root, "marker"); + try { + if (existingMarker) { + await writeFile(markerPath, "existing-private-marker", { + flag: "wx", + mode: 0o600, + }); + } + const requests: string[][] = []; + let writeError: string | null = null; + const source = retainedRoutingPartialStopScript({ + engine: "/synthetic/docker", + engineId: '"synthetic-daemon"', + receipt: "/synthetic/receipt", + ids: IDS, + stopId: IDS[0], + marker: markerPath, + }); + const exit = { code: -1 }; + const readReceipt = async (path: string, limit: number) => { + if (path !== "/synthetic/receipt" || limit !== 131_072) { + throw new Error("unexpected read"); + } + return { text: JSON.stringify(state) }; + }; + const fixtureProcess = { + argv: ["bun", "shim", ...args], + exit: (code: number): never => { + exit.code = code; + throw exit; + }, + }; + const fixtureBun = { + spawn: (argv: string[], _options: unknown) => { + requests.push(argv); + if ( + JSON.stringify(argv) === + JSON.stringify([ + "/synthetic/docker", + "info", + "--format", + "{{json .ID}}", + ]) + ) { + return { + exited: Promise.resolve(0), + stdout: new Response('"synthetic-daemon"\n').body, + }; + } + if ( + JSON.stringify(argv) === + JSON.stringify(["/synthetic/docker", "container", "stop", IDS[0]]) + ) { + return { exited: Promise.resolve(0) }; + } + throw new Error("unexpected effect"); + }, + }; + const AsyncFunction: new ( + ...args: string[] + ) => (...ports: unknown[]) => Promise = Object.getPrototypeOf( + async () => undefined + ).constructor; + try { + const lines = source.split("\n"); + if ( + !( + lines[1]?.startsWith("import { readPrivate } from ") && + lines[1].endsWith(";") + ) || + lines[2] !== "import { writeFile } from 'node:fs/promises';" + ) { + throw new Error("emitted bounded reader import missing"); + } + await new AsyncFunction( + "Bun", + "process", + "readPrivate", + "writeFile", + lines.slice(3).join("\n") + )(fixtureBun, fixtureProcess, readReceipt, writeFile); + throw new Error("emitted forwarder did not exit"); + } catch (error: unknown) { + if ( + existingMarker && + typeof error === "object" && + error !== null && + "code" in error && + error.code === "EEXIST" + ) { + writeError = "EEXIST"; + } else if (error !== exit) { + throw error; + } + } + let marker: string | null = null; + let markerMode: number | null = null; + try { + const info = await lstat(markerPath); + markerMode = info.mode & 0o777; + marker = (await readPrivate(markerPath, 128)).text; + } catch (error: unknown) { + if ( + !( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + return { code: exit.code, requests, marker, markerMode, writeError }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} +const PENDING = { + adoption_receipt_version: 14, + pendingOperation: { operation: "stop", services: ["db", "web", "worker"] }, + routingOperation: { disposition: "prospective", code: null }, + routingHandoff: "held", +}; +test("emitted partial-stop control requires prospective authority before one original-ID stop and exact marker", async () => { + const result = await replay(PENDING, ["container", "stop", ...IDS]); + expect(result).toEqual({ + code: 71, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "known-routing-partial-stop", + markerMode: 0o600, + writeError: null, + }); +}); +test("emitted partial-stop marker refuses an existing private leaf without overwriting it", async () => { + expect(await replay(PENDING, ["container", "stop", ...IDS], true)).toEqual({ + code: -1, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "existing-private-marker", + markerMode: 0o600, + writeError: "EEXIST", + }); +}); +test("emitted partial-stop control refuses stale journals, incomplete selection and foreign argv without effects", async () => { + for (const state of [ + { ...PENDING, adoption_receipt_version: 11 }, + { ...PENDING, routingOperation: { disposition: "settled", code: 0 } }, + { + ...PENDING, + pendingOperation: { + operation: "start", + services: ["db", "web", "worker"], + }, + }, + { ...PENDING, routingHandoff: "releasing" }, + ]) { + expect(await replay(state, ["container", "stop", ...IDS])).toEqual({ + code: 98, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); + } + expect(await replay(PENDING, ["container", "stop", IDS[0]])).toEqual({ + code: 99, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); +}); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts new file mode 100644 index 000000000..fad4583aa --- /dev/null +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -0,0 +1,699 @@ +import { test as boundedTest, expect, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as privateState from "../src/lib/native-compose-private-state.ts"; +import { + ROUTING_CANARY, + ROUTING_IDS, + retainedRoutingFixture, + retainedRoutingFixtureLifetime, +} from "./helpers/retained-routing-adoption.ts"; + +let h: Awaited>; +let fixtureActive = false; +let fixtureUncertain = false; +const test = ( + name: string, + run: () => Promise, + opts: { readonly timeoutMs?: 30_000 | 60_000 } = {} +) => { + const timeoutMs = opts.timeoutMs ?? 30_000; + return boundedTest( + name, + async () => { + if (fixtureActive || fixtureUncertain) { + fixtureUncertain = true; + throw new Error( + "Prior retained routing fixture lifetime is unknown; values omitted." + ); + } + fixtureActive = true; + const lifetime = retainedRoutingFixtureLifetime(Date.now() + timeoutMs); + let issued = false; + let failed = false; + let failure: unknown; + try { + h = await retainedRoutingFixture(lifetime); + issued = true; + await run(); + } catch (error: unknown) { + failed = true; + failure = error; + } finally { + if (!lifetime.canRestore()) { + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture lifetime is unknown; values omitted." + ); + } + } else if (issued) { + try { + await h.cleanup(); + fixtureActive = false; + } catch (error: unknown) { + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = error; + } + } + } else { + // Setup did not issue a complete fixture; do not reuse its global context. + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture setup is incomplete; values omitted." + ); + } + } + } + if (failed) { + throw failure; + } + }, + timeoutMs + ); +}; +async function prepare() { + const store = await h.store(); + try { + return { store, generation: await store.prepare({ binary: h.compiler }) }; + } catch (error: unknown) { + await store.close(); + throw error; + } +} +async function red(value: Promise) { + try { + await value; + throw new Error("unexpected synthetic success"); + } catch (error: unknown) { + expect(String(error)).toMatch(/values omitted/i); + expect(String(error)).not.toContain(ROUTING_CANARY); + expect(String(error)).not.toContain(h.root); + } +} +test( + "v14 keeps original resources and data through stop/publication/up/down/up/rollback and saved open", + async () => { + const phase = (value: string) => { + try { + const inspections = (kind: string) => + h.commands.filter((args) => args[0] === kind && args[1] === "inspect") + .length; + console.info( + JSON.stringify({ + fixture: "retained-routing-compound", + phase: value, + probes: h.commands.length, + containerInspections: inspections("container"), + networkInspections: inspections("network"), + volumeInspections: inspections("volume"), + effects: h.effects.length, + }) + ); + } catch { + // Diagnostic failure must not skip close or replace an original test error. + } + }; + phase("prepare-enter"); + const { store, generation } = await prepare(); + phase("prepare-return"); + try { + expect(generation.report.adoption_generation_version).toBe(14); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + phase("preparation-stop-return"); + await store.publish({ generation, binary: h.compiler }); + phase("publication-return"); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + phase("active-load-return"); + for (const operation of ["start", "stop", "start", "stop"] as const) { + expect(await h.operation(store, active, operation)).toBe(0); + } + phase("four-lifecycle-operations-return"); + await store.withLease({ + generation: active, + run: async (input) => { + expect(input.retainedRouting).toBe(true); + expect(input.routingResolution).toEqual(h.resolution); + expect(JSON.stringify(input)).not.toContain("original.hack"); + }, + }); + phase("saved-lease-return"); + await store.rollback(); + phase("rollback-return"); + expect( + await readFile(join(h.root, ".hack/hack.config.json"), "utf8") + ).toBe(h.config); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + h.effects.every( + (args) => + args.length === 5 && + args[3] === ROUTING_IDS.db && + args[4] === ROUTING_IDS.web + ) + ).toBe(true); + expect( + h.commands.some( + (args) => + args.includes("pull") || + args.includes("build") || + args.includes("create") || + args.includes("rm") + ) + ).toBe(false); + expect(h.commands.length).toBeLessThanOrEqual(9000); + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBeLessThanOrEqual(136); + phase("assertions-complete"); + } finally { + await store.close(); + phase("store-close-return"); + } + // One full cold preparation/publication, four lifecycle operations and rollback. + // Canonical bracketed scans add four probes per stable binding observation. + }, + { timeoutMs: 60_000 } +); +test("read-only routing phase refuses volume birth drift at its complete final binding", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + expect( + h.commands.filter((args) => args[0] === "volume" && args[1] === "inspect") + .length + ).toBeGreaterThan(changedAtVolumeRead ?? Number.POSITIVE_INFINITY); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("read-only routing phase still inspects a newly introduced foreign site writer", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let introduced = false; + let observed = false; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + introduced = true; + h.model.foreign = true; + } else if ( + introduced && + args[0] === "container" && + args[1] === "inspect" && + args.includes(ROUTING_IDS.foreign) + ) { + observed = true; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(introduced).toBe(true); + expect(windowVolumeReads).toBe(4); + expect(observed).toBe(true); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("completed routing observation cannot carry a resource proof into a later operation", async () => { + const { store, generation } = await prepare(); + try { + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation).toBeNull(); + } finally { + await store.close(); + } +}); +for (const changed of ["source", "receipt"] as const) { + test(`read-only routing phase rechecks ${changed} authority inside its observation window`, async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + const path = + changed === "source" + ? join(h.root, ".hack/hack.config.json") + : h.receiptPath; + const bytes = await readFile(path); + await fs.rename(path, join(h.outer, `replaced-${changed}`)); + await fs.writeFile(path, bytes, { mode: 0o600, flag: "wx" }); + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + if (changedAtVolumeRead === undefined) { + throw new Error("Synthetic authority replacement was not reached"); + } + // A cheap inner authority check must refuse before the final resource scan. + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBe(changedAtVolumeRead); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } + }); +} +test("numeric callback cannot settle a prospective child; explicit stop containment retains original uncertainty", async () => { + const { store, generation } = await prepare(); + try { + await red( + h.operation(store, generation, "stop", { + preparation: true, + numeric: true, + }) + ); + const before = await readFile(h.receiptPath, "utf8"); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + expect(h.effects).toEqual([]); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + await red( + h.operation(store, pending, "stop", { preparation: true, recover: true }) + ); + expect(await readFile(h.receiptPath, "utf8")).toBe(before); + expect(h.effects).toHaveLength(1); + await red(store.publish({ generation: pending, binary: h.compiler })); + } finally { + await store.close(); + } +}); +test("a source read resumed after callback return cannot spawn an original-ID child", async () => { + const { store, generation } = await prepare(); + const entered = Promise.withResolvers(); + const released = Promise.withResolvers(); + let armed = false; + let paused = false; + let late: Promise | undefined; + let restoreRead: (() => void) | undefined; + try { + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (armed && !paused && args[0].includes("/generations/")) { + paused = true; + entered.resolve(); + await released.promise; + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + const deadline = Date.now() + 15_000; + await red( + store.withPreparationStop({ + generation, + binary: h.compiler, + deadline, + run: async (input) => { + armed = true; + late = h.track( + runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + }) + ); + await entered.promise; + return 0; + }, + }) + ); + expect(paused).toBe(true); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + const retained = await readFile(h.receiptPath, "utf8"); + released.resolve(); + if (!late) { + throw new Error("Synthetic delayed operation was not reached"); + } + await red(late); + expect(h.effects).toEqual([]); + expect(await readFile(h.receiptPath, "utf8")).toBe(retained); + } finally { + released.resolve(); + await late?.catch(() => undefined); + if (h.canRestore()) { + restoreRead?.(); + } + await store.close(); + } +}); +test("known nonzero child retains a settled journal and exact explicit stop recovery can clear it", async () => { + const { store, generation } = await prepare(); + try { + h.model.partial = true; + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(7); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 7, + }); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + h.model.partial = false; + expect( + await h.operation(store, pending, "stop", { + preparation: true, + recover: true, + }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 0, + }); + await store.publish({ generation: pending, binary: h.compiler }); + } finally { + await store.close(); + } +}); + +boundedTest( + "fixture teardown stays refused after an unfinished continuation later settles", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + const pending = Promise.withResolvers(); + const work = lifetime.track(pending.promise); + expect(lifetime.canRestore()).toBe(false); + pending.resolve(); + await work; + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "expired fixture lifetime never restores even without pending work", + () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() - 1); + expect(lifetime.canRestore()).toBe(false); + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "known callback settlement permits fixture teardown before its unchanged deadline", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + await lifetime.track(Promise.resolve()); + expect(lifetime.canRestore()).toBe(true); + }, + 1000 +); +test("foreign route writer and missing proxy reader refuse preparation before claims or effects", async () => { + for (const kind of ["foreign", "proxy"]) { + const before = h.commands.length; + h.model.foreign = kind === "foreign"; + h.model.proxyAccess = kind !== "proxy"; + const store = await h.store(); + try { + await red(store.prepare({ binary: h.compiler })); + } finally { + await store.close(); + } + expect( + h.commands + .slice(before) + .some((args) => + kind === "foreign" + ? args[0] === "container" && + args[1] === "inspect" && + args[3]?.includes('"sites"') && + args.includes(ROUTING_IDS.foreign) + : args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) + ).toBe(true); + expect(h.effects).toEqual([]); + } +}); +test("late original birth or upstream drift never clears pending or grants rollback", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + h.hooks.afterEffect = async () => { + h.model.webBirth = "2026-02-01T01:02:03Z"; + }; + await red(h.operation(store, active, "start")); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + await red(store.rollback()); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + await readFile(join(h.root, ".hack/hack.project.json"), "utf8") + ).not.toBe(""); + } finally { + await store.close(); + } +}); +test("same-byte receipt substitution during final routing admission cannot clear pending", async () => { + const { store, generation } = await prepare(); + const saved = `${h.receiptPath}.original`; + let substituted = false; + let finalSave = false; + let restoreRead: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (args[0].endsWith(".receipt")) { + const staged: unknown = JSON.parse(value.text); + if ( + isRecord(staged) && + staged.pendingOperation === null && + isRecord(staged.routingOperation) && + staged.routingOperation.disposition === "settled" + ) { + finalSave = true; + } + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + h.hooks.afterProbe = async (args) => { + if (!(finalSave && !substituted && args[0] === "exec")) { + return; + } + substituted = true; + const bytes = await readFile(h.receiptPath); + await fs.rename(h.receiptPath, saved); + await fs.writeFile(h.receiptPath, bytes, { mode: 0o600, flag: "wx" }); + }; + await red(h.operation(store, active, "start")); + expect(substituted).toBe(true); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + expect(await readFile(h.receiptPath)).toEqual(await readFile(saved)); + expect(h.effects).toHaveLength(1); + } finally { + if (h.canRestore()) { + restoreRead?.(); + h.hooks.afterProbe = undefined; + if (substituted) { + await fs.unlink(h.receiptPath); + await fs.rename(saved, h.receiptPath); + } + } + await store.close(); + } +}); +test("restored routing proof refuses same-byte original inode replacement before claim handoff", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const configPath = join(h.root, ".hack/hack.config.json"); + const nativePath = join(h.root, ".hack/hack.project.json"); + let replaced = false; + h.hooks.afterProbe = async (args) => { + if ( + !replaced && + args[0] === "info" && + (await h.receipt()).publication?.phase === "rolling-back" && + !(await Bun.file(nativePath).exists()) && + (await Bun.file(configPath).exists()) + ) { + const bytes = await readFile(configPath); + await fs.rename(configPath, join(h.outer, "original-config-test")); + await fs.writeFile(configPath, bytes, { mode: 0o600 }); + replaced = true; + } + }; + await red(store.rollback()); + expect(replaced).toBe(true); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + await store.close(); + } +}); +test("interrupted claim handoff retries from durable releasing state after exact source restore", async () => { + const { store, generation } = await prepare(); + let restoreUnlink: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const originalUnlink = fs.unlink; + let interrupted = false; + const unlinkSpy = spyOn(fs, "unlink").mockImplementation(async (path) => { + await originalUnlink(path); + if ( + !interrupted && + String(path).includes("/compose-routing/") && + String(path).includes("/claims/") + ) { + interrupted = true; + throw new Error("Synthetic handoff interruption; values omitted."); + } + }); + restoreUnlink = () => unlinkSpy.mockRestore(); + await red(store.rollback()); + expect(interrupted).toBe(true); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + if (!h.canRestore()) { + throw new Error("Synthetic handoff lifetime is unknown; values omitted."); + } + restoreUnlink(); + restoreUnlink = undefined; + await store.repairPublication({ action: "rollback" }); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect(h.effects).toEqual([]); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + } finally { + if (h.canRestore()) { + restoreUnlink?.(); + } + await store.close(); + } +}); diff --git a/tests/native-compose-adoption-routing-receipt.test.ts b/tests/native-compose-adoption-routing-receipt.test.ts new file mode 100644 index 000000000..a7a0d676a --- /dev/null +++ b/tests/native-compose-adoption-routing-receipt.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; + +const checkout = { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, +}; +const generation = { + id: "a".repeat(32), + manifest: { dev: 1, ino: 5, hash: "b".repeat(64) }, +}; +const reference = { + attemptId: "c".repeat(32), + generationIdentity: generation.id, + intent: { dev: 1, ino: 6, hash: "d".repeat(64) }, + reservation: { dev: 1, ino: 7, hash: "e".repeat(64) }, +}; +function fixture() { + return { + adoption_receipt_version: 14, + kind: "legacy-compose-adopted", + checkout, + prepared: generation, + publication: { + generation, + phase: "active", + native: { dev: 1, ino: 8, hash: "f".repeat(64) }, + }, + pendingOperation: { + generation, + operation: "start", + services: ["db", "web"], + }, + routingOperation: { + generation, + token: "1".repeat(32), + reference, + disposition: "prospective" as const, + code: null, + }, + routingHandoff: "held", + }; +} +test("required v14 child disposition binds the exact generation, token and route reference", () => { + const value = fixture(), + parsed = parseLegacyComposeAdoptionReceipt(value, checkout); + expect(parsed.adoption_receipt_version).toBe(14); + expect(parsed.routingOperation).toEqual(value.routingOperation); + expect(parsed.routingHandoff).toBe("held"); + expect(Object.isFrozen(parsed.routingOperation?.reference)).toBe(true); + expect( + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 17, + }, + }, + checkout + ).routingOperation?.code + ).toBe(17); +}); +for (const change of [ + { routingOperation: undefined }, + { routingHandoff: undefined }, + { prepared: null }, + { pendingOperation: null }, + { routingOperation: null }, + { routingHandoff: "releasing" }, +]) { + test(`v14 cannot discard required pending authority ${Object.keys(change).join()}`, () => { + expect(() => + parseLegacyComposeAdoptionReceipt({ ...fixture(), ...change }, checkout) + ).toThrow(); + }); +} +test("foreign reference, malformed disposition and uncertain code cannot become settlement", () => { + const value = fixture(); + for (const change of [ + { reference: { ...reference, generationIdentity: "0".repeat(32) } }, + { reference: { ...reference, reservation: undefined } }, + { token: "private" }, + { token: 1 }, + { code: 0 }, + { disposition: "settled", code: null }, + { disposition: "settled", code: 256 }, + { disposition: "settled", code: -1 }, + { disposition: "other" }, + ]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { ...value.routingOperation, ...change }, + }, + checkout + ) + ).toThrow(); + } +}); +test("rollback handoff is allowed only after pending clearance in rollback phases", () => { + const value = fixture(); + for (const phase of ["rolling-back", "rolled-back"]) { + const parsed = parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 0, + }, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ); + expect(parsed.routingHandoff).toBe("releasing"); + } + for (const phase of ["switching", "active"]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: null, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ) + ).toThrow(); + } +}); +test("older receipts do not silently ignore required v14 routing state", () => { + const value = fixture(); + for (const version of [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version }, + checkout + ) + ).toThrow(); + } + const { + routingOperation: _operation, + routingHandoff: _handoff, + ...older + } = value; + expect( + parseLegacyComposeAdoptionReceipt( + { ...older, adoption_receipt_version: 1 }, + checkout + ).adoption_receipt_version + ).toBe(1); +}); diff --git a/tests/native-compose-adoption-routing-resolution.test.ts b/tests/native-compose-adoption-routing-resolution.test.ts new file mode 100644 index 000000000..f2abc04a3 --- /dev/null +++ b/tests/native-compose-adoption-routing-resolution.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from "bun:test"; +import { legacyComposeRoutingResolutionMatches } from "../src/lib/native-compose-adoption-routing-resolution.ts"; +import { resolveNativeComposeOpenOrigin } from "../src/lib/native-compose-open.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; + +function fixture(authoredPreference: "auto" | "alias" | "dev" = "alias") { + const routing = mapLegacyComposeRouting({ + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: authoredPreference }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: "static:1", + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + }, + }, + })?.intent; + if (!routing) { + throw new Error("Synthetic route missing"); + } + const resolution: NativeRoutingResolution = { + domain: "local.test", + domain_origin: "checkout_local", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "primary_local", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + return { routing, resolution }; +} +test("typed-local domain precedence may retain exact served origins and saved open preference", () => { + const selected = fixture(); + expect(legacyComposeRoutingResolutionMatches(selected)).toBe(true); + expect( + resolveNativeComposeOpenOrigin({ resolution: selected.resolution }) + ).toBe("https://original.hack.gy"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + prefer: "dev", + }) + ).toBe("https://original.hack.local"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + target: "web", + }) + ).toBe("https://original.hack.gy"); +}); +test("effective checkout preference can differ from raw project preference without changing served origins", () => { + for (const [authored, effective, expected] of [ + ["dev", "alias", "https://original.hack.gy"], + ["alias", "dev", "https://original.hack.local"], + ["dev", "auto", "https://original.hack.gy"], + ] as const) { + const { routing, resolution } = fixture(authored); + const selected: NativeRoutingResolution = { + ...resolution, + open_preference: effective, + open_preference_origin: "checkout_local", + open_origin: expected, + }; + expect(routing.openOrigin).not.toBe(expected); + expect( + legacyComposeRoutingResolutionMatches({ routing, resolution: selected }) + ).toBe(true); + expect(resolveNativeComposeOpenOrigin({ resolution: selected })).toBe( + expected + ); + } +}); +test("effective preference cannot select an inconsistent or unavailable origin", () => { + const { routing, resolution } = fixture("dev"); + const web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { open_preference: "dev", open_origin: "https://original.hack.gy" }, + { open_preference: "alias", open_origin: "https://original.hack.local" }, + { open_preference: "auto", open_origin: "https://original.hack.local" }, + ] as const) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + } + expect( + legacyComposeRoutingResolutionMatches({ + routing: { ...routing, aliasHost: null }, + resolution: { + ...resolution, + aliases: {}, + oauth_alias: null, + open_preference: "alias", + open_origin: "https://original.hack.local", + routes: { + web: { ...web, aliases: {} }, + }, + }, + }) + ).toBe(false); +}); +for (const change of [ + { branch: "other" }, + { project_origin: "https://renamed.test" }, + { open_origin: "https://renamed.test" }, + { aliases: {} }, + { oauth_alias: null }, + { routes: {} }, +]) { + test(`effective routing refuses changed served selection ${JSON.stringify(change)}`, () => { + const { routing, resolution } = fixture(); + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + }); +} +test("route target, port, transport and aliases cannot drift behind matching apex metadata", () => { + const { routing, resolution } = fixture(), + web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { service: "other" }, + { port: 3001 }, + { protocol: "https" as const }, + { origin: "https://renamed.test" }, + { aliases: {} }, + ]) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, routes: { web: { ...web, ...change } } }, + }) + ).toBe(false); + } +}); diff --git a/tests/native-compose-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); +}); diff --git a/tests/native-compose-engine-identity.test.ts b/tests/native-compose-engine-identity.test.ts index 91d76a6b5..e42757772 100644 --- a/tests/native-compose-engine-identity.test.ts +++ b/tests/native-compose-engine-identity.test.ts @@ -22,6 +22,7 @@ import type { Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + bindNativeComposeEngineSocketPath, createNativeComposeEngineIdentityObserver, NativeComposeEngineIdentityError, } from "../src/lib/native-compose-engine-identity.ts"; @@ -584,3 +585,75 @@ else console.log(JSON.stringify({id:${JSON.stringify(PROXY)}, project:"hack-dev- } } }); + +test("pure socket path binding accepts a real Unix leaf without querying an engine or spawning", () => { + const spawn = spyOn(Bun, "spawn"); + try { + const bound = bindNativeComposeEngineSocketPath(socket); + expect(bound.path).toBe(socket); + expect(() => bound.assertFresh()).not.toThrow(); + expect(Object.isFrozen(bound)).toBe(true); + expect(Object.keys(bound)).toEqual(["path", "assertFresh"]); + expect(requests).toHaveLength(0); + expect(connections.size).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + } finally { + spawn.mockRestore(); + } +}); +test("pure socket path binding pins alias identity and refuses retargeting before a query", async () => { + const other = join(root, "other.sock"), + alias = join(root, "pure-alias.sock"); + await listen(other); + await symlink("engine.sock", alias); + const bound = bindNativeComposeEngineSocketPath(alias); + expect(bound.path).toBe(socket); + await unlink(alias); + await symlink("other.sock", alias); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding refuses ancestor rebinding even to the same target", async () => { + const alias = join(root, "pure-parent"); + await symlink(root, alias); + const bound = bindNativeComposeEngineSocketPath(join(alias, "engine.sock")); + await unlink(alias); + await symlink(root, alias); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding refuses physical replacement and unchanged-name wrong type", async () => { + const bound = bindNativeComposeEngineSocketPath(socket); + await rename(socket, join(root, "pure-original.sock")); + await listen(socket); + expect(() => bound.assertFresh()).toThrow(NativeComposeEngineIdentityError); + const replacement = bindNativeComposeEngineSocketPath(socket); + await rename(socket, join(root, "pure-replacement.sock")); + await writeFile(socket, "not a socket", { mode: 0o600 }); + expect(() => replacement.assertFresh()).toThrow( + NativeComposeEngineIdentityError + ); + expect(() => bindNativeComposeEngineSocketPath(socket)).toThrow( + NativeComposeEngineIdentityError + ); + expect(requests).toHaveLength(0); +}); +test("pure socket path binding pins mode and refuses ambiguous or relative selection", async () => { + const bound = bindNativeComposeEngineSocketPath(socket); + await chmod(socket, 0o600); + const changed = bindNativeComposeEngineSocketPath(socket); + await chmod(socket, 0o660); + expect(() => changed.assertFresh()).toThrow(NativeComposeEngineIdentityError); + for (const path of [ + "engine.sock", + `${socket}?query`, + `${socket}\0`, + join(root, "missing.sock"), + ]) { + expect(() => bindNativeComposeEngineSocketPath(path)).toThrow( + NativeComposeEngineIdentityError + ); + } + expect(bound.path).toBe(socket); + expect(requests).toHaveLength(0); +}); diff --git a/tests/native-compose-file-permissions.test.ts b/tests/native-compose-file-permissions.test.ts new file mode 100644 index 000000000..609124030 --- /dev/null +++ b/tests/native-compose-file-permissions.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { + nativeComposeFileMode, + nativeComposeFileModeBits, + nativeComposeFileModeBitsValid, +} from "../src/lib/native-compose-file-permissions.ts"; + +test.each([ + ["0444", 0o444], + ["0400", 0o400], + ["0600", 0o600], +] as const)("closed retained mode codec preserves %s", (mode, bits) => { + expect(nativeComposeFileMode(mode)).toBe(mode); + expect(nativeComposeFileModeBits(mode)).toBe(bits); + expect(nativeComposeFileModeBitsValid(bits)).toBe(true); +}); +test.each( + ["0000", "0644", "0777", "400", "600", 0o400, 0o600, null, false, {}, []].map( + (mode) => ({ mode }) + ) +)("retained mode codec refuses unsupported %j", ({ mode }) => { + expect(nativeComposeFileMode(mode)).toBeUndefined(); + expect(nativeComposeFileModeBits(mode)).toBeUndefined(); +}); +test.each( + [0, 0o644, 0o777, "0444", null, false, {}, []].map((bits) => ({ bits })) +)("retained mode bits refuse unsupported %j", ({ bits }) => { + expect(nativeComposeFileModeBitsValid(bits)).toBe(false); +}); diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index f758b63a3..12da75c7d 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -7,6 +7,10 @@ import { mergeNativeComposeNetworkPolicies, NativeComposeOwnershipError, type NativeComposeOwnershipOptions, + type NativeComposeOwnershipRefusal, + type NativeComposeTopologyPredicate, + nativeComposeOwnershipRefusal, + nativeComposeTopologyPredicate, observeNativeComposeStartupOwned, observeSavedNativeComposeOwned, } from "../src/lib/native-compose-ownership.ts"; @@ -172,20 +176,218 @@ async function commands(): Promise { } async function expectRefusal( opts = options, - code = "E_NATIVE_COMPOSE_OWNERSHIP" + code = "E_NATIVE_COMPOSE_OWNERSHIP", + reason?: NativeComposeOwnershipRefusal, + topologyPredicate?: NativeComposeTopologyPredicate ) { + let captured: unknown; try { await assertNativeComposeOwned(opts); throw new Error("unexpected probe success"); } catch (error: unknown) { + captured = error; expect(error).toBeInstanceOf(NativeComposeOwnershipError); expect(error).toMatchObject({ code }); + if (reason !== undefined) { + expect(nativeComposeOwnershipRefusal(error)).toBe(reason); + } + if (topologyPredicate !== undefined) { + expect(nativeComposeTopologyPredicate(error)).toBe(topologyPredicate); + } expect(String(error)).not.toContain(CANARY); expect(JSON.stringify(error)).not.toContain(CANARY); } expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); + return captured; } +test("ownership refusal diagnostics cannot be forged, copied or obtained through getters", () => { + let issued: unknown; + try { + mergeNativeComposeNetworkPolicies({ + proposed: [], + retained: [ + [{ name: "fixture_default", driver: "bridge", internal: false }], + ], + }); + } catch (error: unknown) { + issued = error; + } + expect(issued).toBeInstanceOf(NativeComposeOwnershipError); + if (!(issued instanceof NativeComposeOwnershipError)) { + throw new Error("Missing owner-issued refusal"); + } + expect(nativeComposeOwnershipRefusal(issued)).toBe("bridge-policy"); + expect(issued).toMatchObject({ code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION" }); + expect(String(issued)).toBe( + "NativeComposeOwnershipError: Native Compose network topology changed. Run hack down for this instance before applying the change; values omitted." + ); + expect(JSON.parse(JSON.stringify(issued))).toEqual({ + code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION", + name: "NativeComposeOwnershipError", + }); + const clone = { ...issued, reason: "bridge-policy", secret: CANARY }; + let getters = 0; + const accessor = Object.defineProperty({}, "reason", { + get() { + getters += 1; + throw new Error(CANARY); + }, + }); + const proxy = new Proxy( + {}, + { + get() { + getters += 1; + throw new Error(CANARY); + }, + } + ); + for (const candidate of [ + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + clone, + Object.create(issued), + accessor, + proxy, + null, + undefined, + CANARY, + 1, + ]) { + expect(nativeComposeOwnershipRefusal(candidate)).toBeUndefined(); + expect(nativeComposeTopologyPredicate(candidate)).toBeUndefined(); + } + expect(getters).toBe(0); +}); + +test("topology predicates distinguish original refusal sites and preserve the first failure", async () => { + const predicates: NativeComposeTopologyPredicate[] = [ + "network-members-shape", + "network-member-id", + "member-container-endpoint", + "workload-policy", + "workload-endpoint-keyset", + "created-endpoint-membership", + "live-endpoint-membership", + ]; + for (const predicate of predicates) { + const fixture = owned(); + const container = fixture.container?.[0]; + const network = fixture.network?.[0]; + if (!(container && network)) { + throw new Error("Missing topology fixture"); + } + let selection = options; + if (predicate === "network-members-shape") { + network.containers = null; + // A later keyset failure must not replace the first refused predicate. + container.networks = {}; + } else if (predicate === "network-member-id") { + network.containers = { invalid: {} }; + } else if (predicate === "member-container-endpoint") { + network.containers = { ["f".repeat(64)]: {} }; + } else if (predicate === "workload-policy") { + selection = { ...options, expectedWorkloadNetworks: [] }; + } else if (predicate === "workload-endpoint-keyset") { + (container.networks as Record).foreign = {}; + } else if (predicate === "created-endpoint-membership") { + container.state = "created"; + container.networks = { + [`${PROJECT}_default`]: { NetworkID: "", Aliases: null }, + }; + } else { + network.containers = {}; + } + await prepare(fixture); + let error: unknown; + if (predicate === "created-endpoint-membership") { + try { + await observeSavedNativeComposeOwned(selection); + } catch (caught) { + error = caught; + } + expect(nativeComposeOwnershipRefusal(error)).toBe("topology"); + expect(nativeComposeTopologyPredicate(error)).toBe(predicate); + } else { + error = await expectRefusal( + selection, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + predicate + ); + } + const serialized = JSON.stringify(error); + expect(serialized).not.toContain(predicate); + expect(serialized).not.toContain(CANARY); + expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); + } +}, 30_000); + +test("topology predicate evidence rejects hostile fields and cannot be copied or forged", async () => { + const fixture = owned(); + const network = fixture.network?.[0]; + if (!network) { + throw new Error("Missing topology fixture"); + } + network.containers = {}; + await prepare(fixture); + const issued = await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + "live-endpoint-membership" + ); + expect(nativeComposeTopologyPredicate(issued)).toBe( + "live-endpoint-membership" + ); + let reads = 0; + const hostile = new Proxy( + {}, + { + get() { + reads++; + throw new Error(CANARY); + }, + has() { + reads++; + throw new Error(CANARY); + }, + } + ); + const accessor = Object.defineProperty({}, "topologyPredicate", { + get() { + reads++; + throw new Error(CANARY); + }, + }); + for (const candidate of [ + { topologyPredicate: "live-endpoint-membership" }, + { ...(issued as object), topologyPredicate: "live-endpoint-membership" }, + Object.create(issued as object), + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + hostile, + accessor, + null, + undefined, + CANARY, + ]) { + expect(nativeComposeTopologyPredicate(candidate)).toBeUndefined(); + } + expect(reads).toBe(0); + // A later independent refusal cannot relabel the exact first issued error. + network.containers = null; + await prepare(fixture); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "topology", + "network-members-shape" + ); + expect(nativeComposeTopologyPredicate(issued)).toBe( + "live-endpoint-membership" + ); +}); + test("selected on-failure restart is unready until two stable owned scans", async () => { const fixture = owned(); const container = fixture.container?.[0]; @@ -198,7 +400,7 @@ test("selected on-failure restart is unready until two stable owned scans", asyn await prepare(fixture); expect(await observeNativeComposeStartupOwned(options, ["web"])).toBeNull(); // The same observation must never authorize effect or finalization ownership. - await expectRefusal(options); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "topology"); fixture.mode = "restart-member-transition"; await prepare(fixture); await expectRefusal(options); @@ -453,7 +655,11 @@ test("old or missing owner tokens never adopt same-instance resources", async () } row.owner = owner; await prepare(fixture); - await expectRefusal(); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "resource-label" + ); } } }); @@ -465,7 +671,7 @@ test("unsolicited raw inspect fields are rejected without disclosing private val } row.Config = { Env: [CANARY], Image: CANARY }; await prepare(fixture); - await expectRefusal(); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "unknown"); }); test("every project container is checked, including unknown services and stale or absent generation", async () => { for (const fields of [ @@ -494,11 +700,23 @@ test("every project container is checked, including unknown services and stale o state: "running", exitCode: 0, health: null, + networks: { + [`${PROJECT}_default`]: { + NetworkID: NETWORK_ID, + Aliases: ["otherwise-unexpected-name", "web"], + }, + }, ...fields, }, ]; await prepare(fixture); - await expectRefusal(); + const reason = + "generation" in fields + ? "generation" + : "state" in fields || "health" in fields || "exitCode" in fields + ? "state" + : "resource-label"; + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", reason); } }); test("persistent storage must match exact generated name and logical storage ownership", async () => { @@ -533,7 +751,7 @@ test("retained volume policy requires exact presence and birth while cold invent { name: VOLUME, storage: "data", createdAt: CREATED }, ]); await prepare({}); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); // Legacy history can require presence without inventing a prior birth. await expectRefusal({ ...options, @@ -547,7 +765,7 @@ test("retained volume policy requires exact presence and birth while cold invent } volume.createdAt = REBORN; await prepare(replaced); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); }); test.each([ null, @@ -614,7 +832,8 @@ test("vanished resources and changed selected inventory refuse without retry or options, mode === "vanished" ? "E_NATIVE_COMPOSE_PROBE" - : "E_NATIVE_COMPOSE_OWNERSHIP" + : "E_NATIVE_COMPOSE_OWNERSHIP", + mode === "vanished" ? undefined : "cross-scan-drift" ); } }); @@ -833,7 +1052,13 @@ test("custom bridge driver, internal flag, aliases, generation and unexpected en fixture.mode = change; } await prepare(fixture); - await expectRefusal(selection); + const reason = + change === "driver" || change === "internal" + ? "bridge-policy" + : change === "alias" || change === "endpoint-drift" + ? "endpoint" + : "topology"; + await expectRefusal(selection, "E_NATIVE_COMPOSE_OWNERSHIP", reason); expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } }); diff --git a/tests/native-compose-route-claims.test.ts b/tests/native-compose-route-claims.test.ts index f2fde4194..3ed37da2d 100644 --- a/tests/native-compose-route-claims.test.ts +++ b/tests/native-compose-route-claims.test.ts @@ -180,6 +180,111 @@ test("foreign same-origin collision refuses before caller effect and preserves o expect(await winner.reopen(attempt.reference)).toEqual(attempt); }); +test("retained stopped recovery marks only exact referenced uncertainty and keeps its hostname claimed", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + const original = await Bun.file(claimPath(root)).text(); + await result.markEffectsPossible(attempt); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async (selection) => { + expect(selection).toEqual({ + hostnames: [HOST], + binding: BINDING, + owner: OWNER, + }); + }, + }); + expect((await result.reopen(attempt.reference)).phase).toBe("stopped"); + expect(await Bun.file(claimPath(root)).text()).toBe(original); + await result.assertHeld(attempt.reference); + await expect(acquire(await store(root, OTHER))).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_ROUTE_CONFLICT", + }); + await expect( + result.complete({ attempt, assertTransition: async () => {} }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); +}); +test("retained handoff refuses unknown children and failed restored-source proof without releasing a claim", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await result.markEffectsPossible(attempt); + let callbacks = 0; + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + callbacks++; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + expect(callbacks).toBe(0); + const bytes = await Bun.file(claimPath(root)).text(); + await expect( + result.recoverRetainedStopped({ + references: [], + assertStopped: async () => {}, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + await expect( + result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => { + throw new Error("synthetic stopped proof refused"); + }, + }) + ).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).phase).toBe("armed"); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => {}, + }); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + throw new Error("synthetic restored source refused"); + }, + }) + ).rejects.toThrow(); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); +}); +test("retained rollback handoff is exact, revalidates claim incarnation and is resumable after removal", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await complete(result, attempt); + const bytes = await fs.readFile(claimPath(root)); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + await fs.rename(claimPath(root), `${claimPath(root)}.original`); + await fs.writeFile(claimPath(root), bytes, { mode: 0o600 }); + }, + }) + ).rejects.toThrow(); + await fs.unlink(claimPath(root)); + await fs.rename(`${claimPath(root)}.original`, claimPath(root)); + const callbacks: (readonly string[])[] = []; + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(await Bun.file(claimPath(root)).exists()).toBe(false); + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(callbacks).toEqual([[HOST], []]); + await expect(result.assertHeld(attempt.reference)).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).reference).toEqual( + attempt.reference + ); +}); + test("real simultaneous independent processes have exactly one hostname winner and effect", async () => { const root = await fixture(); const module = new URL( diff --git a/tests/native-config-import-routing.test.ts b/tests/native-config-import-routing.test.ts new file mode 100644 index 000000000..7d6d17963 --- /dev/null +++ b/tests/native-config-import-routing.test.ts @@ -0,0 +1,352 @@ +import { expect, test } from "bun:test"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedRoutingAdoption, + planLegacyComposeSourceBindAdoption, +} from "../src/lib/native-compose-adoption-plan.ts"; +import { + mapLegacyNativeImport, + mapLegacyNativeRetainedRouting, + mapLegacyNativeRetainedSourceBind, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; + +const CANARY = "private-route-source-canary"; +function fixture() { + return { + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true, tld: "gy" }, + open: { prefer: "alias" }, + worktree: { auto_branch: false }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: CANARY, + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + db: { image: "db:1", volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + }, + }; +} +function inputs(config: unknown, compose: unknown) { + return { + configText: JSON.stringify(config), + composeText: JSON.stringify(compose), + }; +} +test("retained route and source-bind planners keep their separate positive families", () => { + const { config, compose } = fixture(); + const routed = inputs(config, compose); + expect( + planLegacyComposeRetainedRoutingAdoption(routed).intent?.routing + ).toBeDefined(); + expect(planLegacyComposeSourceBindAdoption(routed).intent).toBeUndefined(); + expect(mapLegacyNativeRetainedSourceBind(routed).candidate).toBeUndefined(); + const bound = inputs( + { name: "fixture", worktree: { auto_branch: false } }, + { + name: "fixture", + services: { + db: { + image: "db:1", + volumes: [ + "data:/data", + { + type: "bind", + source: "../src", + target: "/work", + read_only: true, + bind: { create_host_path: false }, + }, + ], + }, + }, + volumes: { data: {} }, + } + ); + expect(mapLegacyNativeRetainedSourceBind(bound).candidate).toBeDefined(); + expect(planLegacyComposeSourceBindAdoption(bound).intent).toMatchObject({ + sourceBinds: [ + { service: "db", source: "src", target: "/work", readOnly: true }, + ], + }); + expect( + planLegacyComposeRetainedRoutingAdoption(bound).intent + ).toBeUndefined(); + expect(mapLegacyNativeRetainedRouting(bound).candidate).toBeUndefined(); +}); +test("a routed source with a directory bind cannot collapse into either retained family", () => { + const { config, compose } = fixture(); + const mixed = inputs(config, { + ...compose, + services: { + ...compose.services, + db: { + ...compose.services.db, + volumes: [ + ...compose.services.db.volumes, + { + type: "bind", + source: "../src", + target: "/work", + read_only: true, + bind: { create_host_path: false }, + }, + ], + }, + }, + }); + for (const planned of [ + planLegacyComposeRetainedRoutingAdoption(mixed), + planLegacyComposeSourceBindAdoption(mixed), + ]) { + expect(planned.report.supported).toBe(false); + expect(planned.intent).toBeUndefined(); + } +}); +test("v14 preserves the literal legacy host, existing alias, open preference and named storage without changing old families", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const before = JSON.stringify({ config, compose }); + const mapped = mapLegacyNativeRetainedRouting(source); + const planned = planLegacyComposeRetainedRoutingAdoption(source); + expect(mapped.report.complete).toBe(true); + expect(mapped.candidate).toMatchObject({ + routes: { + origin: "https://original.hack.local", + aliases: { oauth: { origin: "https://original.hack.gy" } }, + oauth_alias: "oauth", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }, + open: { prefer: "alias" }, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); + expect(planned.report.supported).toBe(true); + expect(planned.intent?.routing?.openOrigin).toBe("https://original.hack.gy"); + expect(planned.intent?.volumes).toEqual([ + { name: "fixture_data", storage: "data" }, + ]); + expect(planned.intent?.ownedNetwork).toBeUndefined(); + expect(JSON.stringify({ config, compose })).toBe(before); + for (const older of [ + mapLegacyNativeImport(source), + mapLegacyNativeStorageAdoption(source), + ]) { + expect(older.report.complete).toBe(false); + } + expect(planLegacyComposeAdoption(source).report.supported).toBe(false); + expect(JSON.stringify(mapped)).not.toContain(CANARY); + expect(JSON.stringify(planned)).not.toContain("original.hack"); + expect(Object.isFrozen(planned.intent?.routing?.routes[0]?.labels)).toBe( + true + ); +}); +test("custom existing hosts keep their exact primary origin without inventing an OAuth alias", () => { + const { config, compose } = fixture(); + const selected = { + ...config, + dev_host: "app.example.test", + open: { prefer: "auto" }, + }; + compose.services.web.labels.caddy = "app.example.test"; + const mapped = mapLegacyComposeRouting({ config: selected, compose }); + expect(mapped?.intent.aliasHost).toBeNull(); + expect(mapped?.intent.openOrigin).toBe("https://app.example.test"); + expect(mapped?.candidate.routes).toEqual({ + origin: "https://app.example.test", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }); + expect( + mapLegacyComposeRouting({ + config: { ...selected, open: { prefer: "alias" } }, + compose, + }) + ).toBeUndefined(); +}); +test("closed literal label-list syntax and one relative service route preserve paired hosts", () => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + api: { + image: "api:1", + networks: ["default", "hack-dev"], + labels: [ + "caddy=api.original.hack.local,api.original.hack.gy", + "caddy.reverse_proxy={{upstreams http 8080}}", + "caddy.tls=internal", + "caddy_ingress_network=hack-dev", + ], + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ?.routing?.routes[0] + ).toMatchObject({ service: "api", hostname: "api", port: 8080 }); +}); +test.each([ + { dev_host: "${PRIVATE}" }, + { dev_host: "UPPER.hack" }, + { dev_host: "https://original.hack.local" }, + { oauth: { enabled: true, tld: "../private" } }, + { oauth: { enabled: true, extra: CANARY } }, + { open: { prefer: "unknown" } }, + { open: { prefer: "dev", service: CANARY } }, + { domain: CANARY }, + { routes: { origin: CANARY } }, + { branch: CANARY }, +])("unsupported config cannot mint a routing candidate: %j", (change) => { + const { config, compose } = fixture(); + const result = mapLegacyNativeRetainedRouting( + inputs({ ...config, ...change }, compose) + ); + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(CANARY); +}); +test.each([ + { "caddy.tls": "external" }, + { caddy: "original.hack.local" }, + { caddy: "*.original.hack.local,original.hack.gy" }, + { caddy: "original.hack.local,original.hack.gy,extra.example.test" }, + { "caddy.reverse_proxy": "{{upstreams 0}}" }, + { "caddy.reverse_proxy": "{{upstreams 65536}}" }, + { "caddy.reverse_proxy": "http://foreign:3000" }, + { caddy_ingress_network: "foreign" }, + { caddy_1: CANARY }, + { private: CANARY }, +])("unknown label or changed host/upstream refuses the full source: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { + ...compose.services.web, + labels: { ...compose.services.web.labels, ...change }, + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); +}); +test.each([ + { networks: ["hack-dev"] }, + { networks: ["hack-dev", "default", "foreign"] }, + { networks: { "hack-dev": {}, default: {} } }, + { ports: ["3000:3000"] }, + { build: "." }, + { profiles: ["inactive"] }, + { healthcheck: { test: ["true"] } }, + { configs: [] }, + { secrets: [] }, + { volumes: ["./source:/app:ro"] }, +])("unqualified capability intersections stay refused: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { ...compose.services.web, ...change }, + }, + }; + const result = planLegacyComposeRetainedRoutingAdoption( + inputs(config, selected) + ); + expect(result.report.supported).toBe(false); + expect(result.intent).toBeUndefined(); +}); +test("extra inactive fields, duplicate host ownership and external network options never disappear from refusal", () => { + const { config, compose } = fixture(); + for (const selected of [ + { + ...compose, + networks: { "hack-dev": { external: true, name: "foreign" } }, + }, + { + ...compose, + services: { ...compose.services, other: { ...compose.services.web } }, + }, + { ...compose, "x-private": CANARY }, + { + ...compose, + services: { + ...compose.services, + db: { ...compose.services.db, network_mode: "host" }, + }, + }, + ]) { + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); + } +}); +test("the compiler apex sentinel cannot stand in for a literal project prefix", () => { + const { config, compose } = fixture(); + const prefixed = { + ...compose.services.web, + labels: { + ...compose.services.web.labels, + caddy: "project.original.hack.local,project.original.hack.gy", + }, + }; + for (const services of [ + { ...compose.services, web: prefixed }, + { ...compose.services, prefixed }, + ]) { + const source = inputs(config, { ...compose, services }); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); + } +}); +test("an explicit routing mapper never upgrades a non-routing source", () => { + const source = inputs( + { name: "fixture" }, + { + name: "fixture", + services: { db: { image: "db:1", volumes: ["data:/data"] } }, + volumes: { data: {} }, + } + ); + expect(mapLegacyNativeStorageAdoption(source).report.complete).toBe(true); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); +}); +test("private non-enumerable preparation bytes reach the exact routing mapper", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const privateSource = Object.defineProperties( + {}, + { + configText: { value: source.configText }, + composeText: { value: source.composeText }, + } + ) as typeof source; + expect(Object.keys(privateSource)).toEqual([]); + expect(mapLegacyNativeRetainedRouting(privateSource).candidate).toEqual( + mapLegacyNativeRetainedRouting(source).candidate + ); + expect( + planLegacyComposeRetainedRoutingAdoption(privateSource).intent + ).toEqual(planLegacyComposeRetainedRoutingAdoption(source).intent); +}); diff --git a/tests/native-config-protected-files-fixture.test.ts b/tests/native-config-protected-files-fixture.test.ts new file mode 100644 index 000000000..aaa190763 --- /dev/null +++ b/tests/native-config-protected-files-fixture.test.ts @@ -0,0 +1,850 @@ +import { + afterEach, + beforeEach, + test as boundedTest, + expect, + spyOn, +} from "bun:test"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runNativeFileFixtureCommand as captureNativeFileFixtureCommand } from "./e2e/native-file-permission-command.ts"; +import { + nativeProtectedFileCleanupDown, + nativeProtectedFileReadAllowed, + nativeProtectedFileStartAllowed, + nativeProtectedFileStateRefused, + nativeProtectedFileToolAllowed, +} from "./e2e/native-file-permission-control.ts"; +import { + nativeFileFixtureNonowner, + readNativeFileFixtureGuest, +} from "./e2e/native-file-permission-guest.ts"; +import { createCompletedJobFixtureSettlement } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; +import { + nativeProtectedFileContainerIdentity, + nativeProtectedFileContainerMatches, + nativeProtectedFileRemovalMatches, +} from "./e2e/scenarios/native-config-protected-files.ts"; + +import { retainedRoutingFixtureLifetime } from "./helpers/retained-routing-adoption.ts"; + +let lifetime: ReturnType | undefined; +let uncertain = false; +let expectedUnknown = false; +let restorers: (() => void)[] = []; +function ownedTest(name: string, run: () => unknown, timeoutMs = 5000) { + boundedTest( + name, + async () => { + lifetime = retainedRoutingFixtureLifetime(Date.now() + timeoutMs); + await lifetime.track(Promise.resolve().then(run)); + }, + timeoutMs + ); +} +const test = Object.assign(ownedTest, { + each: + (rows: readonly T[]) => + (name: string, run: (value: T) => unknown) => { + for (const value of rows) { + ownedTest(name.replace("%s", String(value)), () => run(value)); + } + }, + skipIf: (skip: boolean) => (skip ? boundedTest.skip : ownedTest), +}); +function runNativeFileFixtureCommand( + opts: Parameters[0] +) { + if (!lifetime) { + throw new Error("Fixture lifetime missing; values omitted."); + } + const owner = lifetime; + return owner.track( + captureNativeFileFixtureCommand({ + ...opts, + onUnconfirmed: () => { + owner.retain(); + opts.onUnconfirmed?.(); + }, + }) + ); +} + +const grants = [ + { target: "/settings", mode: "0444" as const, bytes: [1] }, + { target: "/run/secrets/owner", mode: "0400" as const, bytes: [2] }, + { target: "/run/secrets/private", mode: "0600" as const, bytes: [3] }, +]; +const observed = { + version: 1, + marker: "granted-files-exact", + uid: 123, + gid: 456, + members: grants.map((row) => ({ + target: row.target, + mode: row.mode, + uid: 234, + gid: 345, + })), +}; +test("guest permission decoder binds explicit modes and observed owner/process IDs without returning content", () => { + const value = readNativeFileFixtureGuest({ + text: JSON.stringify(observed), + expected: grants, + }); + expect(value.uid).toBe(123); + expect(value.members.map((row) => row.mode)).toEqual([ + "0444", + "0400", + "0600", + ]); + expect(nativeFileFixtureNonowner(value)).toBe(65_534); + expect(JSON.stringify(value)).not.toContain('"bytes"'); +}); +test.each([ + "mode", + "target", + "uid", + "extra", + "missing", + "order", +])("guest permission decoder refuses %s", (kind) => { + const value = structuredClone(observed); + if (kind === "mode") { + value.members[1]!.mode = "0444"; + } else if (kind === "target") { + value.members[1]!.target = "/foreign"; + } else if (kind === "uid") { + value.members[1]!.uid = -1; + } else if (kind === "extra") { + Object.assign(value, { bytes: [2] }); + } else if (kind === "missing") { + value.members.pop(); + } else { + value.members.reverse(); + } + expect(() => + readNativeFileFixtureGuest({ + text: JSON.stringify(value), + expected: grants, + }) + ).toThrow(); +}); +const ids = ["a".repeat(64), "b".repeat(64), "c".repeat(64)], + prepared = { + id: "d".repeat(32), + manifest: { dev: 1, ino: 2, hash: "e".repeat(64) }, + }; +test("source refusal requires the exact closed redacted state code and message", () => { + const value = { + ok: false, + error: { + code: "E_CONFIG_INVALID", + message: + "Legacy adoption generation state is invalid, unsafe or changed; values omitted.", + }, + }; + expect(nativeProtectedFileStateRefused(value)).toBe(true); + for (const row of [ + { ...value, ok: true }, + { ...value, error: { ...value.error, code: "E_STARTUP_INCOMPLETE" } }, + { + ...value, + error: { ...value.error, message: "arbitrary source refusal" }, + }, + Object.create(value), + ]) { + expect(nativeProtectedFileStateRefused(row)).toBe(false); + } +}); +const receipt = { + adoption_receipt_version: 8, + prepared, + publication: { phase: "active", generation: prepared }, + pendingOperation: { + operation: "start", + generation: prepared, + services: ["db", "reader", "ungranted"], + }, +}; +test("interrupted start marker requires the real whole original-ID pending selection", () => + expect( + nativeProtectedFileStartAllowed({ + args: ["container", "start", ...ids], + ids, + prepared, + receipt, + }) + ).toBe(true)); +test.each([ + "no-pending", + "foreign-generation", + "wrong-operation", + "wrong-role", + "extra-id", + "force", + "duplicate-id", +])("interrupted start refuses %s before effects", (kind) => { + const value: Record = structuredClone(receipt); + const args = ["container", "start", ...ids]; + if (kind === "no-pending") { + value.pendingOperation = null; + } else if (kind === "foreign-generation") { + value.prepared = { ...prepared, id: "f".repeat(32) }; + } else if (kind === "wrong-operation") { + value.pendingOperation = { ...receipt.pendingOperation, operation: "stop" }; + } else if (kind === "wrong-role") { + value.pendingOperation = { + ...receipt.pendingOperation, + services: ["db", "reader", "foreign"], + }; + } else if (kind === "extra-id") { + args.push("f".repeat(64)); + } else if (kind === "force") { + args.splice(2, 0, "--force"); + } else { + args[3] = ids[0]!; + } + expect( + nativeProtectedFileStartAllowed({ args, ids, prepared, receipt: value }) + ).toBe(false); +}); +const scope = { + projectRoot: "/fixture", + project: "fixture", + containerIds: ids, + networkId: "e".repeat(64), + volumeName: "fixture_data", + generationId: "f".repeat(32), + reader: ids[1]!, + targets: ["/settings"], +}; +test("closed forwarder permits fixed proof stat/hash and rejects content or other effects", () => { + expect( + nativeProtectedFileReadAllowed({ + ...scope, + args: [ + "exec", + scope.reader, + "stat", + "-c", + "%d:%i:%u:%g:%s:%f:%a", + "--", + "/settings", + ], + }) + ).toBe(true); + expect( + nativeProtectedFileReadAllowed({ + ...scope, + args: ["exec", scope.reader, "sha256sum", "--", "/settings"], + }) + ).toBe(true); + for (const args of [ + ["exec", scope.reader, "cat", "/settings"], + ["exec", ids[0]!, "sha256sum", "--", "/settings"], + ["exec", scope.reader, "sha256sum", "--", "/foreign"], + ["container", "rm", "--force", scope.reader], + ["compose", "up"], + ]) { + expect(nativeProtectedFileReadAllowed({ ...scope, args })).toBe(false); + } +}); +const pin = { + id: ids[0], + createdAt: "2026-01-01T00:00:00Z", + image: `sha256:${ids[2]}`, + labels: { project: "fixture" }, + entrypoint: ["sh", "-c"], + command: ["first", "second"], + mounts: [ + { type: "bind", source: "/fixture/secret", target: "/secret", rw: false }, + { + type: "bind", + source: "/fixture/settings", + target: "/settings", + rw: false, + }, + ], + networks: [{ name: "fixture_default", id: ids[1] }], + running: true, + paused: false, + status: "running", +}; +test("stopped cleanup preserves full immutable facts and argv order", () => { + const stopped = { ...pin, running: false, status: "exited" }; + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: stopped, + }) + ).toBe(true); + for (const value of [ + { ...stopped, running: true }, + { ...stopped, createdAt: "2027-01-01" }, + { ...stopped, id: ids[1] }, + { ...stopped, command: ["second", "first"] }, + { ...stopped, mounts: [{ ...pin.mounts[0], rw: true }, pin.mounts[1]] }, + { ...stopped, networks: [{ name: "foreign", id: ids[1] }] }, + ]) { + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: value, + }) + ).toBe(false); + } + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: { ...stopped, mounts: [...stopped.mounts].reverse() }, + }) + ).toBe(true); + expect( + nativeProtectedFileContainerIdentity({ + ...pin, + mounts: [...pin.mounts].reverse(), + }) + ).toBe(nativeProtectedFileContainerIdentity(pin)); +}); +const portPin = { + ...pin, + publishAll: false, + ports: null, + runtimePorts: { "5432/tcp": null }, +}; +const stoppedPortRow = { + ...portPin, + running: false, + status: "exited", + runtimePorts: {}, +}; +test("stopped original null runtime-port keys may become empty while full policy remains exact", () => { + expect( + nativeProtectedFileContainerMatches({ + pin: portPin, + current: stoppedPortRow, + }) + ).toBe(true); + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin: portPin, + current: stoppedPortRow, + }) + ).toBe(true); + expect(nativeProtectedFileContainerIdentity(stoppedPortRow)).not.toBe( + nativeProtectedFileContainerIdentity(portPin) + ); +}); +test("stopped runtime-port correspondence refuses foreign ports, publication and nonstopped state", () => { + for (const current of [ + { ...stoppedPortRow, runtimePorts: { "9999/tcp": null } }, + { ...stoppedPortRow, runtimePorts: null }, + { + ...stoppedPortRow, + runtimePorts: { + "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }], + }, + }, + { + ...stoppedPortRow, + ports: { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }] }, + }, + { ...stoppedPortRow, publishAll: true }, + { ...stoppedPortRow, running: true, status: "running" }, + { ...stoppedPortRow, paused: true }, + { ...stoppedPortRow, status: "created" }, + { ...stoppedPortRow, command: ["second", "first"] }, + { ...stoppedPortRow, image: `sha256:${ids[1]}` }, + ]) { + expect(nativeProtectedFileContainerMatches({ pin: portPin, current })).toBe( + false + ); + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin: portPin, + current, + }) + ).toBe(false); + } + for (const runtimePorts of [ + { "5432/tcp": [] }, + { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "15432" }] }, + ]) { + expect( + nativeProtectedFileContainerMatches({ + pin: { ...portPin, runtimePorts }, + current: stoppedPortRow, + }) + ).toBe(false); + } +}); +const cleanupReceipt = { + adoption_receipt_version: 8, + kind: "legacy-compose-adopted", + checkout: { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, + }, + prepared, + publication: { + phase: "active", + generation: prepared, + native: { dev: 1, ino: 5, hash: "f".repeat(64) }, + }, + pendingOperation: null, +}; +test("retained cleanup uses ordinary stop for clean active state and recovery only for a validated pending selection", async () => { + const calls: string[][] = []; + const invoke = async (args: readonly string[]) => { + calls.push([...args]); + return 17; + }; + expect( + await nativeProtectedFileCleanupDown({ receipt: cleanupReceipt, invoke }) + ).toBe(17); + for (const operation of ["start", "restart", "stop"]) { + expect( + await nativeProtectedFileCleanupDown({ + receipt: { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, operation }, + }, + invoke, + }) + ).toBe(17); + } + expect(calls).toEqual([ + ["down", "--json"], + ["down", "--recover", "--json"], + ["down", "--recover", "--json"], + ["down", "--recover", "--json"], + ]); +}); +test("retained cleanup refuses malformed or unknown selection before any invocation", async () => { + let calls = 0; + const invoke = async () => { + calls++; + return 0; + }; + for (const value of [ + null, + {}, + { ...cleanupReceipt, pendingOperation: undefined }, + { ...cleanupReceipt, pendingOperation: true }, + { ...cleanupReceipt, pendingOperation: {} }, + { ...cleanupReceipt, checkout: {} }, + { + ...cleanupReceipt, + checkout: { ...cleanupReceipt.checkout, git: { dev: 1, ino: 0 } }, + }, + { ...cleanupReceipt, adoption_receipt_version: 14 }, + { + ...cleanupReceipt, + publication: { ...cleanupReceipt.publication, phase: "switching" }, + }, + { + ...cleanupReceipt, + publication: { ...cleanupReceipt.publication, native: null }, + }, + { ...cleanupReceipt, prepared: { ...prepared, id: "invalid" } }, + { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, operation: "unknown" }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + generation: { ...prepared, id: "f".repeat(32) }, + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader", "foreign"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader", "reader"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { + ...receipt.pendingOperation, + services: ["db", "reader"], + }, + }, + { + ...cleanupReceipt, + pendingOperation: { ...receipt.pendingOperation, extra: true }, + }, + { ...cleanupReceipt, extra: true }, + ]) { + await expect( + nativeProtectedFileCleanupDown({ receipt: value, invoke }) + ).rejects.toThrow("selection refused; values omitted"); + } + expect(calls).toBe(0); +}); +let directory: string; +beforeEach(async () => { + if (uncertain) { + throw new Error("Prior fixture lifetime is unknown; values omitted."); + } + lifetime = undefined; + expectedUnknown = false; + restorers = []; + directory = await realpath( + await mkdtemp(join(tmpdir(), "protected-command-")) + ); +}); +afterEach(async () => { + if (!lifetime?.canRestore()) { + uncertain = true; + if (expectedUnknown) { + // The last intentional-unknown case asserts retention; it never releases teardown. + return; + } + throw new Error( + "Fixture callback/child is unsettled; root and globals retained, values omitted." + ); + } + for (const restore of restorers) { + restore(); + } + await rm(directory, { recursive: true, force: true }); +}); +test("bounded command privately delivers stdin and captures a known nonzero unchanged", async () => { + const result = await runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + 'const text=await Bun.stdin.text();if(text!=="fixture-only")process.exit(99);console.log("stdin-verified");process.exit(17)', + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + stdin: Buffer.from("fixture-only"), + timeoutMs: 1000, + capturePrefix: join(directory, "result"), + }); + expect(result.exitCode).toBe(17); + expect(result.stdout.trim()).toBe("stdin-verified"); + expect(await readFile(join(directory, "result.json"), "utf8")).not.toContain( + "fixture-only" + ); +}); +test("caller mutation cannot retarget capture or change an admitted invocation", async () => { + const foreign = join(directory, "foreign"); + await mkdir(foreign, { mode: 0o700 }); + const wrong = join(directory, "wrong-child"), + input = Buffer.from("captured-input"), + signal = new AbortController(); + const script = `const bytes=await Bun.stdin.text();if(bytes!=="captured-input"||process.env.SELECTED!=="original"||process.cwd()!==${JSON.stringify(directory)})process.exit(99);await Bun.sleep(150);console.log("captured-selection");process.exit(17)`; + const opts = { + argv: [process.execPath, "-e", script], + cwd: directory, + env: { PATH: process.env.PATH ?? "", SELECTED: "original" }, + timeoutMs: 1000, + signal: signal.signal, + stdin: input, + capturePrefix: join(directory, "captured"), + }; + const pending = runNativeFileFixtureCommand(opts); + opts.argv.splice( + 0, + opts.argv.length, + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(wrong)},"wrong")` + ); + opts.env.SELECTED = "changed"; + opts.cwd = foreign; + opts.timeoutMs = 1; + input.fill(120); + opts.capturePrefix = join(foreign, "redirected"); + const other = new AbortController(); + other.abort(); + opts.signal = other.signal; + const result = await pending; + expect(result.exitCode).toBe(17); + expect(result.stdout.trim()).toBe("captured-selection"); + expect(await Bun.file(join(directory, "captured.json")).exists()).toBe(true); + expect(await Bun.file(join(foreign, "redirected.stdout")).exists()).toBe( + false + ); + expect(await Bun.file(wrong).exists()).toBe(false); +}); +test("external cancellation settles a live leader within the existing owner and cannot qualify a late success", async () => { + const pidPath = join(directory, "cancelled-leader"), + controller = new AbortController(); + const prefix = join(directory, "cancelled"); + const pending = runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));await Bun.sleep(5000)`, + ], + cwd: directory, + env: {}, + timeoutMs: 1000, + signal: controller.signal, + capturePrefix: prefix, + }); + try { + const deadline = Date.now() + 500; + while (!(await Bun.file(pidPath).exists())) { + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(5); + } + controller.abort(); + await expect(pending).rejects.toThrow(); + const pid = Number(await readFile(pidPath, "utf8")); + try { + process.kill(pid, 0); + throw new Error("captured leader remains"); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + } + const receipt = JSON.parse( + await readFile(`${prefix}.settlement.json`, "utf8") + ); + expect(receipt.interrupted).toBe(true); + expect(receipt.capturedGroupAbsent).toBe(true); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); + } finally { + controller.abort(); + await pending.catch(() => undefined); + } +}); +test("capture overflow cancels and reaps an owned sleeping leader", async () => { + const pidPath = join(directory, "leader"); + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));try{await Bun.write(Bun.stdout,'x'.repeat(300000))}catch{};await Bun.sleep(5000)`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + }) + ).rejects.toThrow(); + const pid = Number(await readFile(pidPath, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(); + try { + process.kill(pid, 0); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + } +}); +test("capture publication conflict refuses before spawn without any group signal", async () => { + const prefix = join(directory, "publication"); + await writeFile(`${prefix}.stdout`, "keep-existing", { + flag: "wx", + mode: 0o600, + }); + const original = process.kill, + groups: number[] = []; + const signal = spyOn(process, "kill").mockImplementation((pid, kind) => { + if (pid < 0 && kind !== 0) { + groups.push(pid); + } + return original.call(process, pid, kind); + }); + restorers.push(() => signal.mockRestore()); + await expect( + runNativeFileFixtureCommand({ + argv: [process.execPath, "-e", 'console.log("complete")'], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + capturePrefix: prefix, + }) + ).rejects.toThrow(); + expect(groups).toEqual([]); + expect(await readFile(`${prefix}.stdout`, "utf8")).toBe("keep-existing"); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); +}); +test("pre-cancelled command refuses before spawning or publishing", async () => { + const marker = join(directory, "never"), + controller = new AbortController(); + controller.abort(); + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(marker)},"unexpected")`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + signal: controller.signal, + }) + ).rejects.toThrow(); + expect(await Bun.file(marker).exists()).toBe(false); +}); + +const systemGit = { + role: "git" as const, + platform: "darwin", + selected: "/usr/bin/git", + physical: "/usr/bin/git", + regular: true, + symlink: false, + uid: 0, + mode: 0o755, + nlink: 78, +}; +test("canonical root-owned Darwin Git admits positive shared links while private and alternate tools stay single-link", () => { + expect(nativeProtectedFileToolAllowed(systemGit)).toBe(true); + expect( + nativeProtectedFileToolAllowed({ ...systemGit, role: "artifact", nlink: 1 }) + ).toBe(true); + for (const changed of [ + { role: "artifact" as const }, + { platform: "linux" }, + { selected: "/tmp/git" }, + { physical: "/tmp/git" }, + { uid: 123 }, + { mode: 0o775 }, + { mode: 0o777 }, + { mode: 0o644 }, + { nlink: 0 }, + { nlink: -1 }, + { nlink: Number.NaN }, + { nlink: 1.5 }, + { regular: false }, + { symlink: true }, + ]) { + expect(nativeProtectedFileToolAllowed({ ...systemGit, ...changed })).toBe( + false + ); + } +}); +test.skipIf(process.platform !== "darwin")( + "canonical OS Git filesystem correspondence preserves the old single-link RED", + async () => { + const info = await lstat("/usr/bin/git"), + physical = await realpath("/usr/bin/git"); + expect( + nativeProtectedFileToolAllowed({ + role: "git", + platform: process.platform, + selected: "/usr/bin/git", + physical, + regular: info.isFile(), + symlink: info.isSymbolicLink(), + uid: info.uid, + mode: info.mode, + nlink: info.nlink, + }) + ).toBe(true); + expect(info.uid).toBe(0); + expect(info.mode & 0o022).toBe(0); + if (info.nlink > 1) { + expect(info.nlink === 1).toBe(false); + } + } +); + +// This intentional-unknown negative is last: its root and global spy remain retained until process exit. +test("closed-pipe survivor refuses completion and permanently vetoes restoration without a former-group signal", async () => { + const pidPath = join(directory, "descendant"), + child = join(directory, "child.ts"), + prefix = join(directory, "survivor"); + await writeFile( + child, + `await Bun.write(${JSON.stringify(pidPath)},String(process.pid));await Bun.sleep(1500);` + ); + const settlement = createCompletedJobFixtureSettlement(); + const original = process.kill.bind(process), + groups: number[] = []; + const signal = spyOn(process, "kill").mockImplementation((pid, kind) => { + if (pid < 0 && kind !== 0) { + groups.push(pid); + } + return original(pid, kind); + }); + restorers.push(() => signal.mockRestore()); + let descendant: number | undefined; + let descendantJoined = false; + const waitForDescendant = async () => { + descendant = Number(await readFile(pidPath, "utf8")); + expect(Number.isSafeInteger(descendant) && descendant > 0).toBe(true); + const deadline = Date.now() + 2500; + while (true) { + try { + original(descendant, 0); + } catch (error: unknown) { + expect((error as { code: string }).code).toBe("ESRCH"); + return; + } + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(10); + } + }; + try { + await expect( + runNativeFileFixtureCommand({ + argv: [ + process.execPath, + "-e", + `Bun.spawn([${JSON.stringify(process.execPath)},${JSON.stringify(child)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'});while(!await Bun.file(${JSON.stringify(pidPath)}).exists())await Bun.sleep(5);process.exit(0)`, + ], + cwd: directory, + env: { PATH: process.env.PATH ?? "" }, + timeoutMs: 1000, + capturePrefix: prefix, + onUnconfirmed: settlement.markUnconfirmed, + }) + ).rejects.toThrow(); + descendant = Number(await readFile(pidPath, "utf8")); + expect(original(descendant, 0)).toBe(true); + expect(await Bun.file(`${prefix}.json`).exists()).toBe(false); + expect(await Bun.file(`${prefix}.settlement.json`).exists()).toBe(false); + let restoration = 0; + expect(() => { + settlement.assertConfirmed(); + restoration += 1; + }).toThrow(); + expect(restoration).toBe(0); + expect(groups).toEqual([]); + await waitForDescendant(); + expect(() => settlement.assertConfirmed()).toThrow(); + expect(groups).toEqual([]); + } finally { + // Only an exact finite self-join can release this test's root/global teardown. + await lifetime!.track(waitForDescendant()).then( + () => { + descendantJoined = true; + }, + () => lifetime!.retain() + ); + } + expect(descendantJoined).toBe(true); + expect(lifetime!.canRestore()).toBe(false); + expectedUnknown = true; +}); diff --git a/tests/native-config-protected-mount-format.test.ts b/tests/native-config-protected-mount-format.test.ts new file mode 100644 index 000000000..7bf7aff27 --- /dev/null +++ b/tests/native-config-protected-mount-format.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test"; +import { isRecord } from "../src/lib/guards.ts"; +import { + nativeProtectedFileContainerInspectFormat, + nativeProtectedFileRemovalMatches, +} from "./e2e/scenarios/native-config-protected-files.ts"; +import { + DOCKER_FORMAT_CONTAINER_ID, + withDockerContainerFormatFixture, +} from "./helpers/docker-container-format.ts"; + +const bind = { + type: "bind", + name: "", + source: "/synthetic/config", + target: "/config", + rw: false, + }, + volume = { + type: "volume", + name: "synthetic_data", + source: "/var/lib/docker/volumes/synthetic_data/_data", + target: "/data", + rw: true, + }, + pin = { + id: DOCKER_FORMAT_CONTAINER_ID, + createdAt: "2026-01-01T00:00:00Z", + image: `sha256:${"b".repeat(64)}`, + labels: { "com.docker.compose.project": "synthetic" }, + command: ["synthetic"], + entrypoint: null, + running: true, + status: "running", + paused: false, + ports: null, + publishAll: false, + runtimePorts: {}, + mounts: [bind, volume], + networks: [], + }; + +test("saved cleanup keeps exact empty bind name and literal volume name", () => { + const stopped = { ...pin, running: false, status: "exited" }; + const { name: _volumeName, ...unnamedVolume } = volume; + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: stopped, + }) + ).toBe(true); + for (const mounts of [ + [{ ...bind, name: false }, volume], + [{ ...bind, name: null }, volume], + [{ ...bind, name: "foreign" }, volume], + [bind, unnamedVolume], + [bind, { ...volume, name: "" }], + [bind, { ...volume, name: "foreign" }], + [{ ...bind, rw: true }, volume], + [{ ...bind, source: "/foreign" }, volume], + ]) { + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin, + current: { ...stopped, mounts }, + }) + ).toBe(false); + } +}); +const binary = process.env.HACK_TEST_DOCKER_FORMAT_BINARY, + sha256 = process.env.HACK_TEST_DOCKER_FORMAT_SHA256; +test.skipIf(binary === undefined && sha256 === undefined)( + "maintained protected formatter accepts an omitted bind Name without changing strict saved mount identity", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + const container = { + Id: pin.id, + Name: "/synthetic-db-1", + Created: pin.createdAt, + Image: pin.image, + Config: { + Labels: pin.labels, + Cmd: pin.command, + Entrypoint: pin.entrypoint, + }, + State: { Running: true, Status: "running", Paused: false }, + HostConfig: { PortBindings: null, PublishAllPorts: false }, + NetworkSettings: { Ports: {}, Networks: {} }, + Mounts: [ + { + Type: bind.type, + Source: bind.source, + Destination: bind.target, + RW: bind.rw, + }, + { + Type: volume.type, + Name: volume.name, + Source: volume.source, + Destination: volume.target, + RW: volume.rw, + }, + ], + }; + await withDockerContainerFormatFixture({ + binary, + sha256, + container, + observe: async (probe) => { + const value: unknown = JSON.parse( + await probe(nativeProtectedFileContainerInspectFormat) + ); + if (!isRecord(value)) { + throw new Error("Synthetic protected projection is malformed"); + } + expect(value.mounts).toEqual(pin.mounts); + expect( + nativeProtectedFileRemovalMatches({ + kind: "container", + pin: value, + current: { ...value, running: false, status: "exited" }, + }) + ).toBe(true); + }, + }); + }, + 20_000 +); diff --git a/tests/native-config-protected-source.test.ts b/tests/native-config-protected-source.test.ts new file mode 100644 index 000000000..6615e3c6c --- /dev/null +++ b/tests/native-config-protected-source.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { legacyComposeRetainedFileGrants } from "../src/lib/native-compose-adoption-files.ts"; +import { planLegacyComposeRetainedFileAdoption } from "../src/lib/native-compose-adoption-plan.ts"; +import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; +import { mapLegacyNativeRetainedFileStorage } from "../src/lib/native-config-import-plan.ts"; +import { + nativeProtectedFileModeRefusalInputs, + nativeProtectedFileRetainedInputs, +} from "./e2e/scenarios/native-config-protected-files.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const image = `sha256:${"a".repeat(64)}`; +function source(inputs: { + readonly config: unknown; + readonly compose: unknown; +}) { + return { + configText: JSON.stringify(inputs.config), + composeText: JSON.stringify(inputs.compose), + }; +} +const retained = () => + nativeProtectedFileRetainedInputs({ + instance: { + name: "fixture", + grants: [ + { target: "/settings", mode: "0444", bytes: [1] }, + { target: "/run/secrets/owner", mode: "0400", bytes: [2] }, + { target: "/run/secrets/private", mode: "0600", bytes: [3] }, + ], + }, + bun: image, + db: image, + }); +test("maintained retained inputs are admitted by the canonical closed file8 mapper", () => { + const inputs = retained(); + expect(Object.keys(inputs.config)).toEqual(["name"]); + for (const service of Object.values(inputs.compose.services)) { + expect(Object.hasOwn(service, "pull_policy")).toBe(false); + } + expect( + planLegacyComposeRetainedFileAdoption(source(inputs)).intent + ).toBeDefined(); + const mapped = mapLegacyNativeRetainedFileStorage(source(inputs)); + expect(mapped.report.complete).toBe(true); + expect( + legacyComposeRetainedFileGrants(mapped.candidate).map((row) => [ + row.service, + row.name, + row.target, + ]) + ).toEqual([ + ["reader", "owner", "/run/secrets/owner"], + ["reader", "private", "/run/secrets/private"], + ["reader", "settings", "/settings"], + ]); +}); +test("historical and canonical worktree and authored pull policy remain refused by retained file8", () => { + const inputs = retained(); + const raw = { ...inputs.config, worktree: { inherit: false } }; + const planned = planLegacyComposeRetainedFileAdoption( + source({ ...inputs, config: raw }) + ); + expect(planned.intent).toBeUndefined(); + expect(planned.report.fields).toContainEqual( + expect.objectContaining({ + document: "config", + pointer: "/worktree/inherit", + status: "refused", + }) + ); + const canonical = mapLegacyNativeRetainedFileStorage( + source({ + ...inputs, + config: { ...inputs.config, worktree: { inherit_local: false } }, + }) + ); + expect(canonical.candidate).toBeDefined(); + expect(() => legacyComposeRetainedFileGrants(canonical.candidate)).toThrow(); + const withPull = structuredClone(inputs); + Object.assign(withPull.compose.services.reader, { pull_policy: "never" }); + const mapped = mapLegacyNativeRetainedFileStorage(source(withPull)); + expect(mapped.candidate).toBeDefined(); + expect(() => legacyComposeRetainedFileGrants(mapped.candidate)).toThrow(); +}); + +let root: string; +let savedEnv: Map; +const keys = [ + "PATH", + "HOME", + "HACK_HOME", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_API_VERSION", +]; +beforeEach(async () => { + savedEnv = new Map(keys.map((key) => [key, process.env[key]])); + root = await realpath(await mkdtemp(join(tmpdir(), "protected-source-"))); + await chmod(root, 0o700); +}); +afterEach(async () => { + for (const [key, value] of savedEnv) { + restoreEnv(key, value); + } + await rm(root, { recursive: true, force: true }); +}); +test("same legal0400 source reaches only the rejecting shim after the mismatched-mode refusal", async () => { + const checkout = join(root, "checkout"), + marker = join(root, "invoked"); + await mkdir(join(checkout, ".hack"), { recursive: true }); + await mkdir(join(checkout, ".git")); + await mkdir(join(checkout, "material")); + const material = join(checkout, "material/private"); + await writeFile(material, "synthetic-owned-material", { + flag: "wx", + mode: 0o400, + }); + const before = await lstat(material); + expect(before.mode & 0o777).toBe(0o400); + const inputs = nativeProtectedFileModeRefusalInputs({ + name: "fixture", + bun: image, + db: image, + }); + await writeFile( + join(checkout, ".hack/hack.config.json"), + JSON.stringify(inputs.config) + ); + const composePath = join(checkout, ".hack/docker-compose.yml"); + await writeFile(composePath, JSON.stringify(inputs.compose)); + await writeFile( + join(root, "docker"), + `#!${process.execPath}\nimport {open} from 'node:fs/promises';const f=await open(${JSON.stringify(marker)},'wx',0o600);try{await f.writeFile(JSON.stringify(process.argv.slice(2)));await f.sync()}finally{await f.close()}process.exit(98);\n`, + { flag: "wx", mode: 0o700 } + ); + for (const key of keys) { + restoreEnv(key, undefined); + } + process.env.PATH = `${root}:/usr/bin:/bin`; + process.env.HOME = root; + process.env.HACK_HOME = join(root, "hack-home"); + process.env.DOCKER_HOST = "unix:///synthetic-never-forwarded.sock"; + const denied = await previewLegacyComposeAdoption({ + projectRoot: checkout, + stop: true, + }); + expect(denied.complete).toBe(false); + await expect(lstat(marker)).rejects.toMatchObject({ code: "ENOENT" }); + inputs.compose.services.reader.secrets[0]!.mode = "0400"; + await writeFile(composePath, JSON.stringify(inputs.compose)); + const legal = await previewLegacyComposeAdoption({ + projectRoot: checkout, + stop: true, + }); + expect(legal.complete).toBe(false); + expect(JSON.parse(await readFile(marker, "utf8"))).toEqual([ + "info", + "--format", + '{"id":{{json .ID}},"os":{{json .OSType}}}', + ]); + const after = await lstat(material); + expect([after.dev, after.ino, after.mode, after.uid, after.gid]).toEqual([ + before.dev, + before.ino, + before.mode, + before.uid, + before.gid, + ]); + await expect( + lstat(join(checkout, ".hack/.internal/legacy-compose-adoption-v1")) + ).rejects.toMatchObject({ code: "ENOENT" }); +}, 15_000); diff --git a/tests/native-process-policy-initial-trace.test.ts b/tests/native-process-policy-initial-trace.test.ts index f0c9b7126..a344d140e 100644 --- a/tests/native-process-policy-initial-trace.test.ts +++ b/tests/native-process-policy-initial-trace.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { chmod, mkdtemp, readdir, rm, symlink, unlink } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + unlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -7,7 +16,10 @@ import { type NativeComposeOwnershipOptions, } from "../src/lib/native-compose-ownership.ts"; import { exec } from "../src/lib/shell.ts"; -import { replayProcessPolicyInitialOwnership } from "./e2e/native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + replayProcessPolicyInitialOwnership, +} from "./e2e/native-process-policy-initial-replay.ts"; import { type ProcessPolicyInitialTraceQuery, prepareProcessPolicyInitialTrace, @@ -392,6 +404,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc outcome: "unready", code: null, reason: null, + topologyPredicate: null, consumed: calls.length, protocolMatched: true, }); @@ -404,7 +417,8 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(strict).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "live-network-membership", + reason: "topology", + topologyPredicate: "live-endpoint-membership", consumed: 6, protocolMatched: true, }); @@ -430,7 +444,8 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(policy).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "network-policy", + reason: "bridge-policy", + topologyPredicate: null, consumed: 6, protocolMatched: true, }); @@ -453,13 +468,94 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(aliases).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "endpoint-aliases", + reason: "endpoint", + topologyPredicate: null, consumed: 6, protocolMatched: true, }); expect(JSON.stringify({ policy, aliases })).not.toContain(owner); expect(JSON.stringify({ policy, aliases })).not.toContain(id); expect(JSON.stringify({ policy, aliases })).not.toContain(import.meta.dir); + const summary: Parameters< + typeof persistProcessPolicyFirstAfterComposeRefusal + >[0]["summary"] = { + status: "captured", + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + queryCount: queries.length, + consumedQueries: queries.length, + complete: true, + observations: [ + { index: 0, phase: "before-compose", startup: strict, strict }, + { index: 1, phase: "after-compose", startup, strict }, + { index: 2, phase: "after-compose", startup: strict, strict }, + ], + }; + // Disabled and malformed opt-ins cannot create a capsule, even for a matching refusal. + for (const enabled of [undefined, "", "0", "true", "1\n"]) { + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled, + summary, + }) + ).toBeNull(); + expect( + await Bun.file( + join(root, "first-after-compose-replay-refusal.json") + ).exists() + ).toBe(false); + } + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }); + expect(capsule).toEqual({ + version: 1, + kind: "native-process-policy-after-compose-replay-refusal", + observationIndex: 1, + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + startupReason: null, + strictReason: "topology", + startupTopologyPredicate: null, + strictTopologyPredicate: "live-endpoint-membership", + }); + const capsulePath = join(root, "first-after-compose-replay-refusal.json"); + const capsuleBytes = await readFile(capsulePath, "utf8"); + expect(JSON.parse(capsuleBytes)).toEqual(capsule); + expect((await stat(capsulePath)).mode & 0o777).toBe(0o600); + expect(capsuleBytes).not.toContain(owner); + expect(capsuleBytes).not.toContain(id); + expect(capsuleBytes).not.toContain(CANARY); + await expect( + persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }) + ).rejects.toThrow(); + expect(await readFile(capsulePath, "utf8")).toBe(capsuleBytes); + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: join(root, "unmatched"), + enabled: "1", + summary: { + ...summary, + observations: [ + { + index: 1, + phase: "after-compose", + startup, + strict: { ...strict, protocolMatched: false }, + }, + ], + }, + }) + ).toBeNull(); const missing = await replayProcessPolicyInitialOwnership({ directory: join(root, "missing"), queries: queries.slice(0, 1), @@ -468,7 +564,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(missing.protocolMatched).toBe(false); expect(missing.outcome).toBe("refused"); - expect(missing.reason).toBe("unavailable"); + expect(missing.reason).toBeNull(); const first = queries[0]; if (!first) { throw new Error("Missing synthetic query"); @@ -481,7 +577,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(changed.protocolMatched).toBe(false); expect(changed.outcome).toBe("refused"); - expect(changed.reason).toBe("unavailable"); + expect(changed.reason).toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tests/native-process-policy-replay-reason.test.ts b/tests/native-process-policy-replay-reason.test.ts index 5c3530369..f50f03560 100644 --- a/tests/native-process-policy-replay-reason.test.ts +++ b/tests/native-process-policy-replay-reason.test.ts @@ -19,23 +19,13 @@ function stack(caller: string): string { return `${header}\n at refuse (${sourcePath}:200:9)\n at requireValue (${sourcePath}:204:5)\n at ${caller}\n at synthetic (private-canary:1:1)`; } -test("replay source pin verifies the actual canonical owner and refuses changed identity correspondence", () => { +test("historical stack classifier refuses the superseding owner source", () => { + // The replay now uses owner-issued reasons; the old line/hash classifier stays unavailable. const before = captureProcessPolicyOwnershipSource(); const after = captureProcessPolicyOwnershipSource(); - expect(before?.sha256).toBe(sourceSha256); - expect(before?.path).toBe(sourcePath); - expect(sameProcessPolicyOwnershipSource(before, after)).toBe(true); - if (!before) { - throw new Error("Missing canonical source pin"); - } - expect( - sameProcessPolicyOwnershipSource(before, { - ...before, - identity: "replaced", - }) - ).toBe(false); - expect(sameProcessPolicyOwnershipSource(before, null)).toBe(false); - expect(sameProcessPolicyOwnershipSource(null, after)).toBe(false); + expect(before).toBeNull(); + expect(after).toBeNull(); + expect(sameProcessPolicyOwnershipSource(before, after)).toBe(false); }); test("only fixed immediate owning callsites can name a replay reason", () => { diff --git a/tests/native-routing-fixture.test.ts b/tests/native-routing-fixture.test.ts index ced9c955b..d752d6143 100644 --- a/tests/native-routing-fixture.test.ts +++ b/tests/native-routing-fixture.test.ts @@ -1,5 +1,19 @@ import { expect, test } from "bun:test"; -import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-config-routing.ts"; +import { + chmod, + lstat, + mkdtemp, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { + proxyHasNoPublishedPorts, + runNativeRoutingFixtureTlsAttempt, +} from "./e2e/scenarios/native-routing-fixture-ingress.ts"; test("unpublished exposed and stopped ports are safe for the isolated proxy", () => { for (const runtimePorts of [null, {}, { "80/tcp": null, "443/tcp": null }]) { @@ -38,3 +52,83 @@ test("missing, malformed and explicit published port facts refuse", () => { expect(proxyHasNoPublishedPorts(facts)).toBe(false); } }); + +test("a failed exact TLS attempt retains bounded private stderr and its exit disposition", async () => { + const root = await mkdtemp(join(tmpdir(), "routing-tls-capture-")); + let settled = false; + try { + const executable = join(root, "docker"); + const id = "a".repeat(64); + const expected = [ + "exec", + id, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + "/data/caddy/pki/authorities/local/root.crt", + "--resolve", + "retained-origin.test:443:127.0.0.1", + "--url", + "https://retained-origin.test/", + ]; + await writeFile( + executable, + `#!${process.execPath}\nif(JSON.stringify(process.argv.slice(2))!==${JSON.stringify(JSON.stringify(expected))})process.exit(97);console.error("fixed synthetic TLS alert");process.exit(35);\n`, + { flag: "wx", mode: 0o700 } + ); + await chmod(executable, 0o700); + const captures = join(root, "captures"); + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: id, + origin: "https://retained-origin.test", + cwd: root, + captures, + env: { PATH: "/usr/bin:/bin" }, + timeoutMs: 2000, + }); + settled = true; + expect(result.exitCode).toBe(35); + expect(result.timedOut).toBe(false); + expect(result.stderr).toBe("fixed synthetic TLS alert\n"); + const leaves = await readdir(captures); + expect(leaves.length).toBe(3); + for (const leaf of leaves) { + expect((await lstat(join(captures, leaf))).mode & 0o777).toBe(0o600); + } + const receipt = await readPrivate(join(captures, "attempt.json"), 1024); + expect(JSON.parse(receipt.text)).toEqual({ + exitCode: 35, + timedOut: false, + stdoutBytes: 0, + stderrBytes: 26, + }); + const stderr = leaves.find((leaf) => leaf.endsWith(".stderr")); + expect(stderr).toBeDefined(); + if (!stderr) { + throw new Error("Missing private TLS stderr capture"); + } + expect((await readPrivate(join(captures, stderr), 1024)).text).toBe( + "fixed synthetic TLS alert\n" + ); + } finally { + if (settled) { + await rm(root, { recursive: true, force: true }); + } + } +});