From 461da53ed41f6d5fe209a1099eb7f96f407c5778 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 08:29:28 -0400 Subject: [PATCH 01/23] fix(runtime): fence retained Compose volume incarnations --- docs/reference/native-compose-generation.md | 25 ++ docs/reference/native-compose-ownership.md | 9 +- src/lib/native-compose-command.ts | 79 +++- src/lib/native-compose-generation.ts | 93 +++- src/lib/native-compose-ownership.ts | 72 +++- src/lib/native-compose-retained-storage.ts | 127 ++++++ tests/helpers/native-compose-command.ts | 50 ++- tests/native-compose-fixture-volume.test.ts | 6 +- tests/native-compose-generation.test.ts | 398 ++++++++++++++++++ tests/native-compose-ownership.test.ts | 101 ++++- ...e-compose-retained-storage-command.test.ts | 183 ++++++++ tests/native-compose-retained-storage.test.ts | 86 ++++ 12 files changed, 1183 insertions(+), 46 deletions(-) create mode 100644 src/lib/native-compose-retained-storage.ts create mode 100644 tests/native-compose-retained-storage-command.test.ts create mode 100644 tests/native-compose-retained-storage.test.ts diff --git a/docs/reference/native-compose-generation.md b/docs/reference/native-compose-generation.md index a77a61e2..95dedff8 100644 --- a/docs/reference/native-compose-generation.md +++ b/docs/reference/native-compose-generation.md @@ -24,6 +24,31 @@ Container resources carry the reserved workload discriminator `service` or `job` for the adapter's saved readiness checks. The store does not infer readiness. A verified private `.gitignore` excludes generated state from normal Git staging. +The first verified persistent-volume observation upgrades the private instance +receipt to version `2`. It records each physical name, logical storage key and +Docker `CreatedAt` value under the existing mutation lock. These facts survive +`down`, new generations and removed mount declarations. Later observations may +add volumes, but cannot forget or replace recorded storage. Intent publication +and completion retain the freshly recorded facts; public command results omit +them. Missing storage or a changed birth refuses resume before Compose can +create an empty replacement. A genuinely cold instance may create its first +volumes. + +Version `1` receipts have no historical birth evidence. Their saved declared +volumes must exist before any new effect, and the first verified observation +records their current birth. This cannot prove that a same-name replacement did +not happen before that first observation. Older clients refuse version `2` +receipts; do not remove or edit private receipts to force a downgrade. The birth +fence trusts engine metadata and cannot protect against a hostile daemon or an +administrator recreating the same metadata. No volume deletion or automatic +data-loss repair is added. + +Verified readiness and one-off ownership observations also feed this storage +history. If a later readiness, route or child-completion check fails, the store +retains already verified birth facts without treating the operation as complete +or starting another probe of the uncertain child. Its pending effect remains +available only for explicit recovery. + `runEffect` verifies generation identity, invokes caller freshness and ownership checks, and synchronizes a pending intent before calling the effect. Checks repeat at the effect boundary. Only a caller-confirmed complete postcondition clears diff --git a/docs/reference/native-compose-ownership.md b/docs/reference/native-compose-ownership.md index 5178dd60..ab2eaf84 100644 --- a/docs/reference/native-compose-ownership.md +++ b/docs/reference/native-compose-ownership.md @@ -12,7 +12,8 @@ selected services, jobs, and dependencies. Expected volume names and logical storage keys, and the default network name, come from the saved immutable generated documents. The stable random owner token comes from the verified private instance receipt, and every resource must match its `io.hack.native-config.owner` label. A -reset receipt cannot adopt resources from an earlier owner token. An omitted persistent selection means no such resource is expected. +reset receipt cannot adopt resources from an earlier owner token. Recorded persistent +storage remains selected even if a later declaration removes its mount. Caller input is an internal execution contract; authored labels do not authorize ownership. @@ -23,14 +24,16 @@ by their project label. Every selected container must carry version `1`, the exa runtime identity, a selected generation ID, and a known Compose service label. Persistent resources must carry version `1`, the exact runtime identity, and the exact declared name; volumes must also match their logical storage label. Missing -resources are valid during fresh startup. A foreign same-name resource, a stale +resources are valid during genuinely cold startup. A retained volume must exist +and match its saved `CreatedAt` value in addition to name and ownership labels. +A foreign same-name resource, a stale generation, an unknown service, a malformed reply, or uncertain inspection refuses. Docker queries request only structured IDs, names, fixed ownership labels, and container state, exit code, health status, and one-off status. They never request `Config.Env`, image values, health logs, or complete inspect objects. Returned observations contain only validated workload IDs, names, generation IDs and states, -volume names/storage keys, and network IDs/names. Container names omit Docker's +volume names/storage keys/birth timestamps, and network IDs/names. Container names omit Docker's inspect-only leading slash. Callers can distinguish current and proposed generations and bind a one-off result to its exact owned name before applying readiness or cleanup decisions. Failures discard daemon output and diagnostics. diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 7b1c4925..6750b088 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -70,6 +70,11 @@ import { NativeComposeRenderError, renderNativeCompose, } from "./native-compose-renderer.ts"; +import { + mergeNativeComposeRetainedVolumes, + type NativeComposeRetainedVolume, + selectNativeComposeVolumePolicies, +} from "./native-compose-retained-storage.ts"; import { NativeComposeRouteClaimError } from "./native-compose-route-claims.ts"; import { type NativeComposeRoutingOwner, @@ -176,6 +181,23 @@ function assertRoutedRunAvailable( } } +function retainedStorageCapture() { + let storage: readonly NativeComposeRetainedVolume[] | null = null; + return { + captureStorage: () => storage ?? invalid(), + observeStorage(observed: NativeComposeOwnershipObservation) { + try { + storage = mergeNativeComposeRetainedVolumes({ + retained: storage ?? [], + observed: observed.volumes, + }); + } catch { + return invalid(); + } + }, + }; +} + function routedRunEffectOwnership(opts: { readonly selection: NativeComposeOwnershipOptions; readonly generation: NativeComposeGeneration; @@ -184,9 +206,12 @@ function routedRunEffectOwnership(opts: { readonly routing: NativeComposeSavedRunRouting; }) { let before: string | null = null; + const storage = retainedStorageCapture(); return { + ...storage, assertOwned: async () => { const observed = await assertNativeComposeOwned(opts.selection); + storage.observeStorage(observed); if ( !( ready(opts.document, observed, opts.generation) && @@ -232,9 +257,11 @@ function preparedEffectOwnership(opts: { readonly afterReadiness?: () => Promise; }) { let completed = false; + const storage = retainedStorageCapture(); return { + ...storage, assertOwned: async () => { - await assertNativeComposeOwned(opts.selection); + storage.observeStorage(await assertNativeComposeOwned(opts.selection)); if (!completed) { await opts.routing?.assertBeforeEffects(); } @@ -279,7 +306,14 @@ async function ownershipSelection(opts: { } } const services = new Set(); - const volumes = new Map(); + const legacyStorage = + state.retainedStorage === null && state.generation + ? new Set( + volumeSelections( + await opts.store.readGenerationDocument(state.generation) + ).map((volume) => volume.name) + ) + : new Set(); const topologies = documents .filter( ({ generation }) => @@ -335,21 +369,19 @@ async function ownershipSelection(opts: { for (const name of Object.keys(serviceMap(document))) { services.add(name); } - for (const selection of volumeSelections(document)) { - const existing = volumes.get(selection.name); - if (existing && existing.storage !== selection.storage) { - return invalid(); - } - volumes.set(selection.name, selection); - } } + const expectedVolumes = selectNativeComposeVolumePolicies({ + declared: documents.flatMap(({ document }) => volumeSelections(document)), + retained: state.retainedStorage ?? [], + legacyNames: legacyStorage, + }); return { composeProject: opts.store.identity.composeProject, runtimeIdentity: opts.store.identity.composeProject, ownerToken: opts.store.identity.ownerToken, generationIds: [...new Set(generations.map((value) => value.generationId))], expectedServices: [...services], - expectedVolumes: [...volumes.values()], + expectedVolumes, expectedNetworks, expectedWorkloadNetworks, ...(opts.operation === "down" && opts.recover @@ -395,6 +427,9 @@ async function waitReady(opts: { readonly ownership: NativeComposeOwnershipOptions; readonly deadline: number; readonly generation: NativeComposeGeneration; + readonly observeStorage: ( + observed: NativeComposeOwnershipObservation + ) => void; }): Promise { const services = nativeComposeOnFailureServices(opts.document); while (Date.now() < opts.deadline) { @@ -402,8 +437,11 @@ async function waitReady(opts: { opts.ownership, services ); - if (state && ready(opts.document, state, opts.generation)) { - return state; + if (state) { + opts.observeStorage(state); + if (ready(opts.document, state, opts.generation)) { + return state; + } } if (opts.ownership.signal?.aborted) { return null; @@ -585,6 +623,7 @@ async function savedCommand(opts: { operation: "down", recoverPending: options.recover === true && pending !== null, assertOwned, + captureStorage: prepared.captureStorage, assertFresh: hooks?.assertFresh, downHooks: hooks?.downHooks, beforeComplete: async () => { @@ -724,8 +763,10 @@ async function prepareSavedStop(opts: { const current = await store.loadCurrent(); const hasDownHooks = Object.hasOwn(document, "x-hack-native-down-hooks"); let engineStopped = false; + let storage: readonly NativeComposeRetainedVolume[] | null = null; const assertOwned = async () => { const observed = await assertNativeComposeOwned(selection); + storage = observed.volumes; if ( engineStopped && (observed.containers.length !== 0 || observed.networks.length !== 0) @@ -783,6 +824,7 @@ async function prepareSavedStop(opts: { } return { assertOwned, + captureStorage: () => storage ?? invalid(), assertAbsent, hooks, hasDownHooks, @@ -856,6 +898,9 @@ async function runOneOff(opts: { readonly selection: NativeComposeOwnershipOptions; readonly base: RuntimeBaseOptions; readonly projection?: NativeComposeRunProjection; + readonly observeStorage: ( + observed: NativeComposeOwnershipObservation + ) => void; }) { const { options, generation, document, selection, base, projection } = opts; const service = requireService(document, options.service); @@ -880,6 +925,7 @@ async function runOneOff(opts: { } ); const observed = await assertNativeComposeOwned(selection); + opts.observeStorage(observed); const completed = nativeComposeCompletedOneoff({ observed, name, @@ -906,6 +952,7 @@ async function runOneOff(opts: { stdout: "stderr", }); const after = await assertNativeComposeOwned(selection); + opts.observeStorage(after); return { value: code, outcome: @@ -926,6 +973,9 @@ async function startNativeComposeWorkloads(opts: { readonly routing: NativeComposeRoutingOwner | null; readonly builds: readonly NativeComposeBuildIntent[]; readonly deadline: number; + readonly observeStorage: ( + observed: NativeComposeOwnershipObservation + ) => void; }) { const { options, generation, document, selection, base, routing, deadline } = opts; @@ -958,6 +1008,7 @@ async function startNativeComposeWorkloads(opts: { ownership: selection, deadline, generation, + observeStorage: opts.observeStorage, }) : null; if (observed) { @@ -982,6 +1033,9 @@ async function executePreparedNativeWorkloads(opts: { readonly signal: AbortSignal; readonly assertFresh: () => Promise; readonly assertOwned: () => Promise; + readonly observeStorage: ( + observed: NativeComposeOwnershipObservation + ) => void; }) { const deadline = opts.options.operation === "run" @@ -1237,6 +1291,7 @@ async function executePreparedGeneration(opts: { signal, assertFresh, assertOwned: ownership.assertOwned, + observeStorage: ownership.observeStorage, }), }); if (result.outcome === "uncertain") { diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 9f672759..d223a397 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -21,6 +21,11 @@ import { token, writeExclusive, } from "./native-compose-private-state.ts"; +import { + mergeNativeComposeRetainedVolumes, + type NativeComposeRetainedVolume, + nativeComposeRetainedVolumesValid, +} from "./native-compose-retained-storage.ts"; import { projectNativeComposeOneOff } from "./native-compose-run-projection.ts"; // biome-ignore lint/performance/noBarrelFile: Preserve the generation store's public topology contract after the single-parser extraction. @@ -280,7 +285,7 @@ type HostHookIntent = readonly operation: "down"; }; type Receipt = { - readonly version: 1; + readonly version: 1 | 2; readonly identity: NativeComposeIdentity; readonly checkout: CheckoutAnchor; readonly current: GenerationAnchor | null; @@ -288,6 +293,8 @@ type Receipt = { readonly pending: PendingReceipt | null; /** No command, PID, environment, or content fingerprint. Interrupted finite hooks never replay. */ readonly beforeHooks: HostHookIntent | null; + /** Version two records monotonic engine birth facts; old readers refuse this receipt. */ + readonly storage?: readonly NativeComposeRetainedVolume[]; }; type FileIdentity = { readonly dev: number; readonly ino: number }; type CheckoutAnchor = FileIdentity & { @@ -450,12 +457,18 @@ function parseReceipt( if ( !( isRecord(value) && - (keys(value, "checkout,current,identity,pending,stopped,version") || - keys( - value, - "beforeHooks,checkout,current,identity,pending,stopped,version" - )) && - value.version === 1 && + ((value.version === 1 && + (keys(value, "checkout,current,identity,pending,stopped,version") || + keys( + value, + "beforeHooks,checkout,current,identity,pending,stopped,version" + ))) || + (value.version === 2 && + keys( + value, + "beforeHooks,checkout,current,identity,pending,stopped,storage,version" + ) && + nativeComposeRetainedVolumesValid(value.storage))) && identityMatches(value.identity, identity) && checkoutMatches(value.checkout, checkout) && (value.current === null || @@ -469,14 +482,22 @@ function parseReceipt( ) { return refuse(); } + let storage: readonly NativeComposeRetainedVolume[] | undefined; + if (value.version === 2) { + if (!nativeComposeRetainedVolumesValid(value.storage)) { + return refuse(); + } + storage = value.storage; + } return { - version: 1, + version: value.version, identity, checkout, current: value.current, stopped: value.stopped, pending: value.pending, beforeHooks: normalizeHostIntent(value.beforeHooks), + ...(storage === undefined ? {} : { storage }), }; } function checkoutMatches(value: unknown, expected: CheckoutAnchor): boolean { @@ -646,6 +667,8 @@ export type NativeComposeEffectOptions = { readonly operation: NativeComposeOperation; readonly assertFresh?: () => Promise; readonly assertOwned: () => Promise; + /** Reuse the exact preceding ownership observation; never perform extra probes here. */ + readonly captureStorage?: () => readonly NativeComposeRetainedVolume[]; readonly recoverPending?: boolean; /** Store-derived immutable one-off delivery, verified before/after run effects. */ readonly projection?: NativeComposeRunProjection; @@ -848,6 +871,8 @@ export type NativeComposeGenerationStore = { readonly pending: NativeComposePending | null; readonly beforeHooksPending: boolean; readonly hostHookPhase: HostHookIntent["phase"] | null; + /** Nonenumerable private provenance; null is an older receipt with no birth history. */ + readonly retainedStorage: readonly NativeComposeRetainedVolume[] | null; }>; loadPending(): Promise; /** Private values: never serialize/log this object. Use within a saved-generation lease. */ @@ -1293,6 +1318,27 @@ export async function openNativeComposeGenerationStore(opts: { refuse(); } }; + const rememberStorage = async (input: NativeComposeEffectOptions) => { + if (!input.captureStorage) { + return; + } + const state = await receipt(); + let storage: readonly NativeComposeRetainedVolume[]; + try { + storage = mergeNativeComposeRetainedVolumes({ + retained: state.storage ?? [], + observed: input.captureStorage(), + }); + } catch { + return refuse(); + } + if ( + storage.length && + JSON.stringify(storage) !== JSON.stringify(state.storage) + ) { + await save({ ...state, version: 2, storage }); + } + }; const finalizeEffect = async ( input: NativeComposeEffectOptions, pending: Receipt["pending"], @@ -1306,6 +1352,7 @@ export async function openNativeComposeGenerationStore(opts: { await verifyProjection(input.projection, input.generation); } await input.assertOwned(); + await rememberStorage(input); let latest = await receipt(); requireFinalPending( latest, @@ -1323,6 +1370,7 @@ export async function openNativeComposeGenerationStore(opts: { await verifyProjection(input.projection, input.generation); } await input.assertOwned(); + await rememberStorage(input); latest = await receipt(); requireFinalPending( latest, @@ -1345,7 +1393,7 @@ export async function openNativeComposeGenerationStore(opts: { identity: ownedIdentity, async loadCurrent() { const state = await receipt(); - return { + const result = { generation: state.current === null ? null @@ -1354,7 +1402,15 @@ export async function openNativeComposeGenerationStore(opts: { pending: publicPending(state.pending), beforeHooksPending: state.beforeHooks !== null, hostHookPhase: state.beforeHooks?.phase ?? null, + retainedStorage: state.storage + ? mergeNativeComposeRetainedVolumes({ + retained: [], + observed: state.storage, + }) + : null, }; + Object.defineProperty(result, "retainedStorage", { enumerable: false }); + return result; }, async loadPending() { const state = await receipt(); @@ -1540,6 +1596,7 @@ export async function openNativeComposeGenerationStore(opts: { await fenceEffectInputs(input); try { await input.assertOwned(); + await rememberStorage(input); } catch (error) { if (error instanceof NativeComposeGenerationError) { throw error; @@ -1591,6 +1648,9 @@ export async function openNativeComposeGenerationStore(opts: { result: Awaited["effect"]>> ) => { if (result.outcome !== "complete") { + // Preserve an already verified observation without probing an + // uncertain child or clearing its pending effect intent. + await rememberStorage(input); return result; } const hooks = input.afterHooks ?? input.downHooks?.after; @@ -1890,9 +1950,13 @@ export async function openNativeComposeGenerationStore(opts: { const input = Object.freeze({ ...options, downHooks }); return await runAction(async () => { requireActive(); - const state = await receipt(); + let state = await receipt(); admitEffect(input, state, opts.mode); await checkEffect(input); + // Ownership can publish newly observed births. Never overwrite them + // with the pre-observation receipt when arming the effect intent. + state = await receipt(); + admitEffect(input, state, opts.mode); const anchor = knownAnchor(input.generation); const pending = effectPending(state, input, anchor); await save({ ...state, pending }); @@ -1906,9 +1970,16 @@ export async function openNativeComposeGenerationStore(opts: { const result = await input.effect(); return await finishEffect(input, pending, anchor, result); } catch { - throw new NativeComposeGenerationError( + const failure = new NativeComposeGenerationError( "E_NATIVE_COMPOSE_UNCERTAIN" ); + try { + await rememberStorage(input); + } catch { + // Storage publication failure cannot clear engine uncertainty. + throw failure; + } + throw failure; } finally { effectOperation = null; activePending = null; diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index 96ba717d..8bcf992a 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -1,5 +1,6 @@ import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; import { isRecord } from "./guards.ts"; +import { nativeComposeVolumeCreatedAt } from "./native-compose-retained-storage.ts"; import { findExecutableInPath } from "./shell.ts"; const PROJECT = /^[a-z0-9][a-z0-9_-]*$/; @@ -28,7 +29,7 @@ const FORMATS = { volume: { // Docker volumes have a name, rather than an immutable engine object ID. list: `{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (.Label "${PROJECT_LABEL}")}}}`, - inspect: `{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (index .Labels "${PROJECT_LABEL}")}},"version":{{json (index .Labels "${PREFIX}.version")}},"instance":{{json (index .Labels "${PREFIX}.instance")}},"owner":{{json (index .Labels "${PREFIX}.owner")}},"storage":{{json (index .Labels "${PREFIX}.storage")}}}`, + inspect: `{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (index .Labels "${PROJECT_LABEL}")}},"version":{{json (index .Labels "${PREFIX}.version")}},"instance":{{json (index .Labels "${PREFIX}.instance")}},"owner":{{json (index .Labels "${PREFIX}.owner")}},"storage":{{json (index .Labels "${PREFIX}.storage")}},"createdAt":{{json .CreatedAt}}}`, }, network: { list: `{"id":{{json .ID}},"name":{{json .Name}},"project":{{json (.Label "${PROJECT_LABEL}")}}}`, @@ -59,6 +60,7 @@ export type NativeComposeOwnershipObservation = { readonly volumes: readonly { readonly name: string; readonly storage: string; + readonly createdAt: string; }[]; readonly networks: readonly { readonly id: string; readonly name: string }[]; }; @@ -75,6 +77,9 @@ export type NativeComposeOwnershipOptions = { readonly expectedVolumes?: readonly { readonly name: string; readonly storage: string; + /** Retained observations must exist; a new cold declaration may be absent. */ + readonly mustExist?: boolean; + readonly createdAt?: string; }[]; /** Pre-topology version-one callers mean the owned outbound default bridge. */ readonly expectedNetwork?: string; @@ -220,6 +225,20 @@ function lines(text: string): Record[] { return value; }); } +function validateVolumes(opts: NativeComposeOwnershipOptions): void { + const names = new Set(); + for (const volume of opts.expectedVolumes ?? []) { + requireValue(NAME.test(volume.name) && SERVICE.test(volume.storage)); + requireValue( + (volume.mustExist === undefined || + typeof volume.mustExist === "boolean") && + (volume.createdAt === undefined || + nativeComposeVolumeCreatedAt(volume.createdAt)) + ); + requireValue(!names.has(volume.name)); + names.add(volume.name); + } +} function validateOptions(opts: NativeComposeOwnershipOptions): void { requireValue(PROJECT.test(opts.composeProject)); requireValue(PROJECT.test(opts.runtimeIdentity)); @@ -229,12 +248,7 @@ function validateOptions(opts: NativeComposeOwnershipOptions): void { requireValue( new Set(opts.expectedServices).size === opts.expectedServices.length ); - const names = new Set(); - for (const volume of opts.expectedVolumes ?? []) { - requireValue(NAME.test(volume.name) && SERVICE.test(volume.storage)); - requireValue(!names.has(volume.name)); - names.add(volume.name); - } + validateVolumes(opts); requireValue( opts.expectedNetwork === undefined || NAME.test(opts.expectedNetwork) ); @@ -432,8 +446,21 @@ async function queryNativeComposeOwned( mode, restartingServices ); - // Attachments can change without changing container or network object IDs. - // Recheck policy IDs, aliases and membership; this is not an IP/endpoint-incarnation fence. + for (const volume of opts.expectedVolumes ?? []) { + if (volume.mustExist || volume.createdAt !== undefined) { + requireValue( + observations.volumes.some( + (current) => + current.name === volume.name && + current.storage === volume.storage && + (volume.createdAt === undefined || + current.createdAt === volume.createdAt) + ) + ); + } + } + // Attachments and volume incarnations can change without changing inventory names. + // Recheck policy IDs, aliases, membership and volume birth; Docker is not atomically locked. const rechecked: MutableObservation = { containers: [], volumes: [], @@ -441,7 +468,7 @@ async function queryNativeComposeOwned( endpoints: new Map(), members: new Map(), }; - for (const kind of ["container", "network"] as const) { + for (const kind of ["container", "volume", "network"] as const) { await collectInspections({ kind, resources: selected.get(kind) ?? [], @@ -473,6 +500,10 @@ async function queryNativeComposeOwned( JSON.stringify(topologySnapshot(observations, restarting)) === JSON.stringify(topologySnapshot(rechecked, restarting)) ); + requireValue( + JSON.stringify(volumeSnapshot(observations)) === + JSON.stringify(volumeSnapshot(rechecked)) + ); for (const kind of ["container", "volume", "network"] as const) { requireValue( JSON.stringify(await inventory(kind)) === @@ -482,7 +513,7 @@ async function queryNativeComposeOwned( return { observation: { containers: rechecked.containers, - volumes: observations.volumes, + volumes: rechecked.volumes, networks: rechecked.networks, }, restarting: restarting.size > 0, @@ -497,11 +528,16 @@ async function queryNativeComposeOwned( type MutableObservation = { containers: NativeComposeContainerObservation[]; - volumes: { name: string; storage: string }[]; + volumes: { name: string; storage: string; createdAt: string }[]; networks: { id: string; name: string }[]; endpoints: Map>; members: Map; }; +function volumeSnapshot(observations: MutableObservation) { + return [...observations.volumes].sort((left, right) => + left.name.localeCompare(right.name) + ); +} async function collectInspections(input: { readonly kind: Kind; readonly resources: readonly Inventory[]; @@ -553,6 +589,7 @@ async function collectInspections(input: { "instance", "owner", "storage", + "createdAt", ]) ); requireValue( @@ -564,7 +601,16 @@ async function collectInspections(input: { requireValue( expectedVolume !== undefined && expectedVolume.storage === row.storage ); - volumes.push({ name: resource.name, storage: row.storage }); + requireValue(nativeComposeVolumeCreatedAt(row.createdAt)); + requireValue( + expectedVolume.createdAt === undefined || + expectedVolume.createdAt === row.createdAt + ); + volumes.push({ + name: resource.name, + storage: row.storage, + createdAt: row.createdAt, + }); } else { requireValue( hasKeys(row, [ diff --git a/src/lib/native-compose-retained-storage.ts b/src/lib/native-compose-retained-storage.ts new file mode 100644 index 00000000..0ff0e524 --- /dev/null +++ b/src/lib/native-compose-retained-storage.ts @@ -0,0 +1,127 @@ +import { isRecord } from "./guards.ts"; + +const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +const STORAGE = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const CREATED = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const LIMIT = 4096; + +/** Private engine provenance. Docker volume names alone do not identify a retained database. */ +export type NativeComposeRetainedVolume = { + readonly name: string; + readonly storage: string; + readonly createdAt: string; +}; + +export type NativeComposeVolumePolicy = { + readonly name: string; + readonly storage: string; + readonly mustExist?: boolean; + readonly createdAt?: string; +}; + +/** Keep historical storage fences even when a new declaration drops its mount. */ +export function selectNativeComposeVolumePolicies(opts: { + readonly declared: readonly { + readonly name: string; + readonly storage: string; + }[]; + readonly retained: readonly NativeComposeRetainedVolume[]; + readonly legacyNames: ReadonlySet; +}): readonly NativeComposeVolumePolicy[] { + if (!nativeComposeRetainedVolumesValid(opts.retained)) { + throw new Error("Invalid native retained storage facts; values omitted."); + } + const volumes = new Map( + opts.retained.map((volume) => [volume.name, { ...volume, mustExist: true }]) + ); + for (const volume of opts.declared) { + const existing = volumes.get(volume.name); + if (existing && existing.storage !== volume.storage) { + throw new Error("Native retained storage changed; values omitted."); + } + if (!existing) { + volumes.set(volume.name, { + name: volume.name, + storage: volume.storage, + ...(opts.legacyNames.has(volume.name) ? { mustExist: true } : {}), + }); + } + } + return [...volumes.values()]; +} + +export function nativeComposeVolumeCreatedAt(value: unknown): value is string { + return ( + typeof value === "string" && + CREATED.test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +/** Validate bounded, unique facts without retaining untrusted values in errors. */ +export function nativeComposeRetainedVolumesValid( + value: unknown +): value is readonly NativeComposeRetainedVolume[] { + if (!Array.isArray(value) || value.length > LIMIT) { + return false; + } + const names = new Set(); + for (const entry of value) { + if ( + !( + isRecord(entry) && + Object.keys(entry).sort().join() === "createdAt,name,storage" && + typeof entry.name === "string" && + entry.name.length <= 256 && + NAME.test(entry.name) && + typeof entry.storage === "string" && + STORAGE.test(entry.storage) && + nativeComposeVolumeCreatedAt(entry.createdAt) && + !names.has(entry.name) + ) + ) { + return false; + } + names.add(entry.name); + } + return true; +} + +/** + * First observations may add facts; existing facts are never forgotten or rebound. + * Missing or replaced volumes refuse before effects, including after a verified stop. + * A birth timestamp is an engine-provided fence, not cryptographic proof against a + * hostile daemon or an administrator able to recreate all volume metadata. + */ +export function mergeNativeComposeRetainedVolumes(opts: { + readonly retained: readonly NativeComposeRetainedVolume[]; + readonly observed: readonly NativeComposeRetainedVolume[]; +}): readonly NativeComposeRetainedVolume[] { + if ( + !( + nativeComposeRetainedVolumesValid(opts.retained) && + nativeComposeRetainedVolumesValid(opts.observed) + ) + ) { + throw new Error("Invalid native retained storage facts; values omitted."); + } + const observed = new Map( + opts.observed.map((volume) => [volume.name, volume]) + ); + for (const previous of opts.retained) { + const current = observed.get(previous.name); + if ( + !current || + current.storage !== previous.storage || + current.createdAt !== previous.createdAt + ) { + throw new Error("Native retained storage changed; values omitted."); + } + } + return Object.freeze( + [...observed.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((volume) => Object.freeze({ ...volume })) + ); +} diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts index aa94e76f..75f5870e 100644 --- a/tests/helpers/native-compose-command.ts +++ b/tests/helpers/native-compose-command.ts @@ -28,6 +28,12 @@ export async function fixture( readonly before?: string; readonly hooks?: unknown; readonly noHooks?: boolean; + readonly storage?: Readonly< + Record< + string, + { readonly kind: "persistent"; readonly scope: "worktree" } + > + >; } = {} ) { const root = await realpath( @@ -42,7 +48,21 @@ export async function fixture( name: "fixture", source: { root: ".", mode: "host-mounted" }, worktree: { inherit_local: false, auto_branch: false }, - services: { web: { image: "fixture/web:1" } }, + services: { + web: { + image: "fixture/web:1", + ...(opts.storage + ? { + mounts: Object.keys(opts.storage).map((storage) => ({ + storage, + target: `/storage/${storage}`, + access: "read-write", + })), + } + : {}), + }, + }, + ...(opts.storage ? { storage: opts.storage } : {}), ...(opts.noHooks ? {} : { @@ -101,7 +121,7 @@ else { const host=Object.fromEntries(["up","down"].map(phase=>[phase,{before:normalized(source.host?.[phase]?.before??[]),after:normalized(source.host?.[phase]?.after??[])}])); const profiles=process.argv.slice(3).filter((_,i,a)=>a[i-1]==="--profile").sort(); const allHooks=[...host.up.before,...host.up.after,...host.down.before,...host.down.after]; - const plan={...source,plan_version:1,selected_profiles:profiles,jobs:{},environment:{},storage:{},host};delete plan.schema_version;delete plan.profiles; + const plan={...source,plan_version:1,selected_profiles:profiles,jobs:{},environment:{},storage:source.storage??{},host};delete plan.schema_version;delete plan.profiles; const result={transport_version:1,ok:true,semantic_hash:createHash("sha256").update(text).digest("hex"),declared_workloads:{web:"service"},host_env_targets:{include_default:[...host.up.before,...host.up.after,...host.down.before,...host.down.after].length>0,workloads:[]},plan}; if(allHooks.length===0) {delete plan.host;delete result.host_env_targets;} if(operation!=="compile") result.local_resolution={overlay:null,origin:"project",auto_branch:false,inherit_local:false,resolution_hash:"b".repeat(64)}; @@ -122,15 +142,29 @@ import {appendFile,rm} from "node:fs/promises"; const root=${JSON.stringify(root)};const args=process.argv.slice(2); await appendFile(root+"/requests",JSON.stringify(args)+"\\n"); const engine=root+"/engine"; +const volumes=root+"/volumes"; if(args[0]==="compose") { if(args.includes("up")) { if(${failedStartup}) process.exit(19); - const doc=await Bun.file(args[args.indexOf("-f")+1]).json();await Bun.write(engine,JSON.stringify(doc));await appendFile(root+"/order","engine-ready\\n");process.exit(0); + const doc=await Bun.file(args[args.indexOf("-f")+1]).json(); + if(Object.keys(doc.volumes).length>0) { + const retained=await Bun.file(volumes).exists()?await Bun.file(volumes).json():[]; + for(const [storage,volume] of Object.entries(doc.volumes)) { + if(!retained.some(row=>row.name===volume.name)) { + retained.push({id:volume.name,name:volume.name,project:doc.name,version:volume.labels["io.hack.native-config.version"],instance:volume.labels["io.hack.native-config.instance"],owner:volume.labels["io.hack.native-config.owner"],storage,createdAt:new Date().toISOString()}); + } + } + await Bun.write(volumes,JSON.stringify(retained)); + } + await Bun.write(engine,JSON.stringify(doc));await appendFile(root+"/order","engine-ready\\n");process.exit(0); } if(args.includes("down")) {await rm(engine,{force:true});await appendFile(root+"/order","engine-stopped\\n");process.exit(0);} process.exit(99); } if(args[1]==="ls") { + if(args[0]==="volume" && await Bun.file(volumes).exists()) { + for(const row of await Bun.file(volumes).json())console.log(JSON.stringify({id:row.name,name:row.name,project:row.project})); + } if(args[0]==="container" && await Bun.file(engine).exists()) { const doc=await Bun.file(engine).json();console.log(JSON.stringify({id:"c".repeat(64),name:doc.name+"-web-1",project:doc.name})); } @@ -139,6 +173,16 @@ if(args[1]==="ls") { } process.exit(0); } +if(args[0]==="volume" && args[1]==="inspect" && await Bun.file(volumes).exists()) { + const selected=args.slice(args.indexOf("--format")+2),format=args[args.indexOf("--format")+1]; + const retained=await Bun.file(volumes).json(); + for(const name of selected) { + const volume=retained.find(row=>row.name===name);if(!volume)process.exit(1); + const {createdAt,...row}=volume; + console.log(JSON.stringify({...row,...(format.includes(".CreatedAt")?{createdAt}:{})})); + } + process.exit(0); +} if(args[0]==="container" && args[1]==="inspect" && await Bun.file(engine).exists()) { const doc=await Bun.file(engine).json();const labels=doc.services.web.labels; const transitional=await Bun.file(root+"/restart-transient").exists();const finalGap=await Bun.file(root+"/restart-finalization-gap").exists();const counter=root+"/restart-container-inspects";const count=(transitional||finalGap)&&await Bun.file(counter).exists()?Number(await Bun.file(counter).text()):0;if(transitional||finalGap)await Bun.write(counter,String(count+1)); diff --git a/tests/native-compose-fixture-volume.test.ts b/tests/native-compose-fixture-volume.test.ts index 7e0d87ce..e452a065 100644 --- a/tests/native-compose-fixture-volume.test.ts +++ b/tests/native-compose-fixture-volume.test.ts @@ -10,7 +10,11 @@ const selection: NativeComposeOwnershipOptions = { expectedServices: ["app"], expectedVolumes: [{ name: "fixture_data", storage: "data" }], }; -const volume = { name: "fixture_data", storage: "data" }; +const volume = { + name: "fixture_data", + storage: "data", + createdAt: "2026-10-08T00:00:00Z", +}; const inspected = { ...volume, createdAt: "2026-10-08T00:00:00Z", diff --git a/tests/native-compose-generation.test.ts b/tests/native-compose-generation.test.ts index 3ecb6157..1181e3e6 100644 --- a/tests/native-compose-generation.test.ts +++ b/tests/native-compose-generation.test.ts @@ -24,6 +24,7 @@ import { openNativeComposeGenerationStore, readNativeComposeNetworkTopology, } from "../src/lib/native-compose-generation.ts"; +import type { NativeComposeRetainedVolume } from "../src/lib/native-compose-retained-storage.ts"; import { type NativeComposeRouteClaims, openNativeComposeRouteClaims, @@ -143,6 +144,403 @@ async function rejected(effect: Promise, code: string) { await expect(effect).rejects.toMatchObject({ code }); } +const VOLUME_CREATED = "2026-10-08T00:00:00.123456789Z"; +const VOLUME_REBORN = "2026-10-08T00:00:01.123456789Z"; +function volumeFact(owner: NativeComposeGenerationStore) { + const project = owner.identity.composeProject; + return { + name: `hack-${project.length}-${project}-4-data`, + storage: "data", + createdAt: VOLUME_CREATED, + }; +} + +async function activateWithStorage(owner: NativeComposeGenerationStore) { + const volume = volumeFact(owner); + let observed: readonly NativeComposeRetainedVolume[] = []; + const generation = await owner.withMutation(async (mutation) => { + const published = await publish(mutation); + await mutation.runEffect({ + generation: published, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => observed, + effect: async () => { + observed = [volume]; + return { outcome: "complete", value: 0 }; + }, + }); + return published; + }); + return { generation, volume }; +} + +test("captured physical storage survives reopen and completed down without public serialization", async () => { + const root = await fixture(); + const owner = await store(root); + expect((await owner.loadCurrent()).retainedStorage).toBeNull(); + const { generation, volume } = await activateWithStorage(owner); + const current = await owner.loadCurrent(); + expect(current.retainedStorage).toEqual([volume]); + expect(Object.isFrozen(current.retainedStorage)).toBe(true); + expect(Object.isFrozen(current.retainedStorage?.[0])).toBe(true); + expect(JSON.stringify(current)).not.toContain("retainedStorage"); + expect(JSON.stringify(current)).not.toContain(VOLUME_CREATED); + await owner.close(); + const saved = await store(root, null, "saved"); + const reopened = await saved.loadCurrent(); + expect(reopened.retainedStorage).toEqual([volume]); + expect(reopened.generation?.generationId).toBe(generation.generationId); + if (!reopened.generation) { + throw new Error("Missing saved storage generation"); + } + await saved.withMutation((mutation) => + mutation.runEffect({ + generation: reopened.generation ?? generation, + operation: "down", + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => ({ outcome: "complete", value: 0 }), + }) + ); + const stopped = await saved.loadCurrent(); + expect(stopped).toMatchObject({ stopped: true, pending: null }); + expect(stopped.retainedStorage).toEqual([volume]); + expect(stopped.generation?.generationId).toBe(generation.generationId); +}); + +test("legacy v1 receipt captures existing storage durably before stop effects", async () => { + const root = await fixture(); + const owner = await store(root); + await activate(owner); + await owner.close(); + const saved = await store(root, null, "saved"); + const current = await saved.loadCurrent(); + expect(current.retainedStorage).toBeNull(); + const generation = current.generation; + if (!generation) { + throw new Error("Missing legacy generation"); + } + const volume = volumeFact(saved); + let effects = 0; + await saved.withMutation((mutation) => + mutation.runEffect({ + generation, + operation: "down", + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => { + effects += 1; + expect((await saved.loadCurrent()).retainedStorage).toEqual([volume]); + return { outcome: "complete", value: 0 }; + }, + }) + ); + expect(effects).toBe(1); + expect((await saved.loadCurrent()).retainedStorage).toEqual([volume]); + expect(await Bun.file(receiptPath(saved)).json()).toMatchObject({ + version: 2, + storage: [volume], + }); +}); + +test.each([ + "missing", + "reborn", +] as const)("captured retained storage refuses %s facts before a new effect or intent", async (change) => { + const owner = await store(await fixture()); + const { generation, volume } = await activateWithStorage(owner); + const before = await Bun.file(receiptPath(owner)).text(); + let effects = 0; + await owner.withMutation(async (mutation) => { + const proposed = await publish(mutation); + await rejected( + mutation.runEffect({ + generation: proposed, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => + change === "missing" ? [] : [{ ...volume, createdAt: VOLUME_REBORN }], + effect: async () => { + effects += 1; + return { outcome: "complete", value: 0 }; + }, + }), + "E_NATIVE_COMPOSE_STATE" + ); + }); + expect(effects).toBe(0); + expect(await Bun.file(receiptPath(owner)).text()).toBe(before); + const current = await owner.loadCurrent(); + expect(current.pending).toBeNull(); + expect(current.generation?.generationId).toBe(generation.generationId); + expect(current.retainedStorage).toEqual([volume]); +}); + +test("capture before uncertain startup remains durable through source-free saved recovery", async () => { + const root = await fixture(); + const owner = await store(root); + const volume = volumeFact(owner); + let generationId = ""; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + generationId = generation.generationId; + expect( + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => { + expect((await owner.loadCurrent()).retainedStorage).toEqual([volume]); + return { outcome: "uncertain", value: 17 }; + }, + }) + ).toEqual({ outcome: "uncertain", value: 17 }); + }); + await owner.close(); + await writeFile(join(root, ".hack", "hack.project.json"), "invalid"); + const saved = await store(root, null, "saved"); + const pending = await saved.loadPending(); + if (!pending) { + throw new Error("Missing storage recovery generation"); + } + expect(pending.generationId).toBe(generationId); + expect((await saved.loadCurrent()).retainedStorage).toEqual([volume]); + await saved.withMutation((mutation) => + mutation.runEffect({ + generation: pending, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => ({ outcome: "complete", value: 0 }), + }) + ); + const recovered = await saved.loadCurrent(); + expect(recovered).toMatchObject({ stopped: true, pending: null }); + expect(recovered.retainedStorage).toEqual([volume]); +}); + +test("explicit pending recovery captures first observed storage before teardown", async () => { + const root = await fixture(); + const owner = await store(root); + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => ({ outcome: "uncertain", value: 1 }), + }); + }); + const saved = await store(root, null, "saved"); + expect((await saved.loadCurrent()).retainedStorage).toBeNull(); + const pending = await saved.loadPending(); + if (!pending) { + throw new Error("Missing cold partial generation"); + } + const volume = volumeFact(saved); + await saved.withMutation((mutation) => + mutation.runEffect({ + generation: pending, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => { + expect((await saved.loadCurrent()).retainedStorage).toEqual([volume]); + return { outcome: "complete", value: 0 }; + }, + }) + ); + expect((await saved.loadCurrent()).retainedStorage).toEqual([volume]); +}); + +test("an uncertain effect retains a newly verified post-effect volume birth", async () => { + const owner = await store(await fixture()); + const volume = volumeFact(owner); + let engineFacts: readonly NativeComposeRetainedVolume[] = []; + let observed: readonly NativeComposeRetainedVolume[] = []; + const assertOwned = async () => { + observed = engineFacts; + }; + let generationId = ""; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + generationId = generation.generationId; + expect( + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned, + captureStorage: () => observed, + effect: async () => { + engineFacts = [volume]; + await assertOwned(); + return { outcome: "uncertain", value: 17 }; + }, + }) + ).toEqual({ outcome: "uncertain", value: 17 }); + }); + const current = await owner.loadCurrent(); + expect(current.retainedStorage).toEqual([volume]); + expect(current.generation).toBeNull(); + expect(current.pending?.generationId).toBe(generationId); +}); + +test("a thrown effect retains its latest verified volume birth without reporting ready", async () => { + const owner = await store(await fixture()); + const volume = volumeFact(owner); + let engineFacts: readonly NativeComposeRetainedVolume[] = []; + let observed: readonly NativeComposeRetainedVolume[] = []; + const assertOwned = async () => { + observed = engineFacts; + }; + let generationId = ""; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + generationId = generation.generationId; + await rejected( + mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned, + captureStorage: () => observed, + effect: async () => { + engineFacts = [volume]; + await assertOwned(); + throw new Error("synthetic-private-effect-canary"); + }, + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + const current = await owner.loadCurrent(); + expect(current.retainedStorage).toEqual([volume]); + expect(current.generation).toBeNull(); + expect(current.pending?.generationId).toBe(generationId); + expect(await Bun.file(receiptPath(owner)).text()).not.toContain( + "synthetic-private-effect-canary" + ); +}); + +test("a finalizer's rebirth observation cannot overwrite captured storage or complete readiness", async () => { + const owner = await store(await fixture()); + const volume = volumeFact(owner); + let observed: readonly NativeComposeRetainedVolume[] = []; + let generationId = ""; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + generationId = generation.generationId; + await rejected( + mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => observed, + effect: async () => { + observed = [volume]; + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + expect((await owner.loadCurrent()).retainedStorage).toEqual([volume]); + observed = [{ ...volume, createdAt: VOLUME_REBORN }]; + }, + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + const current = await owner.loadCurrent(); + expect(current.generation).toBeNull(); + expect(current.pending?.generationId).toBe(generationId); + expect(current.retainedStorage).toEqual([volume]); +}); + +test("identical-content receipt replacement during capture refuses before engine entry", async () => { + const owner = await store(await fixture()); + const { generation, volume } = await activateWithStorage(owner); + const original = await Bun.file(receiptPath(owner)).text(); + let effects = 0; + await owner.withMutation((mutation) => + rejected( + mutation.runEffect({ + generation, + operation: "restart", + assertFresh: async () => {}, + assertOwned: async () => { + // Adversarial synthetic receipt seam: content parity is insufficient under a live owner. + const replacement = `${receiptPath(owner)}.replacement`; + await writeFile(replacement, original, { mode: 0o600 }); + await rename(replacement, receiptPath(owner)); + }, + captureStorage: () => [volume], + effect: async () => { + effects += 1; + return { outcome: "complete", value: 0 }; + }, + }), + "E_NATIVE_COMPOSE_STATE" + ) + ); + expect(effects).toBe(0); + expect(await Bun.file(receiptPath(owner)).text()).toBe(original); + expect((await owner.loadCurrent()).retainedStorage).toEqual([volume]); +}); + +test.each([ + "missing", + "null", + "duplicate", + "missing-birth", + "invalid-birth", + "extra-field", +] as const)("version-two storage receipt refuses %s facts without rewriting it", async (change) => { + const owner = await store(await fixture()); + await activate(owner); + const volume = volumeFact(owner); + // Existing synthetic-receipt tamper seam; no live managed journal is edited. + const value: Record = await Bun.file( + receiptPath(owner) + ).json(); + value.version = 2; + value.storage = [volume]; + if (change === "missing") { + Reflect.deleteProperty(value, "storage"); + } else if (change === "null") { + value.storage = null; + } else if (change === "duplicate") { + value.storage = [volume, volume]; + } else if (change === "missing-birth") { + value.storage = [{ name: volume.name, storage: volume.storage }]; + } else if (change === "invalid-birth") { + value.storage = [ + { ...volume, createdAt: "synthetic-private-storage-canary" }, + ]; + } else { + value.storage = [{ ...volume, extra: "synthetic-private-storage-canary" }]; + } + const changed = JSON.stringify(value); + await writeFile(receiptPath(owner), changed, { mode: 0o600 }); + await rejected(owner.loadCurrent(), "E_NATIVE_COMPOSE_STATE"); + let actions = 0; + await rejected( + owner.withMutation(async () => { + actions += 1; + }), + "E_NATIVE_COMPOSE_STATE" + ); + expect(actions).toBe(0); + expect(await Bun.file(receiptPath(owner)).text()).toBe(changed); +}); + async function routeStore( root: string, owner: { readonly composeProject: string; readonly ownerToken: string } diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index 30abf60d..f758b63a 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -19,6 +19,8 @@ const PENDING = "b".repeat(32); const ID = "c".repeat(64); const NETWORK_ID = "d".repeat(64); const VOLUME = "hack-21-hack-fixture-instance-4-data"; +const CREATED = "2026-10-08T00:00:00.123456789Z"; +const REBORN = "2026-10-08T00:00:01.123456789Z"; const CANARY = "synthetic-private-env-image-canary"; const options: NativeComposeOwnershipOptions = { composeProject: PROJECT, @@ -86,7 +88,12 @@ if (action === "ls") { if (fixture.mode === "restart-member-transition" && kind === "network" && count > 0) { rows[0].containers[${JSON.stringify(ID)}] = {}; } - for (const id of selected) { const row = rows.find(row => row.id === id); if (!row) process.exit(1); console.log(JSON.stringify(row)); } + const order = fixture.mode === "volume-order" && kind === "volume" && count > 0 ? [...selected].reverse() : selected; + for (const id of order) { const row = rows.find(row => row.id === id); if (!row) process.exit(1); console.log(JSON.stringify(row)); } + if (fixture.mode === "volume-rebirth" && kind === "volume" && count === 0) { + fixture.volume[0].createdAt = ${JSON.stringify(REBORN)}; + writeFileSync(root + "/fixture.json", JSON.stringify(fixture)); + } } ` ); @@ -129,6 +136,7 @@ function owned(): Fixture { instance: PROJECT, owner: OWNER, storage: "data", + createdAt: CREATED, }, ], network: [ @@ -300,7 +308,7 @@ test("owned resources yield only bounded readiness observations and exact read-o oneoff: false, }, ], - volumes: [{ name: VOLUME, storage: "data" }], + volumes: [{ name: VOLUME, storage: "data", createdAt: CREATED }], networks: [{ id: NETWORK_ID, name: `${PROJECT}_default` }], }); for (const args of await commands()) { @@ -334,7 +342,7 @@ test("fully completed real queries never signal an exited process group", async expect((await assertNativeComposeOwned(options)).containers).toHaveLength( 1 ); - expect(await commands()).toHaveLength(11); + expect(await commands()).toHaveLength(12); expect(signals).not.toHaveBeenCalled(); } finally { signals.mockRestore(); @@ -504,6 +512,7 @@ test("persistent storage must match exact generated name and logical storage own instance: PROJECT, owner: OWNER, storage: "wrong", + createdAt: CREATED, }, ]; await prepare(fixture); @@ -512,6 +521,92 @@ test("persistent storage must match exact generated name and logical storage own await expectRefusal({ ...options, expectedVolumes: [] }); await expectRefusal({ ...options, expectedNetwork: "another-default" }); }); +test("retained volume policy requires exact presence and birth while cold inventory stays admissible", async () => { + const retained: NativeComposeOwnershipOptions = { + ...options, + expectedVolumes: [ + { name: VOLUME, storage: "data", mustExist: true, createdAt: CREATED }, + ], + }; + await prepare(owned()); + expect((await assertNativeComposeOwned(retained)).volumes).toEqual([ + { name: VOLUME, storage: "data", createdAt: CREATED }, + ]); + await prepare({}); + await expectRefusal(retained); + // Legacy history can require presence without inventing a prior birth. + await expectRefusal({ + ...options, + expectedVolumes: [{ name: VOLUME, storage: "data", mustExist: true }], + }); + expect((await assertNativeComposeOwned(options)).volumes).toEqual([]); + const replaced = owned(); + const volume = replaced.volume?.[0]; + if (!volume) { + throw new Error("Missing volume fixture"); + } + volume.createdAt = REBORN; + await prepare(replaced); + await expectRefusal(retained); +}); +test.each([ + null, + "", + "0", + CANARY, + "2026-10-08 00:00:00", +])("volume creation facts refuse malformed %j without exposing values", async (createdAt) => { + const fixture = owned(); + const volume = fixture.volume?.[0]; + if (!volume) { + throw new Error("Missing volume fixture"); + } + volume.createdAt = createdAt; + await prepare(fixture); + await expectRefusal(); +}); +test("a same-name same-label volume replacement between inspections refuses before effect authority", async () => { + await prepare({ ...owned(), mode: "volume-rebirth" }); + await expectRefusal({ + ...options, + expectedVolumes: [ + { name: VOLUME, storage: "data", mustExist: true, createdAt: CREATED }, + ], + }); +}); +test("stable volume births tolerate reordered complete inspect rows", async () => { + const fixture = owned(); + const volume = fixture.volume?.[0]; + if (!volume) { + throw new Error("Missing volume fixture"); + } + fixture.volume = [ + volume, + { + ...volume, + id: `${VOLUME}-archive`, + name: `${VOLUME}-archive`, + storage: "archive", + }, + ]; + fixture.mode = "volume-order"; + await prepare(fixture); + const result = await assertNativeComposeOwned({ + ...options, + expectedVolumes: [ + { name: VOLUME, storage: "data", createdAt: CREATED }, + { name: `${VOLUME}-archive`, storage: "archive", createdAt: CREATED }, + ], + }); + expect( + [...result.volumes].sort((left, right) => + left.name.localeCompare(right.name) + ) + ).toEqual([ + { name: VOLUME, storage: "data", createdAt: CREATED }, + { name: `${VOLUME}-archive`, storage: "archive", createdAt: CREATED }, + ]); +}); test("vanished resources and changed selected inventory refuse without retry or mutation", async () => { for (const mode of ["vanished", "inventory-change"]) { await prepare({ ...owned(), mode }); diff --git a/tests/native-compose-retained-storage-command.test.ts b/tests/native-compose-retained-storage-command.test.ts new file mode 100644 index 00000000..3cec8e24 --- /dev/null +++ b/tests/native-compose-retained-storage-command.test.ts @@ -0,0 +1,183 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { openNativeComposeGenerationStore } from "../src/lib/native-compose-generation.ts"; +import { fixture, invoke, state } from "./helpers/native-compose-command.ts"; + +type Volume = { + readonly id: string; + readonly name: string; + readonly project: string; + readonly version: string; + readonly instance: string; + readonly owner: string; + readonly storage: string; + readonly createdAt: string; +}; + +/** These are synthetic engine facts, never a fabricated managed receipt. */ +async function retainedVolume(root: string): Promise { + const volumes: unknown = await Bun.file(join(root, "volumes")).json(); + expect(Array.isArray(volumes)).toBe(true); + if (!Array.isArray(volumes) || volumes.length !== 1) { + throw new Error("Expected one persistent fixture volume"); + } + const volume: unknown = volumes[0]; + if ( + !isRecord(volume) || + typeof volume.id !== "string" || + typeof volume.name !== "string" || + typeof volume.project !== "string" || + typeof volume.version !== "string" || + typeof volume.instance !== "string" || + typeof volume.owner !== "string" || + typeof volume.storage !== "string" || + typeof volume.createdAt !== "string" + ) { + throw new Error("Expected complete synthetic volume facts"); + } + expect(volume.id).toBe(volume.name); + expect(volume.storage).toBe("data"); + expect(Number.isFinite(Date.parse(volume.createdAt))).toBe(true); + return { + id: volume.id, + name: volume.name, + project: volume.project, + version: volume.version, + instance: volume.instance, + owner: volume.owner, + storage: volume.storage, + createdAt: volume.createdAt, + }; +} + +async function savedState(root: string) { + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + mode: "saved", + }); + try { + const current = await store.loadCurrent(); + return { + generationId: current.generation?.generationId, + stopped: current.stopped, + pending: current.pending, + retainedStorage: current.retainedStorage, + }; + } finally { + await store.close(); + } +} + +async function composeStarts(root: string) { + return (await Bun.file(join(root, "requests")).text()) + .split("\n") + .filter((line) => { + if (!line) { + return false; + } + const args: unknown = JSON.parse(line); + return ( + Array.isArray(args) && args[0] === "compose" && args.includes("up") + ); + }); +} + +async function storageFixture() { + return await fixture("", false, { + noHooks: true, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); +} + +async function startAndStop() { + const root = await storageFixture(); + expect((await invoke(root)).code).toBe(0); + const volume = await retainedVolume(root); + expect((await invoke(root, ["down", "--json"])).code).toBe(0); + expect(await state(root)).toMatchObject({ stopped: true, pending: false }); + expect(await retainedVolume(root)).toEqual(volume); + expect(await composeStarts(root)).toHaveLength(1); + return { root, volume }; +} + +test("genuinely cold source CLI startup may create its first persistent volume", async () => { + const root = await storageFixture(); + expect(await Bun.file(join(root, "volumes")).exists()).toBe(false); + const result = await invoke(root); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + data: { status: "ready" }, + }); + await retainedVolume(root); + expect(await composeStarts(root)).toHaveLength(1); +}, 30_000); + +test("source CLI down/up restores the unchanged physical volume birth", async () => { + const { root, volume } = await startAndStop(); + const result = await invoke(root); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + data: { status: "ready" }, + }); + expect(await retainedVolume(root)).toEqual(volume); + expect(await composeStarts(root)).toHaveLength(2); + expect(await state(root)).toMatchObject({ stopped: false, pending: false }); +}, 30_000); + +test.each([ + "missing", + "replaced", +] as const)("source CLI refuses a %s retained volume before Compose can create or start", async (change) => { + const { root, volume } = await startAndStop(); + const before = await savedState(root); + const changed = + change === "missing" + ? [] + : [ + { + ...volume, + createdAt: new Date( + Date.parse(volume.createdAt) - 1000 + ).toISOString(), + }, + ]; + await Bun.write(join(root, "volumes"), JSON.stringify(changed)); + const result = await invoke(root); + // The effect count is the safety oracle even if a later readiness check fails. + expect(await composeStarts(root)).toHaveLength(1); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false }); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); + expect(await Bun.file(join(root, "volumes")).json()).toEqual(changed); + expect(await savedState(root)).toEqual(before); +}, 30_000); + +test("incomplete cold readiness retains observed storage and refuses data-loss recovery", async () => { + const root = await storageFixture(); + await Bun.write(join(root, "unready"), "true"); + const started = await invoke(root); + expect(started.code).toBe(1); + const volume = await retainedVolume(root); + const before = await savedState(root); + expect(before.generationId).toBeUndefined(); + expect(before.pending).not.toBeNull(); + expect(before.retainedStorage).toEqual([ + { name: volume.name, storage: volume.storage, createdAt: volume.createdAt }, + ]); + await Bun.write(join(root, "volumes"), "[]"); + const recovered = await invoke(root, ["down", "--recover", "--json"]); + expect(recovered.code).toBe(1); + const requests: string[][] = (await Bun.file(join(root, "requests")).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect( + requests.filter((args) => args[0] === "compose" && args.includes("down")) + ).toHaveLength(0); + expect(await savedState(root)).toEqual(before); + expect(await Bun.file(join(root, "volumes")).json()).toEqual([]); +}, 30_000); diff --git a/tests/native-compose-retained-storage.test.ts b/tests/native-compose-retained-storage.test.ts new file mode 100644 index 00000000..17036db4 --- /dev/null +++ b/tests/native-compose-retained-storage.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { + mergeNativeComposeRetainedVolumes, + nativeComposeRetainedVolumesValid, + selectNativeComposeVolumePolicies, +} from "../src/lib/native-compose-retained-storage.ts"; + +const original = { + name: "owned_data", + storage: "data", + createdAt: "2026-10-08T12:00:00Z", +}; + +test("storage provenance is monotonic across new declarations", () => { + const added = { ...original, name: "owned_cache", storage: "cache" }; + const merged = mergeNativeComposeRetainedVolumes({ + retained: [original], + observed: [original, added], + }); + expect(merged).toEqual([added, original]); + expect(Object.isFrozen(merged)).toBe(true); + expect(Object.isFrozen(merged[0])).toBe(true); + expect(merged[1]).not.toBe(original); +}); + +test.each([ + { observed: [] }, + { observed: [{ ...original, createdAt: "2026-10-08T12:00:01Z" }] }, + { observed: [{ ...original, storage: "rebound" }] }, +])("storage facts cannot forget or rebind a retained volume", ({ + observed, +}) => { + expect(() => + mergeNativeComposeRetainedVolumes({ retained: [original], observed }) + ).toThrow("Native retained storage changed; values omitted."); +}); + +test.each([ + { observed: [{ ...original, createdAt: "private-canary" }] }, + { observed: [{ ...original, createdAt: "2026-99-08T12:00:00Z" }] }, + { observed: [{ ...original, name: "../private-canary" }] }, + { observed: [{ ...original, extra: "private-canary" }] }, + { observed: [original, original] }, +])("malformed engine provenance is refused without reflecting its values", ({ + observed, +}) => { + expect(nativeComposeRetainedVolumesValid(observed)).toBe(false); + expect(() => + mergeNativeComposeRetainedVolumes({ retained: [], observed }) + ).toThrow("Invalid native retained storage facts; values omitted."); +}); + +test("dropped declarations keep retained birth guards; cold volumes may be created", () => { + expect( + selectNativeComposeVolumePolicies({ + declared: [{ name: "owned_cache", storage: "cache" }], + retained: [original], + legacyNames: new Set(), + }) + ).toEqual([ + { ...original, mustExist: true }, + { name: "owned_cache", storage: "cache" }, + ]); +}); + +test("legacy receipts require present storage before their first birth observation", () => { + expect( + selectNativeComposeVolumePolicies({ + declared: [original], + retained: [], + legacyNames: new Set([original.name]), + }) + ).toEqual([ + { name: original.name, storage: original.storage, mustExist: true }, + ]); +}); + +test("a declaration cannot rename the logical owner of historical storage", () => { + expect(() => + selectNativeComposeVolumePolicies({ + declared: [{ name: original.name, storage: "rebound" }], + retained: [original], + legacyNames: new Set(), + }) + ).toThrow("Native retained storage changed; values omitted."); +}); From 4bd6dc8708efae39a53d20db8e9c25bfd67fbbff Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 08:36:42 -0400 Subject: [PATCH 02/23] docs(runtime): state retained-volume metadata proof limits --- docs/reference/native-compose-generation.md | 7 +++++-- docs/reference/native-compose-ownership.md | 10 +++++++--- src/lib/native-compose-retained-storage.ts | 5 +++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/reference/native-compose-generation.md b/docs/reference/native-compose-generation.md index 95dedff8..4dd1811c 100644 --- a/docs/reference/native-compose-generation.md +++ b/docs/reference/native-compose-generation.md @@ -39,8 +39,11 @@ volumes must exist before any new effect, and the first verified observation records their current birth. This cannot prove that a same-name replacement did not happen before that first observation. Older clients refuse version `2` receipts; do not remove or edit private receipts to force a downgrade. The birth -fence trusts engine metadata and cannot protect against a hostile daemon or an -administrator recreating the same metadata. No volume deletion or automatic +fence trusts engine metadata and is not a unique engine identity. An ordinary +same-second replacement with identical labels and `CreatedAt` can be +indistinguishable when the engine timestamp has coarse precision. This guard +does not establish volume-content continuity or detect every empty replacement; +that requires a separately qualified persistent content witness. No volume deletion or automatic data-loss repair is added. Verified readiness and one-off ownership observations also feed this storage diff --git a/docs/reference/native-compose-ownership.md b/docs/reference/native-compose-ownership.md index ab2eaf84..6125529f 100644 --- a/docs/reference/native-compose-ownership.md +++ b/docs/reference/native-compose-ownership.md @@ -40,9 +40,13 @@ readiness or cleanup decisions. Failures discard daemon output and diagnostics. Containers and networks are inspected by full immutable IDs. Docker volumes expose names rather than immutable engine IDs. The selected inventory is checked again -after inspection, so additions, removals, renames, and project-label conflicts -refuse. This is not an atomic engine transaction: another Docker client can change -resources after the last observation, or replace a volume under the same name. +after inspection, including a second selected-volume inspection that compares +name, storage key and reported creation timestamp. Additions, removals, renames, +project-label conflicts or changed birth metadata refuse. This is not an atomic +engine transaction: another Docker client can change resources after the last +observation. Identical names, labels and timestamps do not establish unique +physical identity or content continuity; an ordinary same-second replacement +may be indistinguishable. A persistent content witness remains a separate gate. The caller must recheck at its effect boundary and retain explicit recovery for uncertain outcomes. diff --git a/src/lib/native-compose-retained-storage.ts b/src/lib/native-compose-retained-storage.ts index 0ff0e524..401d4c8b 100644 --- a/src/lib/native-compose-retained-storage.ts +++ b/src/lib/native-compose-retained-storage.ts @@ -90,9 +90,10 @@ export function nativeComposeRetainedVolumesValid( /** * First observations may add facts; existing facts are never forgotten or rebound. - * Missing or replaced volumes refuse before effects, including after a verified stop. + * Missing volumes or changed birth metadata refuse before effects, including after a verified stop. * A birth timestamp is an engine-provided fence, not cryptographic proof against a - * hostile daemon or an administrator able to recreate all volume metadata. + * unique engine identity. Identical metadata, including an ordinary same-second + * replacement when timestamp precision is coarse, cannot prove content continuity. */ export function mergeNativeComposeRetainedVolumes(opts: { readonly retained: readonly NativeComposeRetainedVolume[]; From 26599ea6145457df4a544bcce67f9071b67bd708 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 09:27:39 -0400 Subject: [PATCH 03/23] feat: add native storage witness protocol foundation --- .../native-compose-storage-witness.md | 59 ++ .../native-compose-storage-witness-codec.ts | 151 +++++ src/lib/native-compose-storage-witness.ts | 640 ++++++++++++++++++ ...tive-compose-storage-witness-codec.test.ts | 125 ++++ tests/native-compose-storage-witness.test.ts | 605 +++++++++++++++++ 5 files changed, 1580 insertions(+) create mode 100644 docs/reference/native-compose-storage-witness.md create mode 100644 src/lib/native-compose-storage-witness-codec.ts create mode 100644 src/lib/native-compose-storage-witness.ts create mode 100644 tests/native-compose-storage-witness-codec.test.ts create mode 100644 tests/native-compose-storage-witness.test.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md new file mode 100644 index 00000000..6f205782 --- /dev/null +++ b/docs/reference/native-compose-storage-witness.md @@ -0,0 +1,59 @@ +# Native Compose storage witness foundation + +This is an internal protocol foundation. The CLI still uses the metadata guard +described in [ownership](native-compose-ownership.md). It does not yet enroll or +enforce these witness references. Identical volume labels, name and `CreatedAt` +remain insufficient to detect an empty replacement in the shipping path. + +The candidate protocol binds a random marker leaf and independent 256-bit token +to the engine, instance owner, physical volume name, logical storage, admitting +generation and pending operation token. Marker names, contents, hashes and private +paths must stay out of public receipts, errors, argv and logs. A marker detects a +replacement that lacks the enrolled witness; it does not prove all database bytes +are intact, and a copy containing the original witness is outside this guarantee. + +`prepareNativeComposeStorageWitness` runs under the existing generation mutation's +opaque material authority. The caller must prove either that the selected new +volume is genuinely absent or that an explicit adoption selected an exact stopped +volume. Adoption records the current birth but cannot prove historical continuity. +It creates an immutable private expectation with exclusive publication, file +synchronization and directory synchronization before any provisioning or seed +callback. An existing slot, including an empty interrupted slot, refuses. The +expectation is never deleted or replaced to admit a retry. + +`enrollNativeComposeStorageWitness` consumes the same invocation's opaque +capability before awaiting effects. The transport must refuse an existing marker +path, symlink or special file rather than overwrite it. After one seed attempt, +fresh read-only observation must match the exact marker and volume metadata. Only +then does the owner append and synchronize an immutable completion record. The +private reference pins both directory identities and both journal file identities +and hashes. The current generation and pending token are rechecked around each +publication. Copied, reused or revoked capabilities cannot authorize another seed. + +`verifyNativeComposeStorageWitness` reads and compares those records and the +observed marker. It does not create, write, delete or repair storage. Missing or +changed metadata, content, completion, engine or private journal identity refuses +before the caller's workload action. A pending expectation after interruption +remains uncertain even if a matching marker was written. There is no automatic +promotion or seed replay. Explicit reconciliation of such an expectation is a +separate recovery contract; saved resource stop must retain its witness anchor. + +The archive codec accepts one bounded regular USTAR member with the exact random +leaf, permissions and token. It rejects encoded links, special files, extensions, +extra members, bad checksums and nonzero padding, and extracts nothing on the host. +Rejecting an encoded hardlink is not a claim about the daemon's file link count. + +Activation requires a new mandatory receipt version with immutable references and +an enrollment intent anchored before effects. Existing v1/v2 readers must refuse +that format. Resume must never infer enrollment from metadata, scan arbitrary +marker names, or auto-enroll a legacy volume. Private state for failed, pending and +completed generations must remain monotonic through stop and recovery. + +A stopped, unstarted owned carrier with a read-only volume mount is a possible +Docker read transport. It would need an exact already-selected local image ID, +no pull or start, `volume-nocopy`, complete accounting for image-declared volumes, +bounded output and deadlines, exact carrier ownership and saved cleanup. Docker +copy alone does not provide an exclusive marker write. That enrollment boundary, +carrier creation uncertainty, normal and explicit recovery, compiled CLI coverage +and real engine acceptance remain required gates. No carrier or helper download is +implemented by this foundation. diff --git a/src/lib/native-compose-storage-witness-codec.ts b/src/lib/native-compose-storage-witness-codec.ts new file mode 100644 index 00000000..240f7889 --- /dev/null +++ b/src/lib/native-compose-storage-witness-codec.ts @@ -0,0 +1,151 @@ +import { timingSafeEqual } from "node:crypto"; +import { isRecord } from "./guards.ts"; + +const MARKER = /^\.hack-storage-[a-f0-9]{64}\.witness$/; +const TOKEN = /^[a-f0-9]{64}$/; +const BLOCK = 512; +const ARCHIVE_LIMIT = 8192; +const OCTAL = /^[0-7]+$/; +const CONTENT_PREFIX = "hack-native-storage-witness-v1\n"; + +/** A private random leaf and token; never include either in public reports or argv. */ +export type NativeComposeStorageWitnessMarker = { + readonly name: string; + readonly token: string; +}; + +export function refuseNativeComposeStorageWitness(): never { + throw new Error( + "Native storage witness is missing, unsafe or changed; values omitted. No storage repair was attempted." + ); +} + +export function nativeComposeStorageWitnessMarkerValid( + value: unknown +): value is NativeComposeStorageWitnessMarker { + return ( + isRecord(value) && + Object.keys(value).sort().join() === "name,token" && + typeof value.name === "string" && + MARKER.test(value.name) && + typeof value.token === "string" && + TOKEN.test(value.token) + ); +} + +function content(marker: NativeComposeStorageWitnessMarker): Buffer { + if (!nativeComposeStorageWitnessMarkerValid(marker)) { + return refuseNativeComposeStorageWitness(); + } + return Buffer.from(`${CONTENT_PREFIX}${marker.token}\n`); +} + +function writeOctal( + header: Buffer, + offset: number, + length: number, + value: number +) { + header.write( + `${value.toString(8).padStart(length - 1, "0")}\0`, + offset, + length, + "ascii" + ); +} + +/** A single regular USTAR member. The transport must create exclusively, never extract over an existing path. */ +export function encodeNativeComposeStorageWitnessArchive( + marker: NativeComposeStorageWitnessMarker +): Uint8Array { + const bytes = content(marker); + const archive = Buffer.alloc(BLOCK * 4); + const header = archive.subarray(0, BLOCK); + header.write(marker.name, 0, "ascii"); + writeOctal(header, 100, 8, 0o600); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, bytes.length); + writeOctal(header, 136, 12, 0); + header.fill(32, 148, 156); + header[156] = 48; + header.write("ustar\0", 257, "ascii"); + header.write("00", 263, "ascii"); + const checksum = header.reduce((sum, value) => sum + value, 0); + header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + bytes.copy(archive, BLOCK); + return archive; +} + +function field(header: Buffer, offset: number, length: number): string { + const bytes = header.subarray(offset, offset + length); + const nul = bytes.indexOf(0); + if (nul !== -1 && bytes.subarray(nul).some((value) => value !== 0)) { + return refuseNativeComposeStorageWitness(); + } + const used = nul === -1 ? bytes : bytes.subarray(0, nul); + if (used.some((value) => value > 127)) { + return refuseNativeComposeStorageWitness(); + } + return used.toString("ascii"); +} + +function octal(header: Buffer, offset: number, length: number): number { + const value = header + .subarray(offset, offset + length) + .toString("ascii") + .replaceAll("\0", "") + .trim(); + if (!OCTAL.test(value)) { + return refuseNativeComposeStorageWitness(); + } + const parsed = Number.parseInt(value, 8); + if (!Number.isSafeInteger(parsed)) { + return refuseNativeComposeStorageWitness(); + } + return parsed; +} + +/** + * Decode nothing onto the host. Accept one bounded regular member with the exact + * random name, permissions and contents. Links, special files, PAX/GNU extensions, + * extra members and nonzero padding refuse. This is content continuity, not a + * daemon-provided nlink check or protection against copying an entire witness. + */ +export function verifyNativeComposeStorageWitnessArchive(opts: { + readonly marker: NativeComposeStorageWitnessMarker; + readonly archive: Uint8Array; +}): void { + const expected = content(opts.marker); + if ( + opts.archive.byteLength < BLOCK * 4 || + opts.archive.byteLength > ARCHIVE_LIMIT || + opts.archive.byteLength % BLOCK !== 0 + ) { + refuseNativeComposeStorageWitness(); + } + const archive = Buffer.from(opts.archive); + const header = archive.subarray(0, BLOCK); + const checksum = header.reduce( + (sum, value, index) => sum + (index >= 148 && index < 156 ? 32 : value), + 0 + ); + if ( + field(header, 0, 100) !== opts.marker.name || + octal(header, 100, 8) !== 0o600 || + octal(header, 124, 12) !== expected.length || + octal(header, 148, 8) !== checksum || + ![0, 48].includes(header[156] ?? -1) || + field(header, 157, 100) !== "" || + field(header, 257, 6) !== "ustar" || + field(header, 263, 2) !== "00" || + field(header, 345, 155) !== "" || + !timingSafeEqual( + archive.subarray(BLOCK, BLOCK + expected.length), + expected + ) || + archive.subarray(BLOCK + expected.length).some((value) => value !== 0) + ) { + refuseNativeComposeStorageWitness(); + } +} diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts new file mode 100644 index 00000000..96614131 --- /dev/null +++ b/src/lib/native-compose-storage-witness.ts @@ -0,0 +1,640 @@ +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, opendir } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "./guards.ts"; +import { + assertNativeComposeMaterialAuthority, + type NativeComposeGeneration, + type NativeComposeMaterialAuthority, + type NativeComposeMaterialBinding, + runNativeComposeMaterialAction, +} from "./native-compose-generation.ts"; +import { + type HeldDirectory, + holdDirectory, + keys, + parsePrivateJson, + privateDirectory, + readPrivate, + recheckDirectories, + sameFile, + writeExclusive, +} from "./native-compose-private-state.ts"; +import { + type NativeComposeRetainedVolume, + nativeComposeRetainedVolumesValid, +} from "./native-compose-retained-storage.ts"; +import { + encodeNativeComposeStorageWitnessArchive, + type NativeComposeStorageWitnessMarker, + nativeComposeStorageWitnessMarkerValid, + refuseNativeComposeStorageWitness as refuse, + verifyNativeComposeStorageWitnessArchive, +} from "./native-compose-storage-witness-codec.ts"; + +const LIMIT = 16 * 1024; +const SLOT_LIMIT = 4096; +const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; +const HASH = /^[a-f0-9]{64}$/; +const TOKEN = /^[a-f0-9]{32}$/; +type Anchor = { + readonly dev: number; + readonly ino: number; + readonly hash: string; +}; +type DirectoryAnchor = { readonly dev: number; readonly ino: number }; +type Selection = { readonly name: string; readonly storage: string }; +type Binding = Selection & { + readonly engineId: string; + readonly instanceId: string; + readonly ownerToken: string; + readonly generationId: string; + readonly pendingToken: string; +}; +type Expectation = { + readonly version: 1; + readonly binding: Binding; + readonly admission: "initial-create" | "explicit-adoption"; + readonly originalVolume: NativeComposeRetainedVolume | null; + readonly marker: NativeComposeStorageWitnessMarker; +}; +type Completion = { + readonly version: 1; + readonly expectationHash: string; + readonly volume: NativeComposeRetainedVolume; +}; +/** Private receipt extension candidate. Existing v1/v2 receipts must never silently ignore it. */ +export type NativeComposeStorageWitnessReference = { + readonly version: 1; + readonly volume: NativeComposeRetainedVolume; + readonly root: DirectoryAnchor; + readonly directory: DirectoryAnchor; + readonly expectation: Anchor; + readonly completion: Anchor; +}; +export type NativeComposeStorageWitnessEnrollment = Readonly< + Record +>; +type Enrollment = { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly expectation: Expectation; + readonly anchor: Anchor; + readonly root: DirectoryAnchor; + readonly directory: DirectoryAnchor; + readonly assertAdmission: () => Promise; + consumed: boolean; +}; +const enrollments = new WeakMap< + NativeComposeStorageWitnessEnrollment, + Enrollment +>(); + +function hash(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} +function anchor(read: { + readonly info: { readonly dev: number; readonly ino: number }; + readonly text: string; +}): Anchor { + return Object.freeze({ + dev: read.info.dev, + ino: read.info.ino, + hash: hash(read.text), + }); +} +function anchorValid(value: unknown): value is Anchor { + return ( + isRecord(value) && + keys(value, "dev,hash,ino") && + typeof value.dev === "number" && + Number.isSafeInteger(value.dev) && + value.dev >= 0 && + typeof value.ino === "number" && + Number.isSafeInteger(value.ino) && + value.ino > 0 && + typeof value.hash === "string" && + HASH.test(value.hash) + ); +} +function directoryAnchorValid(value: unknown): value is DirectoryAnchor { + return ( + isRecord(value) && + keys(value, "dev,ino") && + typeof value.dev === "number" && + Number.isSafeInteger(value.dev) && + value.dev >= 0 && + typeof value.ino === "number" && + Number.isSafeInteger(value.ino) && + value.ino > 0 + ); +} +function selectionValid(value: Selection): boolean { + return nativeComposeRetainedVolumesValid([ + { ...value, createdAt: "2000-01-01T00:00:00Z" }, + ]); +} +function bindingValid(value: unknown): value is Binding { + return ( + isRecord(value) && + keys( + value, + "engineId,generationId,instanceId,name,ownerToken,pendingToken,storage" + ) && + typeof value.name === "string" && + typeof value.storage === "string" && + selectionValid({ name: value.name, storage: value.storage }) && + typeof value.engineId === "string" && + ENGINE.test(value.engineId) && + typeof value.instanceId === "string" && + HASH.test(value.instanceId) && + typeof value.ownerToken === "string" && + TOKEN.test(value.ownerToken) && + typeof value.generationId === "string" && + TOKEN.test(value.generationId) && + typeof value.pendingToken === "string" && + TOKEN.test(value.pendingToken) + ); +} +function expectation(text: string): Expectation { + const value = parsePrivateJson(text); + if ( + !( + isRecord(value) && + keys(value, "admission,binding,marker,originalVolume,version") && + value.version === 1 && + bindingValid(value.binding) && + isRecord(value.marker) && + keys(value.marker, "name,token") && + typeof value.marker.name === "string" && + typeof value.marker.token === "string" && + nativeComposeStorageWitnessMarkerValid({ + name: value.marker.name, + token: value.marker.token, + }) && + (value.admission === "initial-create" + ? value.originalVolume === null + : value.admission === "explicit-adoption" && + nativeComposeRetainedVolumesValid([value.originalVolume])) + ) + ) { + return refuse(); + } + const originalVolume = + value.originalVolume === null ? null : parseVolume(value.originalVolume); + if ( + originalVolume && + (originalVolume.name !== value.binding.name || + originalVolume.storage !== value.binding.storage) + ) { + return refuse(); + } + return { + version: 1, + binding: value.binding, + admission: + value.admission === "initial-create" + ? "initial-create" + : "explicit-adoption", + originalVolume, + marker: { name: value.marker.name, token: value.marker.token }, + }; +} +function parseVolume(value: unknown): NativeComposeRetainedVolume { + const entries = [value]; + if (!nativeComposeRetainedVolumesValid(entries)) { + return refuse(); + } + return entries[0] ?? refuse(); +} +function completion(text: string): Completion { + const value = parsePrivateJson(text); + if ( + !( + isRecord(value) && + keys(value, "expectationHash,version,volume") && + value.version === 1 && + typeof value.expectationHash === "string" && + HASH.test(value.expectationHash) + ) + ) { + return refuse(); + } + return { + version: 1, + expectationHash: value.expectationHash, + volume: parseVolume(value.volume), + }; +} +function referenceValid(value: NativeComposeStorageWitnessReference): boolean { + return ( + isRecord(value) && + keys(value, "completion,directory,expectation,root,version,volume") && + value.version === 1 && + nativeComposeRetainedVolumesValid([value.volume]) && + directoryAnchorValid(value.root) && + directoryAnchorValid(value.directory) && + anchorValid(value.expectation) && + anchorValid(value.completion) + ); +} +function sameVolume( + left: NativeComposeRetainedVolume, + right: NativeComposeRetainedVolume +): boolean { + return ( + left.name === right.name && + left.storage === right.storage && + left.createdAt === right.createdAt + ); +} +function matchesBinding( + saved: Binding, + current: NativeComposeMaterialBinding, + engineId: string +): boolean { + return ( + saved.engineId === engineId && + saved.instanceId === current.identity.instanceId && + saved.ownerToken === current.identity.ownerToken + ); +} +function matchesAdmission( + saved: Binding, + current: NativeComposeMaterialBinding +): boolean { + return ( + matchesBinding(saved, current, saved.engineId) && + saved.generationId === current.generationId && + saved.pendingToken === current.pendingToken && + current.pendingGenerationId === saved.generationId + ); +} +async function directories(opts: { + readonly binding: NativeComposeMaterialBinding; + readonly name: string; + readonly create: boolean; +}): Promise { + const held: HeldDirectory[] = []; + try { + const parent = await holdDirectory( + join( + opts.binding.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + opts.binding.identity.instanceId + ), + true + ); + held.push(parent); + const rootPath = join(parent.path, "storage-witnesses"); + held.push( + opts.create + ? await privateDirectory(rootPath) + : await holdDirectory(rootPath, true) + ); + const count = await countSlots(rootPath); + if (opts.create && count >= SLOT_LIMIT) { + refuse(); + } + if (opts.create) { + await parent.file.sync(); + } + const path = join(rootPath, hash(opts.name)); + if (opts.create) { + // An interrupted/empty slot refuses too. No ordinary resume may recreate it. + await mkdir(path, { mode: 0o700 }); + await held[1]?.file.sync(); + } + held.push(await holdDirectory(path, true)); + if (opts.create) { + await countSlots(rootPath); + } + await recheckDirectories(held); + return held; + } catch { + await Promise.allSettled(held.map((directory) => directory.file.close())); + return refuse(); + } +} +async function countSlots(path: string): Promise { + let count = 0; + for await (const entry of await opendir(path)) { + count++; + if (count > SLOT_LIMIT || !entry.isDirectory() || !HASH.test(entry.name)) { + refuse(); + } + } + return count; +} +async function close(held: readonly HeldDirectory[]) { + await Promise.allSettled(held.map((directory) => directory.file.close())); +} +function last(held: readonly HeldDirectory[]): HeldDirectory { + return held.at(-1) ?? refuse(); +} +function directoryAnchor(directory: HeldDirectory): DirectoryAnchor { + return Object.freeze({ dev: directory.info.dev, ino: directory.info.ino }); +} +function checkDirectoryAnchors( + held: readonly HeldDirectory[], + selected: { + readonly root: DirectoryAnchor; + readonly directory: DirectoryAnchor; + } +) { + const root = held[1] ?? refuse(); + if ( + !( + sameFile(root.info, selected.root) && + sameFile(last(held).info, selected.directory) + ) + ) { + refuse(); + } +} +async function checkedExpectation( + held: readonly HeldDirectory[], + expected: Anchor +) { + const read = await readPrivate( + join(last(held).path, "expectation.json"), + LIMIT + ); + if (!sameFile(read.info, expected) || hash(read.text) !== expected.hash) { + return refuse(); + } + await recheckDirectories(held); + return expectation(read.text); +} + +/** + * Foundation only: the CLI does not yet enroll or enforce these references. + * The owning generation mutation must prove a genuinely absent new volume or + * explicit stopped adoption. It must publish a required new receipt version + * before using this extension; v1/v2 migration cannot establish past continuity. + * Durable expectation precedes all provisioning. An existing slot is never reset. + */ +export async function prepareNativeComposeStorageWitness(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly engineId: string; + readonly volume: Selection; + readonly admission: "initial-create" | "explicit-adoption"; + readonly originalVolume?: NativeComposeRetainedVolume; + readonly assertAdmission: () => Promise; +}): Promise { + const input = Object.freeze({ + ...opts, + volume: Object.freeze({ ...opts.volume }), + originalVolume: opts.originalVolume + ? Object.freeze({ ...opts.originalVolume }) + : undefined, + }); + return await runNativeComposeMaterialAction({ + authority: input.authority, + run: async () => { + const current = await assertNativeComposeMaterialAuthority({ + authority: input.authority, + generation: input.generation, + phase: "effect", + }); + const binding: Binding = { + ...input.volume, + engineId: input.engineId, + instanceId: current.identity.instanceId, + ownerToken: current.identity.ownerToken, + generationId: current.generationId, + pendingToken: current.pendingToken ?? refuse(), + }; + const record: Expectation = { + version: 1, + binding, + admission: input.admission, + originalVolume: input.originalVolume ?? null, + marker: { + name: `.hack-storage-${randomBytes(32).toString("hex")}.witness`, + token: randomBytes(32).toString("hex"), + }, + }; + const text = JSON.stringify(record); + expectation(text); + if (!matchesAdmission(binding, current)) { + return refuse(); + } + await input.assertAdmission(); + const held = await directories({ + binding: current, + name: binding.name, + create: true, + }); + try { + const info = await writeExclusive( + join(last(held).path, "expectation.json"), + text + ).catch(() => refuse()); + await last(held).file.sync(); + await recheckDirectories(held); + const latest = await assertNativeComposeMaterialAuthority({ + authority: input.authority, + generation: input.generation, + phase: "effect", + }); + if (!matchesAdmission(binding, latest)) { + return refuse(); + } + const capability = Object.freeze({}); + enrollments.set(capability, { + authority: input.authority, + generation: input.generation, + expectation: record, + anchor: anchor({ info, text }), + root: directoryAnchor(held[1] ?? refuse()), + directory: directoryAnchor(last(held)), + assertAdmission: input.assertAdmission, + consumed: false, + }); + return capability; + } finally { + await close(held); + } + }, + }).catch(() => refuse()); +} + +/** + * Consume before awaiting effects. The transport must refuse an existing marker + * path and never follow/overwrite links; a tar archive itself is not O_EXCL. + * Reopened, copied, failed or revoked capabilities cannot authorize another seed. + * Missing completion after a crash remains uncertain, even if the marker exists. + */ +export async function enrollNativeComposeStorageWitness(opts: { + readonly enrollment: NativeComposeStorageWitnessEnrollment; + readonly seed: (archive: Uint8Array) => Promise; + readonly observe: () => Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly archive: Uint8Array; + }>; +}): Promise { + const selected = enrollments.get(opts.enrollment); + if (!selected || selected.consumed) { + return refuse(); + } + selected.consumed = true; + return await runNativeComposeMaterialAction({ + authority: selected.authority, + run: async () => { + const check = async () => { + const binding = await assertNativeComposeMaterialAuthority({ + authority: selected.authority, + generation: selected.generation, + phase: "effect", + }); + if (!matchesAdmission(selected.expectation.binding, binding)) { + return refuse(); + } + return binding; + }; + const held = await directories({ + binding: await check(), + name: selected.expectation.binding.name, + create: false, + }); + try { + checkDirectoryAnchors(held, selected); + const saved = await checkedExpectation(held, selected.anchor); + await selected.assertAdmission(); + await check(); + await checkedExpectation(held, selected.anchor); + await opts.seed(encodeNativeComposeStorageWitnessArchive(saved.marker)); + const observed = await opts.observe(); + const volume = parseVolume(observed.volume); + if ( + volume.name !== saved.binding.name || + volume.storage !== saved.binding.storage || + (saved.originalVolume && !sameVolume(saved.originalVolume, volume)) + ) { + return refuse(); + } + verifyNativeComposeStorageWitnessArchive({ + marker: saved.marker, + archive: observed.archive, + }); + await check(); + await checkedExpectation(held, selected.anchor); + const text = JSON.stringify({ + version: 1, + expectationHash: selected.anchor.hash, + volume, + } satisfies Completion); + const info = await writeExclusive( + join(last(held).path, "enrolled.json"), + text + ).catch(() => refuse()); + await last(held).file.sync(); + await recheckDirectories(held); + await check(); + await checkedExpectation(held, selected.anchor); + const completed = await readPrivate( + join(last(held).path, "enrolled.json"), + LIMIT + ); + if (!sameFile(completed.info, info) || completed.text !== text) { + return refuse(); + } + await recheckDirectories(held); + return Object.freeze({ + version: 1, + volume: Object.freeze({ ...volume }), + root: selected.root, + directory: selected.directory, + expectation: selected.anchor, + completion: anchor({ info, text }), + }); + } finally { + await close(held); + } + }, + }).catch(() => refuse()); +} + +/** Read-only verification. No missing journal/marker, including interrupted enrollment, is seeded or repaired. */ +export async function verifyNativeComposeStorageWitness(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly engineId: string; + readonly reference: NativeComposeStorageWitnessReference; + readonly observe: (markerName: string) => Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly archive: Uint8Array; + }>; +}): Promise { + if (!referenceValid(opts.reference)) { + return refuse(); + } + const reference = structuredClone(opts.reference); + return await runNativeComposeMaterialAction({ + authority: opts.authority, + run: async () => { + const current = await assertNativeComposeMaterialAuthority({ + authority: opts.authority, + generation: opts.generation, + phase: "inspect", + }); + const held = await directories({ + binding: current, + name: reference.volume.name, + create: false, + }); + try { + checkDirectoryAnchors(held, reference); + const saved = await checkedExpectation(held, reference.expectation); + const readCompletion = async () => { + const read = await readPrivate( + join(last(held).path, "enrolled.json"), + LIMIT + ); + const record = completion(read.text); + if ( + !sameFile(read.info, reference.completion) || + hash(read.text) !== reference.completion.hash || + record.expectationHash !== reference.expectation.hash || + !sameVolume(record.volume, reference.volume) || + (saved.originalVolume !== null && + !sameVolume(saved.originalVolume, record.volume)) || + record.volume.name !== saved.binding.name || + record.volume.storage !== saved.binding.storage + ) { + return refuse(); + } + }; + await readCompletion(); + if (!matchesBinding(saved.binding, current, opts.engineId)) { + return refuse(); + } + const observed = await opts.observe(saved.marker.name); + if (!sameVolume(parseVolume(observed.volume), reference.volume)) { + return refuse(); + } + verifyNativeComposeStorageWitnessArchive({ + marker: saved.marker, + archive: observed.archive, + }); + await checkedExpectation(held, reference.expectation); + await readCompletion(); + const latest = await assertNativeComposeMaterialAuthority({ + authority: opts.authority, + generation: opts.generation, + phase: "inspect", + }); + if (!matchesBinding(saved.binding, latest, opts.engineId)) { + refuse(); + } + await recheckDirectories(held); + } finally { + await close(held); + } + }, + }).catch(() => refuse()); +} diff --git a/tests/native-compose-storage-witness-codec.test.ts b/tests/native-compose-storage-witness-codec.test.ts new file mode 100644 index 00000000..908a3218 --- /dev/null +++ b/tests/native-compose-storage-witness-codec.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { + encodeNativeComposeStorageWitnessArchive, + nativeComposeStorageWitnessMarkerValid, + verifyNativeComposeStorageWitnessArchive, +} from "../src/lib/native-compose-storage-witness-codec.ts"; + +const marker = { + name: `.hack-storage-${"a".repeat(64)}.witness`, + token: "b".repeat(64), +}; + +function changedHeader(change: (bytes: Buffer) => void): Uint8Array { + const bytes = Buffer.from(encodeNativeComposeStorageWitnessArchive(marker)); + change(bytes); + bytes.fill(32, 148, 156); + const checksum = bytes + .subarray(0, 512) + .reduce((sum, value) => sum + value, 0); + bytes.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + return bytes; +} + +test("witness codec accepts exactly its random leaf and payload without extraction", () => { + const archive = encodeNativeComposeStorageWitnessArchive(marker); + expect(archive.byteLength).toBe(2048); + expect(() => + verifyNativeComposeStorageWitnessArchive({ marker, archive }) + ).not.toThrow(); + expect(() => + verifyNativeComposeStorageWitnessArchive({ + marker: { ...marker, token: "c".repeat(64) }, + archive, + }) + ).toThrow("values omitted"); + expect(() => + verifyNativeComposeStorageWitnessArchive({ + marker: { ...marker, name: `.hack-storage-${"c".repeat(64)}.witness` }, + archive, + }) + ).toThrow("values omitted"); +}); + +test.each([ + { name: "../private-canary", token: "b".repeat(64) }, + { name: marker.name, token: "private-canary" }, + { ...marker, extra: true }, + null, +])("untrusted marker names and tokens refuse without reflection", (value) => { + expect(nativeComposeStorageWitnessMarkerValid(value)).toBe(false); +}); + +test.each([ + "1", + "2", + "3", + "4", + "5", + "6", + "x", + "g", + "L", +])("witness archive refuses type %s before exposing content", (type) => { + const archive = changedHeader((bytes) => { + bytes[156] = type.charCodeAt(0); + }); + expect(() => + verifyNativeComposeStorageWitnessArchive({ marker, archive }) + ).toThrow("values omitted"); +}); + +test.each([ + { change: (bytes: Buffer) => bytes.write("0000644\0", 100, "ascii") }, + { change: (bytes: Buffer) => bytes.write("other", 157, "ascii") }, + { change: (bytes: Buffer) => bytes.write("../private-canary", 345, "ascii") }, + { change: (bytes: Buffer) => bytes.write("\xff", 0, "latin1") }, + { + change: (bytes: Buffer) => { + bytes[600] = 1; + }, + }, + { + change: (bytes: Buffer) => { + bytes[1800] = 1; + }, + }, +])("witness archive refuses unsafe fields, wrong contents and hidden members", ({ + change, +}) => { + expect(() => + verifyNativeComposeStorageWitnessArchive({ + marker, + archive: changedHeader(change), + }) + ).toThrow("values omitted"); +}); + +test.each([ + 0, 512, 1536, 2049, 8704, +])("witness archive length %s fails its bounded exact-member contract", (length) => { + expect(() => + verifyNativeComposeStorageWitnessArchive({ + marker, + archive: new Uint8Array(length), + }) + ).toThrow("values omitted"); +}); + +test("witness archive checksum corruption and duplicate member both refuse", () => { + const archive = Buffer.from(encodeNativeComposeStorageWitnessArchive(marker)); + archive[108] = 49; + expect(() => + verifyNativeComposeStorageWitnessArchive({ marker, archive }) + ).toThrow("values omitted"); + const duplicate = Buffer.concat([ + Buffer.from(encodeNativeComposeStorageWitnessArchive(marker)).subarray( + 0, + 1024 + ), + Buffer.from(encodeNativeComposeStorageWitnessArchive(marker)), + ]); + expect(() => + verifyNativeComposeStorageWitnessArchive({ marker, archive: duplicate }) + ).toThrow("values omitted"); +}); diff --git a/tests/native-compose-storage-witness.test.ts b/tests/native-compose-storage-witness.test.ts new file mode 100644 index 00000000..3ea80db1 --- /dev/null +++ b/tests/native-compose-storage-witness.test.ts @@ -0,0 +1,605 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type NativeComposeGeneration, + type NativeComposeGenerationStore, + type NativeComposeMutation, + openNativeComposeGenerationStore, +} from "../src/lib/native-compose-generation.ts"; +import { mergeNativeComposeRetainedVolumes } from "../src/lib/native-compose-retained-storage.ts"; +import { + enrollNativeComposeStorageWitness, + type NativeComposeStorageWitnessEnrollment, + type NativeComposeStorageWitnessReference, + prepareNativeComposeStorageWitness, + verifyNativeComposeStorageWitness, +} from "../src/lib/native-compose-storage-witness.ts"; + +const roots: string[] = []; +const stores: NativeComposeGenerationStore[] = []; +const engineId = "d".repeat(64); +const volume = { + name: "owned_data", + storage: "data", + createdAt: "2026-10-08T12:00:00Z", +}; +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.close())); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +test("reused and copied enrollment capabilities cannot authorize a second seed", async () => { + const store = await fixture(); + let seeds = 0; + let archive: Uint8Array = new Uint8Array(); + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, generation); + const seed = async (bytes: Uint8Array) => { + seeds++; + archive = bytes; + }; + const observe = async () => ({ volume, archive }); + await enrollNativeComposeStorageWitness({ enrollment, seed, observe }); + await expect( + enrollNativeComposeStorageWitness({ enrollment, seed, observe }) + ).rejects.toThrow("values omitted"); + await expect( + enrollNativeComposeStorageWitness({ + enrollment: { ...enrollment }, + seed, + observe, + }) + ).rejects.toThrow("values omitted"); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(seeds).toBe(1); +}); + +test("failed initial admission creates no expectation or seed capability", async () => { + const store = await fixture(); + let admissions = 0; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: { name: volume.name, storage: volume.storage }, + admission: "initial-create", + assertAdmission: async () => { + admissions++; + throw new Error("existing volume"); + }, + }); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(admissions).toBe(1); + expect(await Bun.file(join(slot(store), "expectation.json")).exists()).toBe( + false + ); +}); + +test.each([ + "selection", + "journal", +] as const)("%s drift during the seed observation preserves incomplete enrollment", async (change) => { + const store = await fixture(); + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, generation); + let archive: Uint8Array = new Uint8Array(); + await enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + archive = bytes; + }, + observe: async () => { + if (change === "journal") { + await writeFile( + join(slot(store), "expectation.json"), + "private-invalid-canary", + { mode: 0o600 } + ); + } + return { + volume: + change === "selection" + ? { ...volume, storage: "rebound" } + : volume, + archive, + }; + }, + }); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + false + ); + expect((await store.loadCurrent()).pending).not.toBeNull(); +}); + +test.each([ + false, + true, +])("explicit adoption requires its original exact birth (changed=%s)", async (changed) => { + const store = await fixture(); + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + const result = mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: { name: volume.name, storage: volume.storage }, + admission: "explicit-adoption", + originalVolume: volume, + assertAdmission: async () => {}, + }); + let archive: Uint8Array = new Uint8Array(); + const reference = await enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + archive = bytes; + }, + observe: async () => ({ + volume: changed + ? { ...volume, createdAt: "2026-10-08T12:00:01Z" } + : volume, + archive, + }), + }); + await verifyNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + observe: async () => ({ volume, archive }), + }); + return { value: 0, outcome: "complete" }; + }, + }); + if (changed) { + await expect(result).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_UNCERTAIN", + }); + } else { + expect(await result).toEqual({ value: 0, outcome: "complete" }); + } + }); + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + !changed + ); +}); +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-storage-witness-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await Bun.write( + join(root, ".hack", "hack.project.json"), + JSON.stringify({ schema_version: 1, name: "witness" }) + ); + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + }); + stores.push(store); + return store; +} +async function publish(mutation: NativeComposeMutation) { + const reservation = mutation.reserveGeneration(); + return await mutation.publish({ + reservation, + profiles: [], + inputRevision: "a".repeat(64), + assertFresh: async () => {}, + composeJson: JSON.stringify({ + name: reservation.identity.composeProject, + services: { + app: { + image: "synthetic:1", + labels: { + "io.hack.native-config.version": "1", + "io.hack.native-config.instance": + reservation.identity.composeProject, + "io.hack.native-config.owner": reservation.identity.ownerToken, + "io.hack.native-config.generation": reservation.generationId, + "io.hack.native-config.workload": "service", + }, + }, + }, + }), + }); +} +function slot(store: NativeComposeGenerationStore) { + return join( + store.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + store.identity.instanceId, + "storage-witnesses", + createHash("sha256").update(volume.name).digest("hex") + ); +} +async function prepare( + mutation: NativeComposeMutation, + generation: NativeComposeGeneration +) { + return await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: { name: volume.name, storage: volume.storage }, + admission: "initial-create", + assertAdmission: async () => {}, + }); +} +async function active() { + const store = await fixture(); + let archive: Uint8Array = new Uint8Array(); + const captured: { reference: NativeComposeStorageWitnessReference | null } = { + reference: null, + }; + const generation = await store.withMutation(async (mutation) => { + const current = await publish(mutation); + await mutation.runEffect({ + generation: current, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, current); + expect( + await Bun.file(join(slot(store), "enrolled.json")).exists() + ).toBe(false); + captured.reference = await enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + expect( + await Bun.file(join(slot(store), "expectation.json")).exists() + ).toBe(true); + expect( + (await lstat(join(slot(store), "expectation.json"))).mode & 0o777 + ).toBe(0o600); + archive = bytes; + }, + observe: async () => ({ volume, archive }), + }); + return { value: 0, outcome: "complete" }; + }, + }); + return current; + }); + if (!captured.reference) { + throw new Error("Expected enrolled fixture"); + } + return { store, generation, archive, reference: captured.reference }; +} + +test("durable expectation precedes seed, and completed read-only verification preserves both files", async () => { + const { store, generation, archive, reference } = await active(); + const before = await Promise.all([ + readFile(join(slot(store), "expectation.json")), + readFile(join(slot(store), "enrolled.json")), + ]); + await store.withMutation(async (mutation) => { + await verifyNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + observe: async () => ({ volume, archive }), + }); + }); + expect( + await Promise.all([ + readFile(join(slot(store), "expectation.json")), + readFile(join(slot(store), "enrolled.json")), + ]) + ).toEqual(before); + expect(Object.isFrozen(reference)).toBe(true); + expect(Object.isFrozen(reference.volume)).toBe(true); +}); + +test.each([ + "missing", + "wrong", +] as const)("same-label SAME-CreatedAt empty/replaced witness %s refuses before workload without reseeding", async (change) => { + const { store, generation, archive, reference } = await active(); + await store.withMutation(async (mutation) => { + await mutation.runEffect({ + generation, + operation: "down", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => ({ value: 0, outcome: "complete" }), + }); + }); + // PR200 metadata observes exactly the same facts; it cannot distinguish this replacement. + expect( + mergeNativeComposeRetainedVolumes({ + retained: [volume], + observed: [{ ...volume }], + }) + ).toEqual([volume]); + const replaced = + change === "missing" ? new Uint8Array(2048) : new Uint8Array(archive); + if (change === "wrong") { + replaced[550] = (replaced[550] ?? 0) ^ 1; + } + let workloads = 0; + let probes = 0; + const before = await readFile(join(slot(store), "expectation.json")); + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + await verifyNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + observe: async () => { + probes++; + return { volume: { ...volume }, archive: replaced }; + }, + }); + workloads++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(workloads).toBe(0); + expect(probes).toBe(1); + expect(await readFile(join(slot(store), "expectation.json"))).toEqual(before); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); +}); + +test("a seed failure consumes capability and retains expectation with no completion", async () => { + const store = await fixture(); + let seeds = 0; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, generation); + const attempt = () => + enrollNativeComposeStorageWitness({ + enrollment, + seed: async () => { + seeds++; + throw new Error("synthetic write interruption"); + }, + observe: async () => { + throw new Error("Must not observe"); + }, + }); + await expect(attempt()).rejects.toThrow("values omitted"); + await expect(attempt()).rejects.toThrow("values omitted"); + return { value: 17, outcome: "uncertain" }; + }, + }) + ).resolves.toEqual({ value: 17, outcome: "uncertain" }); + }); + expect(seeds).toBe(1); + expect(await Bun.file(join(slot(store), "expectation.json")).exists()).toBe( + true + ); + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + false + ); + expect((await store.loadCurrent()).pending).not.toBeNull(); +}); + +test.each([ + false, + true, +])("interrupted expectation (marker written=%s) cannot be replayed after saved stop", async (written) => { + const store = await fixture(); + let savedArchive: Uint8Array = new Uint8Array(); + let oldCapability: NativeComposeStorageWitnessEnrollment | null = null; + let seeds = 0; + const generation = await store.withMutation(async (mutation) => { + const current = await publish(mutation); + await mutation.runEffect({ + generation: current, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + oldCapability = await prepare(mutation, current); + if (written) { + await expect( + enrollNativeComposeStorageWitness({ + enrollment: oldCapability, + seed: async (bytes) => { + seeds++; + savedArchive = bytes; + }, + observe: async () => { + throw new Error("Interrupted before completion"); + }, + }) + ).rejects.toThrow(); + } + return { value: 1, outcome: "uncertain" }; + }, + }); + return current; + }); + if (!oldCapability) { + throw new Error("Expected retained capability"); + } + await expect( + enrollNativeComposeStorageWitness({ + enrollment: oldCapability, + seed: async () => { + seeds++; + }, + observe: async () => ({ volume, archive: savedArchive }), + }) + ).rejects.toThrow(); + const before = await readFile(join(slot(store), "expectation.json")); + await store.withMutation(async (mutation) => { + await mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => ({ value: 0, outcome: "complete" }), + }); + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + await prepare(mutation, generation); + seeds++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(seeds).toBe(written ? 1 : 0); + expect(await readFile(join(slot(store), "expectation.json"))).toEqual(before); + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + false + ); +}); + +test.each([ + "run", + "down", +] as const)("%s cannot admit a seed or acquire private admission inputs", async (operation) => { + const store = await fixture(); + let admissions = 0; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await expect( + mutation.runEffect({ + generation, + operation, + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume, + admission: "initial-create", + assertAdmission: async () => { + admissions++; + }, + }); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(admissions).toBe(0); +}); + +test.each([ + "expectation", + "completion", + "directory", + "engine", +] as const)("read-only witness verification refuses changed %s identity before observation", async (changed) => { + const { store, generation, archive, reference } = await active(); + if (changed === "expectation") { + const file = join(slot(store), "expectation.json"); + await rename(file, `${file}.old`); + await symlink(`${file}.old`, file); + } else if (changed === "completion") { + await rename( + join(slot(store), "enrolled.json"), + join(slot(store), "enrolled.old") + ); + } else if (changed === "directory") { + await rename(slot(store), `${slot(store)}.old`); + await mkdir(slot(store), { mode: 0o700 }); + } + let probes = 0; + await store.withMutation(async (mutation) => { + await expect( + verifyNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId: changed === "engine" ? "e".repeat(64) : engineId, + reference, + observe: async () => { + probes++; + return { volume, archive }; + }, + }) + ).rejects.toThrow(); + }); + expect(probes).toBe(0); +}); From 0908d14f97356ce132766e7db432fa29f6fc5d60 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 09:29:43 -0400 Subject: [PATCH 04/23] test: verify storage witness control parameters --- tests/native-compose-storage-witness-codec.test.ts | 1 + tests/native-compose-storage-witness.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/native-compose-storage-witness-codec.test.ts b/tests/native-compose-storage-witness-codec.test.ts index 908a3218..d6ba2391 100644 --- a/tests/native-compose-storage-witness-codec.test.ts +++ b/tests/native-compose-storage-witness-codec.test.ts @@ -98,6 +98,7 @@ test.each([ test.each([ 0, 512, 1536, 2049, 8704, ])("witness archive length %s fails its bounded exact-member contract", (length) => { + expect(typeof length).toBe("number"); expect(() => verifyNativeComposeStorageWitnessArchive({ marker, diff --git a/tests/native-compose-storage-witness.test.ts b/tests/native-compose-storage-witness.test.ts index 3ea80db1..d9709dc0 100644 --- a/tests/native-compose-storage-witness.test.ts +++ b/tests/native-compose-storage-witness.test.ts @@ -166,6 +166,7 @@ test.each([ false, true, ])("explicit adoption requires its original exact birth (changed=%s)", async (changed) => { + expect(typeof changed).toBe("boolean"); const store = await fixture(); await store.withMutation(async (mutation) => { const generation = await publish(mutation); @@ -458,6 +459,7 @@ test.each([ false, true, ])("interrupted expectation (marker written=%s) cannot be replayed after saved stop", async (written) => { + expect(typeof written).toBe("boolean"); const store = await fixture(); let savedArchive: Uint8Array = new Uint8Array(); let oldCapability: NativeComposeStorageWitnessEnrollment | null = null; From 3bce0a363528bd79f04b287da6776f054984ceb0 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 09:43:08 -0400 Subject: [PATCH 05/23] fix: fence storage witness async authority and archive numbers --- .../native-compose-storage-witness-codec.ts | 31 ++- src/lib/native-compose-storage-witness.ts | 31 +-- ...tive-compose-storage-witness-codec.test.ts | 16 ++ tests/native-compose-storage-witness.test.ts | 182 +++++++++++++++++- 4 files changed, 235 insertions(+), 25 deletions(-) diff --git a/src/lib/native-compose-storage-witness-codec.ts b/src/lib/native-compose-storage-witness-codec.ts index 240f7889..a20314c5 100644 --- a/src/lib/native-compose-storage-witness-codec.ts +++ b/src/lib/native-compose-storage-witness-codec.ts @@ -5,7 +5,6 @@ const MARKER = /^\.hack-storage-[a-f0-9]{64}\.witness$/; const TOKEN = /^[a-f0-9]{64}$/; const BLOCK = 512; const ARCHIVE_LIMIT = 8192; -const OCTAL = /^[0-7]+$/; const CONTENT_PREFIX = "hack-native-storage-witness-v1\n"; /** A private random leaf and token; never include either in public reports or argv. */ @@ -91,19 +90,31 @@ function field(header: Buffer, offset: number, length: number): string { } function octal(header: Buffer, offset: number, length: number): number { - const value = header - .subarray(offset, offset + length) - .toString("ascii") - .replaceAll("\0", "") - .trim(); - if (!OCTAL.test(value)) { + const bytes = header.subarray(offset, offset + length); + const nul = bytes.indexOf(0); + let end = nul === -1 ? bytes.length : nul; + if (nul !== -1 && bytes.subarray(nul + 1).some((byte) => byte !== 32)) { + return refuseNativeComposeStorageWitness(); + } + if (nul === -1) { + while (end > 0 && bytes[end - 1] === 32) { + end--; + } + } + if (end === 0) { return refuseNativeComposeStorageWitness(); } - const parsed = Number.parseInt(value, 8); - if (!Number.isSafeInteger(parsed)) { + let value = 0; + for (const byte of bytes.subarray(0, end)) { + if (byte < 48 || byte > 55) { + return refuseNativeComposeStorageWitness(); + } + value = value * 8 + byte - 48; + } + if (!Number.isSafeInteger(value)) { return refuseNativeComposeStorageWitness(); } - return parsed; + return value; } /** diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index 96614131..f8169f51 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -477,7 +477,8 @@ export async function enrollNativeComposeStorageWitness(opts: { readonly archive: Uint8Array; }>; }): Promise { - const selected = enrollments.get(opts.enrollment); + const { enrollment, seed, observe } = opts; + const selected = enrollments.get(enrollment); if (!selected || selected.consumed) { return refuse(); } @@ -505,10 +506,10 @@ export async function enrollNativeComposeStorageWitness(opts: { checkDirectoryAnchors(held, selected); const saved = await checkedExpectation(held, selected.anchor); await selected.assertAdmission(); - await check(); await checkedExpectation(held, selected.anchor); - await opts.seed(encodeNativeComposeStorageWitnessArchive(saved.marker)); - const observed = await opts.observe(); + await check(); + await seed(encodeNativeComposeStorageWitnessArchive(saved.marker)); + const observed = await observe(); const volume = parseVolume(observed.volume); if ( volume.name !== saved.binding.name || @@ -521,8 +522,8 @@ export async function enrollNativeComposeStorageWitness(opts: { marker: saved.marker, archive: observed.archive, }); - await check(); await checkedExpectation(held, selected.anchor); + await check(); const text = JSON.stringify({ version: 1, expectationHash: selected.anchor.hash, @@ -544,6 +545,7 @@ export async function enrollNativeComposeStorageWitness(opts: { return refuse(); } await recheckDirectories(held); + await check(); return Object.freeze({ version: 1, volume: Object.freeze({ ...volume }), @@ -570,16 +572,17 @@ export async function verifyNativeComposeStorageWitness(opts: { readonly archive: Uint8Array; }>; }): Promise { + const { authority, generation, engineId, observe } = opts; if (!referenceValid(opts.reference)) { return refuse(); } const reference = structuredClone(opts.reference); return await runNativeComposeMaterialAction({ - authority: opts.authority, + authority, run: async () => { const current = await assertNativeComposeMaterialAuthority({ - authority: opts.authority, - generation: opts.generation, + authority, + generation, phase: "inspect", }); const held = await directories({ @@ -610,10 +613,10 @@ export async function verifyNativeComposeStorageWitness(opts: { } }; await readCompletion(); - if (!matchesBinding(saved.binding, current, opts.engineId)) { + if (!matchesBinding(saved.binding, current, engineId)) { return refuse(); } - const observed = await opts.observe(saved.marker.name); + const observed = await observe(saved.marker.name); if (!sameVolume(parseVolume(observed.volume), reference.volume)) { return refuse(); } @@ -623,15 +626,15 @@ export async function verifyNativeComposeStorageWitness(opts: { }); await checkedExpectation(held, reference.expectation); await readCompletion(); + await recheckDirectories(held); const latest = await assertNativeComposeMaterialAuthority({ - authority: opts.authority, - generation: opts.generation, + authority, + generation, phase: "inspect", }); - if (!matchesBinding(saved.binding, latest, opts.engineId)) { + if (!matchesBinding(saved.binding, latest, engineId)) { refuse(); } - await recheckDirectories(held); } finally { await close(held); } diff --git a/tests/native-compose-storage-witness-codec.test.ts b/tests/native-compose-storage-witness-codec.test.ts index d6ba2391..da001ce4 100644 --- a/tests/native-compose-storage-witness-codec.test.ts +++ b/tests/native-compose-storage-witness-codec.test.ts @@ -124,3 +124,19 @@ test("witness archive checksum corruption and duplicate member both refuse", () verifyNativeComposeStorageWitnessArchive({ marker, archive: duplicate }) ).toThrow("values omitted"); }); + +test.each([ + { byte: 0x80 }, + { byte: 0xb0 }, + { byte: 0 }, + { byte: 32 }, +])("numeric header raw bytes cannot be normalized into valid octal", ({ + byte, +}) => { + const archive = changedHeader((bytes) => { + bytes[100] = byte; + }); + expect(() => + verifyNativeComposeStorageWitnessArchive({ marker, archive }) + ).toThrow("values omitted"); +}); diff --git a/tests/native-compose-storage-witness.test.ts b/tests/native-compose-storage-witness.test.ts index d9709dc0..bebe87c8 100644 --- a/tests/native-compose-storage-witness.test.ts +++ b/tests/native-compose-storage-witness.test.ts @@ -1,5 +1,7 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; import { lstat, mkdir, @@ -79,6 +81,184 @@ test("reused and copied enrollment capabilities cannot authorize a second seed", expect(seeds).toBe(1); }); +test("enrollment captures original callbacks before admission can replace them", async () => { + const store = await fixture(); + let originalSeeds = 0; + let originalObservations = 0; + let replacements = 0; + let archive: Uint8Array = new Uint8Array(); + const mutable: { + enrollment: NativeComposeStorageWitnessEnrollment; + seed: (bytes: Uint8Array) => Promise; + observe: () => Promise<{ volume: typeof volume; archive: Uint8Array }>; + } = { + enrollment: {}, + seed: async (bytes) => { + originalSeeds++; + archive = bytes; + }, + observe: async () => { + originalObservations++; + return { volume, archive }; + }, + }; + let replace = false; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + mutable.enrollment = await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: { name: volume.name, storage: volume.storage }, + admission: "initial-create", + assertAdmission: async () => { + if (replace) { + mutable.seed = async (bytes) => { + replacements++; + archive = bytes; + }; + mutable.observe = async () => { + replacements++; + return { volume, archive }; + }; + } + }, + }); + replace = true; + await enrollNativeComposeStorageWitness(mutable); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(originalSeeds).toBe(1); + expect(originalObservations).toBe(1); + expect(replacements).toBe(0); +}); + +test.each([ + { change: "callback" }, + { change: "bindings" }, +])("verification captures original inputs before its first await", async ({ + change, +}) => { + const { store, generation, archive, reference } = await active(); + let original = 0; + let substituted = 0; + await store.withMutation(async (mutation) => { + const mutable = { + authority: mutation.materialAuthority, + generation, + engineId, + reference, + observe: async () => { + original++; + return { volume, archive }; + }, + }; + const verifying = verifyNativeComposeStorageWitness(mutable); + mutable.observe = async () => { + substituted++; + return { volume, archive }; + }; + if (change === "bindings") { + mutable.authority = {}; + mutable.engineId = "substituted"; + } + await verifying; + }); + expect(original).toBe(1); + expect(substituted).toBe(0); +}); + +test.each([ + { boundary: "seed", read: 2 }, + { boundary: "completion", read: 3 }, +])("revoked effect authority refuses at the final filesystem boundary", async ({ + boundary, + read, +}) => { + const store = await fixture(); + const waiting = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const work: { result: Promise | null } = { result: null }; + let hit = false; + let count = 0; + let seeds = 0; + let archive: Uint8Array = new Uint8Array(); + const originalOpen = fs.open; + const opened = spyOn(fs, "open").mockImplementation( + async (...args: Parameters) => { + const file = await originalOpen(...args); + if ( + args[0] === join(slot(store), "expectation.json") && + typeof args[1] === "number" && + (args[1] & constants.O_CREAT) === 0 + ) { + count++; + if (count === read) { + hit = true; + waiting.resolve(); + await resume.promise; + } + } + return file; + } + ); + const guard = setTimeout(() => { + waiting.resolve(); + resume.resolve(); + }, 3000); + try { + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, generation); + work.result = enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + seeds++; + archive = bytes; + }, + observe: async () => ({ volume, archive }), + }).then( + () => true, + () => false + ); + await waiting.promise; + return { value: 1, outcome: "uncertain" }; + }, + }); + // Returning from runEffect revokes effectOperation/activePending. The + // registered material action still owns its process/files until settled. + resume.resolve(); + expect(await work.result).toBe(false); + }); + expect(hit).toBe(true); + expect(seeds).toBe(boundary === "seed" ? 0 : 1); + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + false + ); + expect((await store.loadCurrent()).pending).not.toBeNull(); + } finally { + waiting.resolve(); + resume.resolve(); + clearTimeout(guard); + await work.result; + opened.mockRestore(); + } +}); + test("failed initial admission creates no expectation or seed capability", async () => { const store = await fixture(); let admissions = 0; From c20e48cf8cbc0667da3bc61c1cec24c39d9f11b3 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 10:16:12 -0400 Subject: [PATCH 06/23] feat: bind storage witness intent to required generation receipts --- .../native-compose-storage-witness.md | 48 +- src/lib/native-compose-command.ts | 16 +- src/lib/native-compose-generation.ts | 307 +++++++++++- .../native-compose-storage-witness-receipt.ts | 213 +++++++++ .../native-compose-storage-witness-state.ts | 166 +++++++ src/lib/native-compose-storage-witness.ts | 94 ++-- ...ve-compose-storage-witness-command.test.ts | 136 ++++++ tests/native-compose-storage-witness.test.ts | 447 +++++++++++++++++- 8 files changed, 1339 insertions(+), 88 deletions(-) create mode 100644 src/lib/native-compose-storage-witness-receipt.ts create mode 100644 src/lib/native-compose-storage-witness-state.ts create mode 100644 tests/native-compose-storage-witness-command.test.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 6f205782..4f1404c5 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -1,9 +1,9 @@ # Native Compose storage witness foundation -This is an internal protocol foundation. The CLI still uses the metadata guard -described in [ownership](native-compose-ownership.md). It does not yet enroll or -enforce these witness references. Identical volume labels, name and `CreatedAt` -remain insufficient to detect an empty replacement in the shipping path. +This is an internal protocol foundation. Ordinary CLI instances still use the +metadata guard described in [ownership](native-compose-ownership.md). The CLI does +not enroll volume witnesses. Identical volume labels, name and `CreatedAt` remain +insufficient to detect an empty replacement in that shipping path. The candidate protocol binds a random marker leaf and independent 256-bit token to the engine, instance owner, physical volume name, logical storage, admitting @@ -16,7 +16,11 @@ are intact, and a copy containing the original witness is outside this guarantee opaque material authority. The caller must prove either that the selected new volume is genuinely absent or that an explicit adoption selected an exact stopped volume. Adoption records the current birth but cannot prove historical continuity. -It creates an immutable private expectation with exclusive publication, file +The generation owner first publishes a required version-three receipt containing +an `Expected` intent bound to the exact pending generation and operation token. +This precedes even creation of the witness slot, so a crash with an empty or absent +slot cannot fall back to an older receipt. It then creates an immutable private +expectation with exclusive publication, file synchronization and directory synchronization before any provisioning or seed callback. An existing slot, including an empty interrupted slot, refuses. The expectation is never deleted or replaced to admit a retry. @@ -27,8 +31,10 @@ path, symlink or special file rather than overwrite it. After one seed attempt, fresh read-only observation must match the exact marker and volume metadata. Only then does the owner append and synchronize an immutable completion record. The private reference pins both directory identities and both journal file identities -and hashes. The current generation and pending token are rechecked around each -publication. Copied, reused or revoked capabilities cannot authorize another seed. +and hashes. A fresh read-only proof precedes attachment of that exact reference as +`Enrolled` in the version-three receipt. The current generation and pending token +are rechecked after the final filesystem await immediately before publication. +Copied, reused or revoked capabilities cannot authorize another seed. `verifyNativeComposeStorageWitness` reads and compares those records and the observed marker. It does not create, write, delete or repair storage. Missing or @@ -37,23 +43,33 @@ before the caller's workload action. A pending expectation after interruption remains uncertain even if a matching marker was written. There is no automatic promotion or seed replay. Explicit reconciliation of such an expectation is a separate recovery contract; saved resource stop must retain its witness anchor. +Explicit `down --recover` may stop saved engine resources, but returns incomplete +with `Expected` preserved, the pending generation retained and dependent ownership +retirement skipped. A stopped engine alone does not complete enrollment. The archive codec accepts one bounded regular USTAR member with the exact random leaf, permissions and token. It rejects encoded links, special files, extensions, -extra members, bad checksums and nonzero padding, and extracts nothing on the host. +extra members, noncanonical raw numeric fields, bad checksums and nonzero padding, +and extracts nothing on the host. Rejecting an encoded hardlink is not a claim about the daemon's file link count. -Activation requires a new mandatory receipt version with immutable references and -an enrollment intent anchored before effects. Existing v1/v2 readers must refuse -that format. Resume must never infer enrollment from metadata, scan arbitrary -marker names, or auto-enroll a legacy volume. Private state for failed, pending and -completed generations must remain monotonic through stop and recovery. +The required version-three decoder rejects missing, unknown or duplicate witness +entries, mismatched retained births and detached `Expected` bindings. Existing +v1/v2 readers refuse that format. An enrolled store requires fresh marker proof +before and after startup/run, including both final receipt boundaries. Missing +carrier authority refuses before a workload effect. The CLI deliberately refuses +witness-bearing startup, run and exec until the carrier is qualified. Resume +never infers enrollment from metadata, scans arbitrary marker names or auto-enrolls +a legacy volume. Failed, pending and completed witness state remains monotonic +through stop and recovery; storage capture never downgrades a v3 receipt. A stopped, unstarted owned carrier with a read-only volume mount is a possible Docker read transport. It would need an exact already-selected local image ID, no pull or start, `volume-nocopy`, complete accounting for image-declared volumes, bounded output and deadlines, exact carrier ownership and saved cleanup. Docker copy alone does not provide an exclusive marker write. That enrollment boundary, -carrier creation uncertainty, normal and explicit recovery, compiled CLI coverage -and real engine acceptance remain required gates. No carrier or helper download is -implemented by this foundation. +carrier creation uncertainty, explicit interrupted-enrollment reconciliation, +compiled CLI coverage and real engine acceptance remain required gates. No carrier +or helper download is implemented by this foundation. Store and source-CLI tests +use synthetic observation ports and engine transports; they do not qualify Docker +write or archive semantics. diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 3dc5a8d2..70125dd2 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -575,6 +575,9 @@ async function savedCommand(opts: { await store.recoverInterruptedLock(); } const state = await store.loadCurrent(); + if (options.operation === "exec" && state.storageWitnesses !== null) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_STATE"); + } const pending = await store.loadPending(); const generation = options.operation === "down" && options.recover && pending @@ -819,6 +822,9 @@ async function prepareSavedStop(opts: { const { store, generation, document, selection, saved, options, signal } = opts; const current = await store.loadCurrent(); + if (current.storageWitnessesPending && !options.recover) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } const hasDownHooks = Object.hasOwn(document, "x-hack-native-down-hooks"); let engineStopped = false; let storage: readonly NativeComposeRetainedVolume[] | null = null; @@ -922,7 +928,11 @@ async function finalizeNativeComposeStop(opts: { readonly signal: AbortSignal; readonly recover?: boolean; }): Promise { - if ((await opts.store.loadCurrent()).beforeHooksPending) { + const current = await opts.store.loadCurrent(); + if (current.storageWitnessesPending) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } + if (current.beforeHooksPending) { throw new HackCliError({ code: "E_LIFECYCLE_FAILED", message: @@ -1822,6 +1832,10 @@ function assertSelectedWorkloads(inputs: AcquiredComposeInputs): void { function assertStartupAvailable( current: Awaited> ) { + // The content carrier is deliberately unactivated. Existing witness receipts cannot fall back to metadata. + if (current.storageWitnesses !== null) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_STATE"); + } if (current.beforeHooksPending) { throw new HackCliError({ code: "E_LIFECYCLE_FAILED", diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index eee64b02..248616b3 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -27,6 +27,15 @@ import { nativeComposeRetainedVolumesValid, } from "./native-compose-retained-storage.ts"; import { projectNativeComposeOneOff } from "./native-compose-run-projection.ts"; +import { createNativeComposeStorageWitnessReceiptProtocol } from "./native-compose-storage-witness-receipt.ts"; +import { + type NativeComposeStorageWitnessIntent, + type NativeComposeStorageWitnessReference, + type NativeComposeStorageWitnessState, + nativeComposeStorageWitnessIntentValid, + nativeComposeStorageWitnessReferenceValid, + nativeComposeStorageWitnessStatesValid, +} from "./native-compose-storage-witness-state.ts"; // biome-ignore lint/performance/noBarrelFile: Preserve the generation store's public topology contract after the single-parser extraction. export { @@ -117,6 +126,59 @@ const materialActions = new WeakMap< object, (run: () => Promise) => Promise >(); +export type NativeComposeStorageWitnessPublication = + | { + readonly state: "expected"; + readonly intent: NativeComposeStorageWitnessIntent; + } + | { + readonly state: "enrolled"; + readonly reference: NativeComposeStorageWitnessReference; + readonly verify: () => Promise; + }; +const witnessPublications = new WeakMap< + object, + ( + generation: NativeComposeGeneration, + input: NativeComposeStorageWitnessPublication + ) => Promise +>(); +/** Only the live generation owner can durably arm an expectation, before its private slot exists. */ +export async function armNativeComposeStorageWitnessIntent(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly intent: NativeComposeStorageWitnessIntent; +}): Promise { + const { authority, generation } = opts; + if (!nativeComposeStorageWitnessIntentValid(opts.intent)) { + refuse(); + } + const intent = structuredClone(opts.intent); + const publish = witnessPublications.get(authority) ?? refuse(); + await runNativeComposeMaterialAction({ + authority, + run: async () => await publish(generation, { state: "expected", intent }), + }); +} +/** Append the exact completed extension; publication never repairs or reenrolls an Expected entry. */ +export async function publishNativeComposeStorageWitnessEnrollment(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly reference: NativeComposeStorageWitnessReference; + readonly verify: () => Promise; +}): Promise { + const { authority, generation, verify } = opts; + if (!nativeComposeStorageWitnessReferenceValid(opts.reference)) { + return refuse(); + } + const reference = structuredClone(opts.reference); + const publish = witnessPublications.get(authority) ?? refuse(); + await runNativeComposeMaterialAction({ + authority, + run: async () => + await publish(generation, { state: "enrolled", reference, verify }), + }); +} /** The generation lease remains held until owned material work has settled, even after revocation. */ export async function runNativeComposeMaterialAction(opts: { readonly authority: NativeComposeMaterialAuthority; @@ -172,6 +234,9 @@ function materialEffectAllowed( if (state.beforeHooks !== null || state.pending === null) { return false; } + if (phase === "retire" && witnessPending(state)) { + return false; + } const startup = operation === "up" || operation === "restart"; if (phase === "effect") { return startup && state.pending.generationId === generationId; @@ -184,6 +249,45 @@ function materialEffectAllowed( state.pending.generationId !== generationId)) ); } +function requireMaterialEffectLive(opts: { + readonly selection: MaterialSelection; + readonly state: Receipt; + readonly effectOperation: NativeComposeOperation | null; + readonly activePending: Receipt["pending"]; + readonly generationId: string; +}): void { + const { selection, state, effectOperation, activePending, generationId } = + opts; + if (["prepare", "source"].includes(selection.phase)) { + return; + } + if ( + !materialEffectAllowed( + selection.phase, + effectOperation, + state, + generationId + ) + ) { + refuse(); + } + if ( + selection.phase !== "inspect" && + (activePending === null || + JSON.stringify(state.pending) !== JSON.stringify(activePending)) + ) { + refuse(); + } +} +function admitWitnessEffect( + operation: NativeComposeOperation, + recover: boolean | undefined, + state: Receipt +): void { + if (witnessPending(state) && !(operation === "down" && recover === true)) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } +} function materialPreparation(opts: { readonly selection: MaterialSelection; readonly state: Receipt; @@ -296,7 +400,7 @@ type HostHookIntent = readonly operation: "down"; }; type Receipt = { - readonly version: 1 | 2; + readonly version: 1 | 2 | 3; readonly identity: NativeComposeIdentity; readonly checkout: CheckoutAnchor; readonly current: GenerationAnchor | null; @@ -306,7 +410,14 @@ type Receipt = { readonly beforeHooks: HostHookIntent | null; /** Version two records monotonic engine birth facts; old readers refuse this receipt. */ readonly storage?: readonly NativeComposeRetainedVolume[]; + /** Required v3 continuity state. Older readers must refuse, never silently ignore it. */ + readonly storageWitnesses?: readonly NativeComposeStorageWitnessState[]; }; +function witnessPending(state: Receipt): boolean { + return ( + state.storageWitnesses?.some((entry) => entry.state === "expected") ?? false + ); +} type FileIdentity = { readonly dev: number; readonly ino: number }; type CheckoutAnchor = FileIdentity & { readonly projectDirectory: FileIdentity; @@ -455,7 +566,8 @@ function requireBeforeHooksAdmission( if ( mode === "saved" || state.pending !== null || - state.beforeHooks !== null + state.beforeHooks !== null || + state.storageWitnesses !== undefined ) { throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); } @@ -479,7 +591,15 @@ function parseReceipt( value, "beforeHooks,checkout,current,identity,pending,stopped,storage,version" ) && - nativeComposeRetainedVolumesValid(value.storage))) && + nativeComposeRetainedVolumesValid(value.storage)) || + (value.version === 3 && + keys( + value, + "beforeHooks,checkout,current,identity,pending,stopped,storage,storageWitnesses,version" + ) && + nativeComposeRetainedVolumesValid(value.storage) && + nativeComposeStorageWitnessStatesValid(value.storageWitnesses) && + receiptWitnessesCorrespond(value))) && identityMatches(value.identity, identity) && checkoutMatches(value.checkout, checkout) && (value.current === null || @@ -494,7 +614,7 @@ function parseReceipt( return refuse(); } let storage: readonly NativeComposeRetainedVolume[] | undefined; - if (value.version === 2) { + if (value.version === 2 || value.version === 3) { if (!nativeComposeRetainedVolumesValid(value.storage)) { return refuse(); } @@ -509,8 +629,62 @@ function parseReceipt( pending: value.pending, beforeHooks: normalizeHostIntent(value.beforeHooks), ...(storage === undefined ? {} : { storage }), + ...(value.version === 3 + ? { + storageWitnesses: + value.storageWitnesses as readonly NativeComposeStorageWitnessState[], + } + : {}), }; } +function expectedWitnessCorresponds( + entry: Extract, + pending: unknown, + storage: readonly NativeComposeRetainedVolume[] +): boolean { + if ( + !pendingValid(pending) || + pending.generationId !== entry.generationId || + pending.token !== entry.pendingToken + ) { + return false; + } + const retained = storage.find((volume) => volume.name === entry.name); + return ( + (!retained || retained.storage === entry.storage) && + (entry.originalVolume === null || + retained?.createdAt === entry.originalVolume.createdAt) + ); +} +function receiptWitnessesCorrespond(value: Record): boolean { + if ( + !( + nativeComposeStorageWitnessStatesValid(value.storageWitnesses) && + nativeComposeRetainedVolumesValid(value.storage) + ) + ) { + return false; + } + for (const entry of value.storageWitnesses) { + if (entry.state === "expected") { + if (!expectedWitnessCorresponds(entry, value.pending, value.storage)) { + return false; + } + } else { + const retained = value.storage.find( + (volume) => volume.name === entry.name + ); + if ( + !retained || + retained.storage !== entry.storage || + retained.createdAt !== entry.reference.volume.createdAt + ) { + return false; + } + } + } + return true; +} function checkoutMatches(value: unknown, expected: CheckoutAnchor): boolean { if ( !( @@ -680,6 +854,18 @@ export type NativeComposeEffectOptions = { readonly assertOwned: () => Promise; /** Reuse the exact preceding ownership observation; never perform extra probes here. */ readonly captureStorage?: () => readonly NativeComposeRetainedVolume[]; + /** Required for previously enrolled v3 startup/run. Carrier remains unactivated in the CLI. */ + readonly storageWitnesses?: { + readonly engineId: string; + readonly observe: (selection: { + readonly name: string; + readonly storage: string; + readonly markerName: string; + }) => Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly archive: Uint8Array; + }>; + }; readonly recoverPending?: boolean; /** Store-derived immutable one-off delivery, verified before/after run effects. */ readonly projection?: NativeComposeRunProjection; @@ -780,6 +966,7 @@ function admitEffect( refuse(); } admitDownHooks(input, state); + admitWitnessEffect(input.operation, input.recoverPending, state); if ( input.afterHooks && input.operation !== "up" && @@ -884,6 +1071,11 @@ export type NativeComposeGenerationStore = { readonly hostHookPhase: HostHookIntent["phase"] | null; /** Nonenumerable private provenance; null is an older receipt with no birth history. */ readonly retainedStorage: readonly NativeComposeRetainedVolume[] | null; + /** Private mandatory continuity state. No marker or reference is serialized publicly. */ + readonly storageWitnesses: + | readonly NativeComposeStorageWitnessState[] + | null; + readonly storageWitnessesPending: boolean; }>; loadPending(): Promise; /** Private values: never serialize/log this object. Use within a saved-generation lease. */ @@ -1125,7 +1317,7 @@ export async function openNativeComposeGenerationStore(opts: { refuse(); } }; - const save = async (state: Receipt) => { + const save = async (state: Receipt, beforePublish?: () => void) => { await receipt(); await assertMutationReceipt(); const temporary = join(instanceRoot, `${token()}.receipt.tmp`); @@ -1138,6 +1330,7 @@ export async function openNativeComposeGenerationStore(opts: { refuse(); } await assertMutationReceipt(); + beforePublish?.(); await rename(temporary, receiptPath); if (mutationReceipt !== null) { mutationReceipt = { dev: stagedInfo.dev, ino: stagedInfo.ino, text }; @@ -1324,7 +1517,9 @@ export async function openNativeComposeGenerationStore(opts: { ) => { if ( JSON.stringify(latest.pending) !== JSON.stringify(pending) || - ((operation !== "down" || hasDownHooks) && latest.beforeHooks !== null) + ((operation !== "down" || hasDownHooks) && + latest.beforeHooks !== null) || + witnessPending(latest) ) { refuse(); } @@ -1347,13 +1542,14 @@ export async function openNativeComposeGenerationStore(opts: { storage.length && JSON.stringify(storage) !== JSON.stringify(state.storage) ) { - await save({ ...state, version: 2, storage }); + await save({ ...state, version: state.version === 3 ? 3 : 2, storage }); } }; const finalizeEffect = async ( input: NativeComposeEffectOptions, pending: Receipt["pending"], - anchor: GenerationAnchor + anchor: GenerationAnchor, + assertWitnesses: () => Promise ) => { if (input.assertFresh) { await assertFresh(input.assertFresh); @@ -1364,6 +1560,7 @@ export async function openNativeComposeGenerationStore(opts: { } await input.assertOwned(); await rememberStorage(input); + await assertWitnesses(); let latest = await receipt(); requireFinalPending( latest, @@ -1382,6 +1579,7 @@ export async function openNativeComposeGenerationStore(opts: { } await input.assertOwned(); await rememberStorage(input); + await assertWitnesses(); latest = await receipt(); requireFinalPending( latest, @@ -1419,8 +1617,18 @@ export async function openNativeComposeGenerationStore(opts: { observed: state.storage, }) : null, + storageWitnesses: state.storageWitnesses + ? structuredClone(state.storageWitnesses) + : null, + storageWitnessesPending: witnessPending(state), }; Object.defineProperty(result, "retainedStorage", { enumerable: false }); + Object.defineProperty(result, "storageWitnesses", { + enumerable: false, + }); + Object.defineProperty(result, "storageWitnessesPending", { + enumerable: false, + }); return result; }, async loadPending() { @@ -1568,6 +1776,13 @@ export async function openNativeComposeGenerationStore(opts: { ) { return refuse(); } + requireMaterialEffectLive({ + selection, + state, + effectOperation, + activePending, + generationId, + }); return Object.freeze({ identity: ownedIdentity, generationId, @@ -1589,6 +1804,55 @@ export async function openNativeComposeGenerationStore(opts: { pendingToken: state.pending?.token ?? null, }); }); + const requireWitnessEffect = ( + generation: NativeComposeGeneration, + intent?: NativeComposeStorageWitnessIntent + ) => { + requireActive(); + if ( + !( + (effectOperation === "up" || effectOperation === "restart") && + activePending !== null && + activePending.generationId === generation.generationId && + (!intent || + (intent.generationId === generation.generationId && + intent.pendingToken === activePending.token)) + ) + ) { + return refuse(); + } + }; + const witnessProtocol = + createNativeComposeStorageWitnessReceiptProtocol({ + authority: materialAuthority, + read: receipt, + save, + requireActive, + requireEffect: requireWitnessEffect, + verifyAuthority: async (generation) => { + await assertNativeComposeMaterialAuthority({ + authority: materialAuthority, + generation, + phase: "effect", + }); + }, + load: async (state) => { + const saved = state.pending ?? state.current ?? refuse(); + return await load(saved.generationId, { + generationId: saved.generationId, + manifestHash: saved.manifestHash, + manifest: saved.manifest, + }); + }, + }); + witnessPublications.set(materialAuthority, witnessProtocol.publish); + const assertWitnesses = async ( + input: NativeComposeEffectOptions + ) => + await witnessProtocol.assert( + input.operation, + input.storageWitnesses + ); const fenceEffectInputs = async ( input: NativeComposeEffectOptions ) => { @@ -1599,6 +1863,7 @@ export async function openNativeComposeGenerationStore(opts: { if (input.assertFresh) { await assertFresh(input.assertFresh); } + await assertWitnesses(input); }; const checkEffect = async ( input: NativeComposeEffectOptions @@ -1658,11 +1923,13 @@ export async function openNativeComposeGenerationStore(opts: { anchor: GenerationAnchor, result: Awaited["effect"]>> ) => { - if (result.outcome !== "complete") { - // Preserve an already verified observation without probing an - // uncertain child or clearing its pending effect intent. + if ( + result.outcome !== "complete" || + witnessPending(await receipt()) + ) { + // Saved recovery may stop engine resources, but unresolved enrollment keeps its anchor. await rememberStorage(input); - return result; + return { value: result.value, outcome: "uncertain" as const }; } const hooks = input.afterHooks ?? input.downHooks?.after; if (hooks) { @@ -1676,7 +1943,12 @@ export async function openNativeComposeGenerationStore(opts: { return { value: after.value, outcome: "uncertain" as const }; } } - await finalizeEffect(input, pending, anchor); + await finalizeEffect( + input, + pending, + anchor, + async () => await assertWitnesses(input) + ); return result; }; const effectPending = ( @@ -1958,7 +2230,13 @@ export async function openNativeComposeGenerationStore(opts: { : undefined, }) : undefined; - const input = Object.freeze({ ...options, downHooks }); + const input = Object.freeze({ + ...options, + downHooks, + storageWitnesses: options.storageWitnesses + ? Object.freeze({ ...options.storageWitnesses }) + : undefined, + }); return await runAction(async () => { requireActive(); let state = await receipt(); @@ -2008,6 +2286,7 @@ export async function openNativeComposeGenerationStore(opts: { active = false; materialAuthorities.delete(materialAuthority); materialActions.delete(materialAuthority); + witnessPublications.delete(materialAuthority); if (actionDone !== null) { await actionDone; } diff --git a/src/lib/native-compose-storage-witness-receipt.ts b/src/lib/native-compose-storage-witness-receipt.ts new file mode 100644 index 00000000..80f11642 --- /dev/null +++ b/src/lib/native-compose-storage-witness-receipt.ts @@ -0,0 +1,213 @@ +import type { + NativeComposeEffectOptions, + NativeComposeGeneration, + NativeComposeMaterialAuthority, + NativeComposeOperation, + NativeComposeStorageWitnessPublication, +} from "./native-compose-generation.ts"; +import { NativeComposeGenerationError } from "./native-compose-private-state.ts"; +import { + mergeNativeComposeRetainedVolumes, + type NativeComposeRetainedVolume, +} from "./native-compose-retained-storage.ts"; +import { + type NativeComposeStorageWitnessIntent, + type NativeComposeStorageWitnessState, + nativeComposeStorageWitnessStatesValid, +} from "./native-compose-storage-witness-state.ts"; + +function refuse(): never { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_STATE"); +} +type State = { + readonly version: 1 | 2 | 3; + readonly stopped: boolean; + readonly storage?: readonly NativeComposeRetainedVolume[]; + readonly storageWitnesses?: readonly NativeComposeStorageWitnessState[]; +}; +/** Decision helper only: every read, write and live effect fence belongs to the existing generation mutation. */ +export function createNativeComposeStorageWitnessReceiptProtocol< + R extends State, +>(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly read: () => Promise; + readonly save: (state: R, beforePublish: () => void) => Promise; + readonly requireActive: () => void; + readonly requireEffect: ( + generation: NativeComposeGeneration, + intent?: NativeComposeStorageWitnessIntent + ) => void; + readonly verifyAuthority: ( + generation: NativeComposeGeneration + ) => Promise; + readonly load: (state: R) => Promise; +}) { + let publishing = false; + const verifiers = new Map Promise>(); + const arm = async ( + generation: NativeComposeGeneration, + intent: NativeComposeStorageWitnessIntent + ) => { + opts.requireEffect(generation, intent); + const state = await opts.read(); + const prior = state.storageWitnesses ?? []; + if ( + prior.some((entry) => entry.name === intent.name) || + prior.length >= 64 + ) { + return refuse(); + } + const observed = state.storage?.find((entry) => entry.name === intent.name); + if (intent.admission === "explicit-adoption" && !state.stopped) { + return refuse(); + } + if (intent.admission === "initial-create" && observed) { + return refuse(); + } + if ( + observed && + JSON.stringify(observed) !== JSON.stringify(intent.originalVolume) + ) { + return refuse(); + } + const storageWitnesses = [ + ...prior, + { ...intent, state: "expected" as const }, + ].sort((a, b) => a.name.localeCompare(b.name)); + if (!nativeComposeStorageWitnessStatesValid(storageWitnesses)) { + return refuse(); + } + const storage = intent.originalVolume + ? mergeNativeComposeRetainedVolumes({ + retained: state.storage ?? [], + observed: [intent.originalVolume], + }) + : (state.storage ?? []); + await opts.save({ ...state, version: 3, storage, storageWitnesses }, () => + opts.requireEffect(generation, intent) + ); + }; + const complete = async ( + generation: NativeComposeGeneration, + input: Extract< + NativeComposeStorageWitnessPublication, + { state: "enrolled" } + > + ) => { + const state = await opts.read(); + const prior = state.storageWitnesses ?? []; + const selected = prior.find( + (entry) => entry.name === input.reference.volume.name + ); + if ( + !selected || + selected.state !== "expected" || + selected.storage !== input.reference.volume.storage + ) { + return refuse(); + } + opts.requireEffect(generation, selected); + await input.verify(); + const latest = await opts.read(); + if (JSON.stringify(latest.storageWitnesses) !== JSON.stringify(prior)) { + return refuse(); + } + const entry: NativeComposeStorageWitnessState = { + ...selected, + state: "enrolled", + reference: input.reference, + }; + const storageWitnesses = prior.map((item) => + item.name === selected.name ? entry : item + ); + if (!nativeComposeStorageWitnessStatesValid(storageWitnesses)) { + return refuse(); + } + const storage = mergeNativeComposeRetainedVolumes({ + retained: latest.storage ?? [], + observed: [input.reference.volume], + }); + await opts.save({ ...latest, version: 3, storage, storageWitnesses }, () => + opts.requireEffect(generation, selected) + ); + verifiers.set(selected.name, input.verify); + }; + const verifyEntry = async ( + entry: NativeComposeStorageWitnessState, + state: R, + carrier: NativeComposeEffectOptions["storageWitnesses"] + ) => { + if (entry.state !== "enrolled") { + return refuse(); + } + const local = verifiers.get(entry.name); + if (local) { + await local(); + return; + } + if (!carrier) { + return refuse(); + } + const generation = await opts.load(state); + const { verifyNativeComposeStorageWitness } = await import( + "./native-compose-storage-witness.ts" + ); + await verifyNativeComposeStorageWitness({ + authority: opts.authority, + generation, + engineId: carrier.engineId, + reference: entry.reference, + observe: async (markerName) => + await carrier.observe({ + name: entry.name, + storage: entry.storage, + markerName, + }), + }); + }; + return { + async publish( + generation: NativeComposeGeneration, + input: NativeComposeStorageWitnessPublication + ): Promise { + opts.requireEffect(generation); + if (publishing) { + return refuse(); + } + publishing = true; + try { + await opts.verifyAuthority(generation); + if (input.state === "expected") { + await arm(generation, input.intent); + } else { + await complete(generation, input); + } + } finally { + publishing = false; + } + }, + async assert( + operation: NativeComposeOperation, + carrier: NativeComposeEffectOptions["storageWitnesses"] + ): Promise { + if (operation === "down") { + return; + } + const state = await opts.read(); + if (state.storageWitnesses?.some((entry) => entry.state === "expected")) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } + for (const entry of state.storageWitnesses ?? []) { + await verifyEntry(entry, state, carrier); + } + const latest = await opts.read(); + if ( + JSON.stringify(latest.storageWitnesses) !== + JSON.stringify(state.storageWitnesses) + ) { + return refuse(); + } + opts.requireActive(); + }, + }; +} diff --git a/src/lib/native-compose-storage-witness-state.ts b/src/lib/native-compose-storage-witness-state.ts new file mode 100644 index 00000000..ddaabde2 --- /dev/null +++ b/src/lib/native-compose-storage-witness-state.ts @@ -0,0 +1,166 @@ +import { isRecord } from "./guards.ts"; +import { keys } from "./native-compose-private-state.ts"; +import { + type NativeComposeRetainedVolume, + nativeComposeRetainedVolumesValid, +} from "./native-compose-retained-storage.ts"; + +const HASH = /^[a-f0-9]{64}$/; +const TOKEN = /^[a-f0-9]{32}$/; +const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; +type DirectoryAnchor = { readonly dev: number; readonly ino: number }; +type Anchor = DirectoryAnchor & { readonly hash: string }; +/** Private immutable extension references. Never include in a public DTO. */ +export type NativeComposeStorageWitnessReference = { + readonly version: 1; + readonly volume: NativeComposeRetainedVolume; + readonly root: DirectoryAnchor; + readonly directory: DirectoryAnchor; + readonly expectation: Anchor; + readonly completion: Anchor; +}; +export type NativeComposeStorageWitnessIntent = { + readonly name: string; + readonly storage: string; + readonly engineId: string; + readonly generationId: string; + readonly pendingToken: string; + readonly admission: "initial-create" | "explicit-adoption"; + readonly originalVolume: NativeComposeRetainedVolume | null; +}; +/** Required v3 state: Expected survives every interruption; only this invocation can enroll. */ +export type NativeComposeStorageWitnessState = + NativeComposeStorageWitnessIntent & + ( + | { readonly state: "expected" } + | { + readonly state: "enrolled"; + readonly reference: NativeComposeStorageWitnessReference; + } + ); + +function directoryValid(value: unknown): value is DirectoryAnchor { + return ( + isRecord(value) && + keys(value, "dev,ino") && + typeof value.dev === "number" && + Number.isSafeInteger(value.dev) && + value.dev >= 0 && + typeof value.ino === "number" && + Number.isSafeInteger(value.ino) && + value.ino > 0 + ); +} +function anchorValid(value: unknown): value is Anchor { + if (!(isRecord(value) && keys(value, "dev,hash,ino"))) { + return false; + } + return ( + directoryValid({ dev: value.dev, ino: value.ino }) && + typeof value.hash === "string" && + HASH.test(value.hash) + ); +} +export function nativeComposeStorageWitnessReferenceValid( + value: unknown +): value is NativeComposeStorageWitnessReference { + return ( + isRecord(value) && + keys(value, "completion,directory,expectation,root,version,volume") && + value.version === 1 && + nativeComposeRetainedVolumesValid([value.volume]) && + directoryValid(value.root) && + directoryValid(value.directory) && + anchorValid(value.expectation) && + anchorValid(value.completion) + ); +} +export function nativeComposeStorageWitnessIntentValid( + value: unknown +): value is NativeComposeStorageWitnessIntent { + if ( + !( + isRecord(value) && + keys( + value, + "admission,engineId,generationId,name,originalVolume,pendingToken,storage" + ) && + nativeComposeRetainedVolumesValid([ + { + name: value.name, + storage: value.storage, + createdAt: "2000-01-01T00:00:00Z", + }, + ]) && + typeof value.engineId === "string" && + ENGINE.test(value.engineId) && + typeof value.generationId === "string" && + TOKEN.test(value.generationId) && + typeof value.pendingToken === "string" && + TOKEN.test(value.pendingToken) + ) + ) { + return false; + } + if (value.admission === "initial-create") { + return value.originalVolume === null; + } + return ( + value.admission === "explicit-adoption" && + nativeComposeRetainedVolumesValid([value.originalVolume]) && + isRecord(value.originalVolume) && + value.originalVolume.name === value.name && + value.originalVolume.storage === value.storage + ); +} +export function nativeComposeStorageWitnessStatesValid( + value: unknown +): value is readonly NativeComposeStorageWitnessState[] { + if (!Array.isArray(value) || value.length === 0 || value.length > 64) { + return false; + } + const names = new Set(); + let engineId: string | null = null; + let previousName = ""; + for (const entry of value) { + if ( + !( + isRecord(entry) && + keys( + entry, + entry.state === "expected" + ? "admission,engineId,generationId,name,originalVolume,pendingToken,state,storage" + : "admission,engineId,generationId,name,originalVolume,pendingToken,reference,state,storage" + ) + ) + ) { + return false; + } + const { state, reference, ...intent } = entry; + if ( + !nativeComposeStorageWitnessIntentValid(intent) || + names.has(intent.name) || + intent.name.localeCompare(previousName) <= 0 || + (engineId !== null && engineId !== intent.engineId) + ) { + return false; + } + names.add(intent.name); + previousName = intent.name; + engineId = intent.engineId; + if ( + state !== "expected" && + !( + state === "enrolled" && + nativeComposeStorageWitnessReferenceValid(reference) && + reference.volume.name === intent.name && + reference.volume.storage === intent.storage && + (intent.originalVolume === null || + reference.volume.createdAt === intent.originalVolume.createdAt) + ) + ) { + return false; + } + } + return true; +} diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index f8169f51..65ff2610 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -3,10 +3,12 @@ import { mkdir, opendir } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "./guards.ts"; import { + armNativeComposeStorageWitnessIntent, assertNativeComposeMaterialAuthority, type NativeComposeGeneration, type NativeComposeMaterialAuthority, type NativeComposeMaterialBinding, + publishNativeComposeStorageWitnessEnrollment, runNativeComposeMaterialAction, } from "./native-compose-generation.ts"; import { @@ -32,6 +34,13 @@ import { verifyNativeComposeStorageWitnessArchive, } from "./native-compose-storage-witness-codec.ts"; +import { + type NativeComposeStorageWitnessReference, + nativeComposeStorageWitnessReferenceValid, +} from "./native-compose-storage-witness-state.ts"; + +export type { NativeComposeStorageWitnessReference } from "./native-compose-storage-witness-state.ts"; + const LIMIT = 16 * 1024; const SLOT_LIMIT = 4096; const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; @@ -63,15 +72,6 @@ type Completion = { readonly expectationHash: string; readonly volume: NativeComposeRetainedVolume; }; -/** Private receipt extension candidate. Existing v1/v2 receipts must never silently ignore it. */ -export type NativeComposeStorageWitnessReference = { - readonly version: 1; - readonly volume: NativeComposeRetainedVolume; - readonly root: DirectoryAnchor; - readonly directory: DirectoryAnchor; - readonly expectation: Anchor; - readonly completion: Anchor; -}; export type NativeComposeStorageWitnessEnrollment = Readonly< Record >; @@ -103,32 +103,6 @@ function anchor(read: { hash: hash(read.text), }); } -function anchorValid(value: unknown): value is Anchor { - return ( - isRecord(value) && - keys(value, "dev,hash,ino") && - typeof value.dev === "number" && - Number.isSafeInteger(value.dev) && - value.dev >= 0 && - typeof value.ino === "number" && - Number.isSafeInteger(value.ino) && - value.ino > 0 && - typeof value.hash === "string" && - HASH.test(value.hash) - ); -} -function directoryAnchorValid(value: unknown): value is DirectoryAnchor { - return ( - isRecord(value) && - keys(value, "dev,ino") && - typeof value.dev === "number" && - Number.isSafeInteger(value.dev) && - value.dev >= 0 && - typeof value.ino === "number" && - Number.isSafeInteger(value.ino) && - value.ino > 0 - ); -} function selectionValid(value: Selection): boolean { return nativeComposeRetainedVolumesValid([ { ...value, createdAt: "2000-01-01T00:00:00Z" }, @@ -226,18 +200,6 @@ function completion(text: string): Completion { volume: parseVolume(value.volume), }; } -function referenceValid(value: NativeComposeStorageWitnessReference): boolean { - return ( - isRecord(value) && - keys(value, "completion,directory,expectation,root,version,volume") && - value.version === 1 && - nativeComposeRetainedVolumesValid([value.volume]) && - directoryAnchorValid(value.root) && - directoryAnchorValid(value.directory) && - anchorValid(value.expectation) && - anchorValid(value.completion) - ); -} function sameVolume( left: NativeComposeRetainedVolume, right: NativeComposeRetainedVolume @@ -370,10 +332,10 @@ async function checkedExpectation( } /** - * Foundation only: the CLI does not yet enroll or enforce these references. + * Internal foundation: the CLI refuses witness-bearing workload admission until its carrier is qualified. * The owning generation mutation must prove a genuinely absent new volume or * explicit stopped adoption. It must publish a required new receipt version - * before using this extension; v1/v2 migration cannot establish past continuity. + * before creating this extension; v1/v2 migration cannot establish past continuity. * Durable expectation precedes all provisioning. An existing slot is never reset. */ export async function prepareNativeComposeStorageWitness(opts: { @@ -424,6 +386,19 @@ export async function prepareNativeComposeStorageWitness(opts: { return refuse(); } await input.assertAdmission(); + await armNativeComposeStorageWitnessIntent({ + authority: input.authority, + generation: input.generation, + intent: { + name: binding.name, + storage: binding.storage, + engineId: binding.engineId, + generationId: binding.generationId, + pendingToken: binding.pendingToken, + admission: record.admission, + originalVolume: record.originalVolume, + }, + }); const held = await directories({ binding: current, name: binding.name, @@ -546,14 +521,29 @@ export async function enrollNativeComposeStorageWitness(opts: { } await recheckDirectories(held); await check(); - return Object.freeze({ - version: 1, + const reference = Object.freeze({ + version: 1 as const, volume: Object.freeze({ ...volume }), root: selected.root, directory: selected.directory, expectation: selected.anchor, completion: anchor({ info, text }), }); + await publishNativeComposeStorageWitnessEnrollment({ + authority: selected.authority, + generation: selected.generation, + reference, + verify: async () => { + await verifyNativeComposeStorageWitness({ + authority: selected.authority, + generation: selected.generation, + engineId: saved.binding.engineId, + reference, + observe: async () => await observe(), + }); + }, + }); + return reference; } finally { await close(held); } @@ -573,7 +563,7 @@ export async function verifyNativeComposeStorageWitness(opts: { }>; }): Promise { const { authority, generation, engineId, observe } = opts; - if (!referenceValid(opts.reference)) { + if (!nativeComposeStorageWitnessReferenceValid(opts.reference)) { return refuse(); } const reference = structuredClone(opts.reference); diff --git a/tests/native-compose-storage-witness-command.test.ts b/tests/native-compose-storage-witness-command.test.ts new file mode 100644 index 00000000..e32a75c8 --- /dev/null +++ b/tests/native-compose-storage-witness-command.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { openNativeComposeGenerationStore } from "../src/lib/native-compose-generation.ts"; +import { nativeComposeRetainedVolumesValid } from "../src/lib/native-compose-retained-storage.ts"; +import { + enrollNativeComposeStorageWitness, + prepareNativeComposeStorageWitness, +} from "../src/lib/native-compose-storage-witness.ts"; +import { fixture, invoke } from "./helpers/native-compose-command.ts"; + +async function prepared(enroll: boolean) { + const root = await fixture("", false, { + noHooks: true, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); + expect((await invoke(root)).code).toBe(0); + expect((await invoke(root, ["down", "--json"])).code).toBe(0); + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + mode: "prepare", + }); + try { + const current = await store.loadCurrent(); + const generation = current.generation; + const volume = current.retainedStorage?.[0]; + if ( + !(generation && volume && nativeComposeRetainedVolumesValid([volume])) + ) { + throw new Error("Expected public-store retained generation and birth"); + } + let archive: Uint8Array = new Uint8Array(); + await store.withMutation(async (mutation) => { + const result = await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageWitness({ + authority: mutation.materialAuthority, + generation, + engineId: "d".repeat(64), + volume: { name: volume.name, storage: volume.storage }, + admission: "explicit-adoption", + originalVolume: volume, + assertAdmission: async () => {}, + }); + if (enroll) { + await enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + archive = bytes; + }, + observe: async () => ({ volume, archive }), + }); + } + return { value: 0, outcome: enroll ? "complete" : "uncertain" }; + }, + }); + expect(result.outcome).toBe(enroll ? "complete" : "uncertain"); + }); + return { + root, + witnesses: (await store.loadCurrent()).storageWitnesses, + generationId: generation.generationId, + }; + } finally { + await store.close(); + } +} + +async function saved(root: string) { + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + mode: "saved", + }); + try { + return await store.loadCurrent(); + } finally { + await store.close(); + } +} + +test("source CLI cannot fall back to metadata for enrolled v3 startup, run or exec", async () => { + const { root, witnesses } = await prepared(true); + const requests = await Bun.file(join(root, "requests")).text(); + const order = await Bun.file(join(root, "order")).text(); + for (const args of [ + ["up", "--detach", "--json"], + ["run", "web", "--", "synthetic"], + ["exec", "web", "--", "synthetic"], + ]) { + const result = await invoke(root, args); + expect(result.code).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("E_CONFIG_INVALID"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain( + ".hack-storage-" + ); + } + expect(await Bun.file(join(root, "requests")).text()).toBe(requests); + expect(await Bun.file(join(root, "order")).text()).toBe(order); + expect((await saved(root)).storageWitnesses).toEqual(witnesses); +}, 30_000); + +test("source-unavailable explicit saved recovery stops engine but retains Expected intent and ownership anchor", async () => { + const { root, witnesses, generationId } = await prepared(false); + const requests = await Bun.file(join(root, "requests")).text(); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + await Bun.write( + join(root, ".hack/hack.project.json"), + "malformed authored source" + ); + await Bun.write( + join(root, ".hack/hack.env.default.yaml"), + "malformed private env" + ); + const normal = await invoke(root, ["down", "--json"]); + expect(normal.code).toBe(1); + expect(await Bun.file(join(root, "requests")).text()).toBe(requests); + const recovery = await invoke(root, ["down", "--recover", "--json"]); + expect(recovery.code).toBe(1); + expect(JSON.parse(recovery.stdout)).toMatchObject({ + ok: false, + error: { code: "E_COMPOSE_FAILED" }, + }); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + const current = await saved(root); + expect(current.storageWitnesses).toEqual(witnesses); + expect(current.storageWitnessesPending).toBe(true); + expect(current.pending?.generationId).toBe(generationId); + expect(current.retainedStorage).toHaveLength(1); + expect(JSON.stringify(current)).not.toContain(".hack-storage-"); +}, 30_000); diff --git a/tests/native-compose-storage-witness.test.ts b/tests/native-compose-storage-witness.test.ts index bebe87c8..9130569a 100644 --- a/tests/native-compose-storage-witness.test.ts +++ b/tests/native-compose-storage-witness.test.ts @@ -16,6 +16,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { + assertNativeComposeMaterialAuthority, type NativeComposeGeneration, type NativeComposeGenerationStore, type NativeComposeMutation, @@ -137,7 +138,8 @@ test("enrollment captures original callbacks before admission can replace them", }); }); expect(originalSeeds).toBe(1); - expect(originalObservations).toBe(1); + // Enrollment proof, fresh receipt publication proof, and final startup proof use the captured callback. + expect(originalObservations).toBe(3); expect(replacements).toBe(0); }); @@ -569,6 +571,13 @@ test.each([ operation: "up", assertOwned: async () => {}, assertFresh: async () => {}, + storageWitnesses: { + engineId, + observe: async () => { + probes++; + return { volume: { ...volume }, archive: replaced }; + }, + }, effect: async () => { await verifyNativeComposeStorageWitness({ authority: mutation.materialAuthority, @@ -584,14 +593,12 @@ test.each([ return { value: 0, outcome: "complete" }; }, }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + ).rejects.toThrow("values omitted"); }); expect(workloads).toBe(0); expect(probes).toBe(1); expect(await readFile(join(slot(store), "expectation.json"))).toEqual(before); - expect((await store.loadCurrent()).pending?.generationId).toBe( - generation.generationId - ); + expect((await store.loadCurrent()).pending).toBeNull(); }); test("a seed failure consumes capability and retains expectation with no completion", async () => { @@ -785,3 +792,433 @@ test.each([ }); expect(probes).toBe(0); }); + +function receiptFile(store: NativeComposeGenerationStore) { + return join( + store.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + store.identity.instanceId, + "receipt.json" + ); +} + +test("v3 Expected is durable before the slot and survives failed publication and explicit saved stop", async () => { + const store = await fixture(); + const originalMkdir = fs.mkdir; + let journalBeforeSlot = false; + const mocked = spyOn(fs, "mkdir").mockImplementation((async ( + path, + options + ) => { + if (String(path) === slot(store)) { + const value = JSON.parse(await readFile(receiptFile(store), "utf8")); + journalBeforeSlot = + value.version === 3 && value.storageWitnesses[0]?.state === "expected"; + throw new Error("synthetic slot creation interruption"); + } + return await originalMkdir(path, options); + }) as typeof fs.mkdir); + let generation: NativeComposeGeneration; + try { + generation = await store.withMutation(async (mutation) => { + const current = await publish(mutation); + await expect( + mutation.runEffect({ + generation: current, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + await prepare(mutation, current); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + return current; + }); + } finally { + mocked.mockRestore(); + } + expect(journalBeforeSlot).toBe(true); + await expect(lstat(slot(store))).rejects.toMatchObject({ code: "ENOENT" }); + const expected = (await store.loadCurrent()).storageWitnesses; + expect(expected?.[0]?.state).toBe("expected"); + await store.close(); + const saved = await openNativeComposeGenerationStore({ + projectRoot: store.identity.checkoutRoot, + instance: null, + mode: "saved", + }); + stores.push(saved); + const pending = await saved.loadPending(); + expect(pending?.generationId).toBe(generation.generationId); + if (!pending) { + throw new Error("Expected recovery anchor"); + } + let probes = 0; + let effects = 0; + let retirements = 0; + await saved.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation: pending, + operation: "down", + assertOwned: async () => { + probes++; + }, + effect: async () => { + effects++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(probes).toBe(0); + expect(effects).toBe(0); + const result = await mutation.runEffect({ + generation: pending, + operation: "down", + recoverPending: true, + assertOwned: async () => { + probes++; + }, + beforeComplete: async () => { + retirements++; + }, + effect: async () => { + effects++; + return { value: 0, outcome: "complete" }; + }, + }); + expect(result).toEqual({ value: 0, outcome: "uncertain" }); + }); + expect(effects).toBe(1); + expect(retirements).toBe(0); + const current = await saved.loadCurrent(); + expect(current.storageWitnesses).toEqual(expected); + expect(current.storageWitnessesPending).toBe(true); + expect(current.pending?.generationId).toBe(generation.generationId); + // No successful startup existed: its original stopped bit remains, alongside the required pending anchor. + expect(current.stopped).toBe(true); + expect(JSON.stringify(current)).not.toContain("storageWitness"); +}); + +test("enrolled v3 references survive metadata capture, reopen and saved down, and resume requires a carrier", async () => { + const { store, generation, reference } = await active(); + const before = (await store.loadCurrent()).storageWitnesses; + expect(before?.[0]?.state).toBe("enrolled"); + expect(before?.[0]?.state === "enrolled" && before[0].reference).toEqual( + reference + ); + expect((await store.loadCurrent()).retainedStorage).toEqual([volume]); + expect(JSON.stringify(await store.loadCurrent())).not.toContain(volume.name); + await store.withMutation(async (mutation) => { + await mutation.runEffect({ + generation, + operation: "down", + assertOwned: async () => {}, + captureStorage: () => [volume], + effect: async () => ({ value: 0, outcome: "complete" }), + }); + }); + expect(JSON.parse(await readFile(receiptFile(store), "utf8")).version).toBe( + 3 + ); + await store.close(); + const reopened = await openNativeComposeGenerationStore({ + projectRoot: store.identity.checkoutRoot, + instance: null, + mode: "prepare", + }); + stores.push(reopened); + const current = await reopened.loadCurrent(); + expect(current.storageWitnesses).toEqual(before); + expect(current.storageWitnessesPending).toBe(false); + expect(current.stopped).toBe(true); + if (!current.generation) { + throw new Error("Expected saved generation"); + } + let probes = 0; + let effects = 0; + await reopened.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation: current.generation!, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => { + probes++; + }, + effect: async () => { + effects++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + }); + expect(probes).toBe(0); + expect(effects).toBe(0); + expect((await reopened.loadCurrent()).pending).toBeNull(); +}); + +test("store fences an unchanged resume before and after workload without enrollment replay", async () => { + const { store, generation, archive } = await active(); + const events: string[] = []; + await store.withMutation(async (mutation) => { + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => { + events.push("owned"); + }, + storageWitnesses: { + engineId, + observe: async ({ name, storage, markerName }) => { + expect(name).toBe(volume.name); + expect(storage).toBe(volume.storage); + expect(markerName).toMatch(/^\.hack-storage-[a-f0-9]{64}\.witness$/); + events.push("marker"); + return { volume, archive }; + }, + }, + effect: async () => { + events.push("workload"); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(events.indexOf("marker")).toBeLessThan(events.indexOf("owned")); + expect(events.lastIndexOf("marker")).toBeGreaterThan( + events.indexOf("workload") + ); + expect(events.filter((event) => event === "workload")).toHaveLength(1); + expect((await store.loadCurrent()).pending).toBeNull(); +}); + +test("finalizer marker drift preserves exact v3 recovery state and cannot publish ready", async () => { + const { store, generation, archive } = await active(); + let currentArchive = archive; + const before = (await store.loadCurrent()).storageWitnesses; + let finalizers = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "restart", + assertFresh: async () => {}, + assertOwned: async () => {}, + storageWitnesses: { + engineId, + observe: async () => ({ volume, archive: currentArchive }), + }, + beforeComplete: async () => { + finalizers++; + currentArchive = new Uint8Array(2048); + }, + effect: async () => ({ value: 0, outcome: "complete" }), + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(finalizers).toBe(1); + const current = await store.loadCurrent(); + expect(current.pending?.generationId).toBe(generation.generationId); + expect(current.storageWitnesses).toEqual(before); +}); + +test.each([ + "downgrade", + "missing", + "duplicate", + "birth", + "unknown", +] as const)("required v3 witness receipt refuses %s without rewriting", async (change) => { + const { store } = await active(); + const file = receiptFile(store); + const value = JSON.parse(await readFile(file, "utf8")); + if (change === "downgrade") { + value.version = 2; + } + if (change === "missing") { + Reflect.deleteProperty(value, "storageWitnesses"); + } + if (change === "duplicate") { + value.storageWitnesses.push(value.storageWitnesses[0]); + } + if (change === "birth") { + value.storage[0].createdAt = "2026-10-08T12:00:01Z"; + } + if (change === "unknown") { + value.storageWitnesses[0].state = "synthetic-private-canary"; + } + const changed = JSON.stringify(value); + await writeFile(file, changed, { mode: 0o600 }); + await expect(store.loadCurrent()).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_STATE", + }); + expect(await readFile(file, "utf8")).toBe(changed); +}); + +test("material effect revocation during its own final receipt read cannot return authority", async () => { + const store = await fixture(); + const waiting = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const held: { work: Promise | null } = { work: null }; + const originalOpen = fs.open; + let armed = false; + let count = 0; + let hit = false; + const mocked = spyOn(fs, "open").mockImplementation( + async (path, flags, mode) => { + if ( + armed && + String(path) === receiptFile(store) && + Number(flags) === + (constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + ) { + count++; + if (count === 2) { + hit = true; + waiting.resolve(); + await resume.promise; + } + } + return await originalOpen(path, flags, mode); + } + ); + const guard = setTimeout(() => waiting.resolve(), 3000); + try { + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + armed = true; + held.work = assertNativeComposeMaterialAuthority({ + authority: mutation.materialAuthority, + generation, + phase: "effect", + }).then( + () => true, + () => false + ); + await waiting.promise; + return { value: 0, outcome: "uncertain" }; + }, + }); + armed = false; + resume.resolve(); + expect(await held.work).toBe(false); + }); + expect(hit).toBe(true); + expect((await store.loadCurrent()).pending).not.toBeNull(); + } finally { + waiting.resolve(); + resume.resolve(); + clearTimeout(guard); + await held.work; + mocked.mockRestore(); + } +}); + +test("revocation during the staged Enrolled receipt read preserves Expected and never promotes", async () => { + const store = await fixture(); + const waiting = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const held: { work: Promise | null } = { work: null }; + let seeds = 0; + let hit = false; + let archive: Uint8Array = new Uint8Array(); + const originalOpen = fs.open; + const mocked = spyOn(fs, "open").mockImplementation( + async (path, flags, mode) => { + if ( + !hit && + String(path).endsWith(".receipt.tmp") && + Number(flags) === + (constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + ) { + const staged = await Bun.file(String(path)).json(); + if (staged.storageWitnesses?.[0]?.state === "enrolled") { + hit = true; + waiting.resolve(); + await resume.promise; + } + } + return await originalOpen(path, flags, mode); + } + ); + const guard = setTimeout(() => waiting.resolve(), 3000); + try { + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepare(mutation, generation); + held.work = enrollNativeComposeStorageWitness({ + enrollment, + seed: async (bytes) => { + seeds++; + archive = bytes; + }, + observe: async () => ({ volume, archive }), + }).then( + () => true, + () => false + ); + await waiting.promise; + return { value: 0, outcome: "uncertain" }; + }, + }); + resume.resolve(); + expect(await held.work).toBe(false); + }); + expect(hit).toBe(true); + expect(seeds).toBe(1); + // The completed extension alone cannot authorize promotion after this invocation ends. + expect(await Bun.file(join(slot(store), "enrolled.json")).exists()).toBe( + true + ); + const current = await store.loadCurrent(); + expect(current.storageWitnessesPending).toBe(true); + expect(current.storageWitnesses?.[0]?.state).toBe("expected"); + expect(current.generation).toBeNull(); + expect(current.pending).not.toBeNull(); + } finally { + waiting.resolve(); + resume.resolve(); + clearTimeout(guard); + await held.work; + mocked.mockRestore(); + } +}); + +test("witness-bearing before hooks refuse before private acquisition or spawn while the carrier is inactive", async () => { + const { store } = await active(); + let acquisitions = 0; + let effects = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runBeforeHooks({ + assertFresh: async () => { + acquisitions++; + }, + effect: async () => { + effects++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(acquisitions).toBe(0); + expect(effects).toBe(0); + expect((await store.loadCurrent()).beforeHooksPending).toBe(false); +}); From a9ab3f52d2c47529a9432a7b240b61c5c4fcc174 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 10:28:35 -0400 Subject: [PATCH 07/23] fix: require issued proof for storage witness enrollment --- .../native-compose-storage-witness.md | 5 +- src/lib/native-compose-generation.ts | 24 +++-- .../native-compose-storage-witness-state.ts | 4 + src/lib/native-compose-storage-witness.ts | 42 +++++++- tests/native-compose-storage-witness.test.ts | 99 +++++++++++++++++++ 5 files changed, 162 insertions(+), 12 deletions(-) diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 4f1404c5..f4359fbe 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -34,7 +34,10 @@ private reference pins both directory identities and both journal file identitie and hashes. A fresh read-only proof precedes attachment of that exact reference as `Enrolled` in the version-three receipt. The current generation and pending token are rechecked after the final filesystem await immediately before publication. -Copied, reused or revoked capabilities cannot authorize another seed. +The owner privately issues and consumes a one-use completion proof bound to that +same live authority and generation. A fabricated reference or caller-supplied +no-op verifier cannot publish enrollment. Copied, reused or revoked capabilities +cannot authorize another seed or completion. `verifyNativeComposeStorageWitness` reads and compares those records and the observed marker. It does not create, write, delete or repair storage. Missing or diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 248616b3..70706125 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -29,11 +29,11 @@ import { import { projectNativeComposeOneOff } from "./native-compose-run-projection.ts"; import { createNativeComposeStorageWitnessReceiptProtocol } from "./native-compose-storage-witness-receipt.ts"; import { + type NativeComposeStorageWitnessCompletionProof, type NativeComposeStorageWitnessIntent, type NativeComposeStorageWitnessReference, type NativeComposeStorageWitnessState, nativeComposeStorageWitnessIntentValid, - nativeComposeStorageWitnessReferenceValid, nativeComposeStorageWitnessStatesValid, } from "./native-compose-storage-witness-state.ts"; @@ -164,19 +164,23 @@ export async function armNativeComposeStorageWitnessIntent(opts: { export async function publishNativeComposeStorageWitnessEnrollment(opts: { readonly authority: NativeComposeMaterialAuthority; readonly generation: NativeComposeGeneration; - readonly reference: NativeComposeStorageWitnessReference; - readonly verify: () => Promise; + readonly proof: NativeComposeStorageWitnessCompletionProof; }): Promise { - const { authority, generation, verify } = opts; - if (!nativeComposeStorageWitnessReferenceValid(opts.reference)) { - return refuse(); - } - const reference = structuredClone(opts.reference); + const { authority, generation, proof } = opts; const publish = witnessPublications.get(authority) ?? refuse(); await runNativeComposeMaterialAction({ authority, - run: async () => - await publish(generation, { state: "enrolled", reference, verify }), + run: async () => { + const { consumeNativeComposeStorageWitnessCompletionProof } = + await import("./native-compose-storage-witness.ts"); + const { reference, verify } = + consumeNativeComposeStorageWitnessCompletionProof({ + authority, + generation, + proof, + }); + await publish(generation, { state: "enrolled", reference, verify }); + }, }); } /** The generation lease remains held until owned material work has settled, even after revocation. */ diff --git a/src/lib/native-compose-storage-witness-state.ts b/src/lib/native-compose-storage-witness-state.ts index ddaabde2..f9234736 100644 --- a/src/lib/native-compose-storage-witness-state.ts +++ b/src/lib/native-compose-storage-witness-state.ts @@ -19,6 +19,10 @@ export type NativeComposeStorageWitnessReference = { readonly expectation: Anchor; readonly completion: Anchor; }; +/** Issued privately by the witness owner. An object copy cannot authorize receipt publication. */ +export type NativeComposeStorageWitnessCompletionProof = Readonly< + Record +>; export type NativeComposeStorageWitnessIntent = { readonly name: string; readonly storage: string; diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index 65ff2610..a3b5fa3e 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -35,6 +35,7 @@ import { } from "./native-compose-storage-witness-codec.ts"; import { + type NativeComposeStorageWitnessCompletionProof, type NativeComposeStorageWitnessReference, nativeComposeStorageWitnessReferenceValid, } from "./native-compose-storage-witness-state.ts"; @@ -89,6 +90,38 @@ const enrollments = new WeakMap< NativeComposeStorageWitnessEnrollment, Enrollment >(); +const completionProofs = new WeakMap< + NativeComposeStorageWitnessCompletionProof, + { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly reference: NativeComposeStorageWitnessReference; + readonly verify: () => Promise; + consumed: boolean; + } +>(); +/** Generation publication consumes an issued proof; arbitrary references or no-op callbacks cannot mint one. */ +export function consumeNativeComposeStorageWitnessCompletionProof(opts: { + readonly authority: NativeComposeMaterialAuthority; + readonly generation: NativeComposeGeneration; + readonly proof: NativeComposeStorageWitnessCompletionProof; +}): { + readonly reference: NativeComposeStorageWitnessReference; + readonly verify: () => Promise; +} { + const { authority, generation, proof } = opts; + const issued = completionProofs.get(proof); + if ( + !issued || + issued.consumed || + issued.authority !== authority || + issued.generation !== generation + ) { + return refuse(); + } + issued.consumed = true; + return Object.freeze({ reference: issued.reference, verify: issued.verify }); +} function hash(text: string): string { return createHash("sha256").update(text).digest("hex"); @@ -529,10 +562,12 @@ export async function enrollNativeComposeStorageWitness(opts: { expectation: selected.anchor, completion: anchor({ info, text }), }); - await publishNativeComposeStorageWitnessEnrollment({ + const proof = Object.freeze({}); + completionProofs.set(proof, { authority: selected.authority, generation: selected.generation, reference, + consumed: false, verify: async () => { await verifyNativeComposeStorageWitness({ authority: selected.authority, @@ -543,6 +578,11 @@ export async function enrollNativeComposeStorageWitness(opts: { }); }, }); + await publishNativeComposeStorageWitnessEnrollment({ + authority: selected.authority, + generation: selected.generation, + proof, + }); return reference; } finally { await close(held); diff --git a/tests/native-compose-storage-witness.test.ts b/tests/native-compose-storage-witness.test.ts index 9130569a..e942795e 100644 --- a/tests/native-compose-storage-witness.test.ts +++ b/tests/native-compose-storage-witness.test.ts @@ -15,12 +15,15 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as generationOwner from "../src/lib/native-compose-generation.ts"; import { + armNativeComposeStorageWitnessIntent, assertNativeComposeMaterialAuthority, type NativeComposeGeneration, type NativeComposeGenerationStore, type NativeComposeMutation, openNativeComposeGenerationStore, + publishNativeComposeStorageWitnessEnrollment, } from "../src/lib/native-compose-generation.ts"; import { mergeNativeComposeRetainedVolumes } from "../src/lib/native-compose-retained-storage.ts"; import { @@ -1222,3 +1225,99 @@ test("witness-bearing before hooks refuse before private acquisition or spawn wh expect(effects).toBe(0); expect((await store.loadCurrent()).beforeHooksPending).toBe(false); }); + +test("a shape-valid fabricated reference and no-op verifier cannot promote Expected", async () => { + const store = await fixture(); + let suppliedVerifiers = 0; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const binding = await assertNativeComposeMaterialAuthority({ + authority: mutation.materialAuthority, + generation, + phase: "effect", + }); + if (!binding.pendingToken) { + throw new Error("Expected active pending token"); + } + await armNativeComposeStorageWitnessIntent({ + authority: mutation.materialAuthority, + generation, + intent: { + name: volume.name, + storage: volume.storage, + engineId, + generationId: generation.generationId, + pendingToken: binding.pendingToken, + admission: "initial-create", + originalVolume: null, + }, + }); + const forged = { + authority: mutation.materialAuthority, + generation, + proof: {}, + reference: { + version: 1 as const, + volume, + root: { dev: 1, ino: 1 }, + directory: { dev: 1, ino: 2 }, + expectation: { dev: 1, ino: 3, hash: "a".repeat(64) }, + completion: { dev: 1, ino: 4, hash: "b".repeat(64) }, + }, + verify: async () => { + suppliedVerifiers++; + }, + }; + await expect( + publishNativeComposeStorageWitnessEnrollment(forged) + ).rejects.toThrow("values omitted"); + return { value: 0, outcome: "uncertain" }; + }, + }); + }); + expect(suppliedVerifiers).toBe(0); + const current = await store.loadCurrent(); + expect(current.storageWitnessesPending).toBe(true); + expect(current.storageWitnesses?.[0]?.state).toBe("expected"); + expect(current.generation).toBeNull(); + await expect(lstat(slot(store))).rejects.toMatchObject({ code: "ENOENT" }); +}); + +test("issued completion proof rejects copies, changed authority/generation and reuse without consuming the original", async () => { + const originalPublish = + generationOwner.publishNativeComposeStorageWitnessEnrollment; + let publications = 0; + const mocked = spyOn( + generationOwner, + "publishNativeComposeStorageWitnessEnrollment" + ).mockImplementation(async (opts) => { + publications++; + await expect( + originalPublish({ ...opts, proof: { ...opts.proof } }) + ).rejects.toThrow("values omitted"); + await expect(originalPublish({ ...opts, authority: {} })).rejects.toThrow( + "values omitted" + ); + await expect( + originalPublish({ ...opts, generation: { ...opts.generation } }) + ).rejects.toThrow("values omitted"); + await originalPublish(opts); + await expect(originalPublish(opts)).rejects.toThrow("values omitted"); + }); + try { + const { store } = await active(); + expect(publications).toBe(1); + const current = await store.loadCurrent(); + expect(current.storageWitnessesPending).toBe(false); + expect(current.storageWitnesses?.[0]?.state).toBe("enrolled"); + expect(current.pending).toBeNull(); + } finally { + mocked.mockRestore(); + } +}); From 7de393d2ce8692715561b64cdafb6f3e1649aa9b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 09:36:21 -0400 Subject: [PATCH 08/23] test(runtime): model durable storage witness enrollment --- scripts/lib/tla-runtime-models.ts | 328 ++++++++++++++++++ tests/models/tla/README.md | 71 ++++ .../tla/native-storage-witness/Witness.tla | 328 ++++++++++++++++++ .../native-storage-witness/cold-reachable.cfg | 18 + .../empty-intent-refusal-reachable.cfg | 18 + .../expectation-replacement.cfg | 17 + .../foreign-metadata.cfg | 17 + .../interrupted-matching-reachable.cfg | 18 + .../interrupted-missing-reachable.cfg | 18 + .../legacy-refusal-reachable.cfg | 18 + .../missing-resume-refusal-reachable.cfg | 18 + .../missing-witness.cfg | 17 + .../tla/native-storage-witness/negative.cfg | 17 + .../tla/native-storage-witness/positive.cfg | 17 + .../premature-completion.cfg | 17 + .../readonly-reenrollment.cfg | 17 + .../recovery-reachable.cfg | 18 + .../native-storage-witness/recovery-seed.cfg | 17 + .../resume-reachable.cfg | 18 + .../stale-proof-refusal-reachable.cfg | 18 + .../wrong-resume-refusal-reachable.cfg | 18 + .../native-storage-witness/wrong-witness.cfg | 17 + tests/tla-runtime-models.test.ts | 108 ++++++ 23 files changed, 1168 insertions(+) create mode 100644 tests/models/tla/native-storage-witness/Witness.tla create mode 100644 tests/models/tla/native-storage-witness/cold-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/empty-intent-refusal-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/expectation-replacement.cfg create mode 100644 tests/models/tla/native-storage-witness/foreign-metadata.cfg create mode 100644 tests/models/tla/native-storage-witness/interrupted-matching-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/interrupted-missing-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/legacy-refusal-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/missing-resume-refusal-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/missing-witness.cfg create mode 100644 tests/models/tla/native-storage-witness/negative.cfg create mode 100644 tests/models/tla/native-storage-witness/positive.cfg create mode 100644 tests/models/tla/native-storage-witness/premature-completion.cfg create mode 100644 tests/models/tla/native-storage-witness/readonly-reenrollment.cfg create mode 100644 tests/models/tla/native-storage-witness/recovery-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/recovery-seed.cfg create mode 100644 tests/models/tla/native-storage-witness/resume-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/stale-proof-refusal-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/wrong-resume-refusal-reachable.cfg create mode 100644 tests/models/tla/native-storage-witness/wrong-witness.cfg diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 214d13d4..dff8f409 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -40,6 +40,334 @@ const laterIncompleteMissingLockStages = [ "owner", ] as const; const contracts: readonly ModelContract[] = [ + { + name: "native-storage-witness", + module: "Witness", + states: 352, + invariant: "NoSeedOnReopen", + action: "SeedOnResume", + fields: [ + 'operation = "resume"', + "expected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 1", + "seedWrites = 2", + "enrollmentWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeSeed = TRUE", + ], + additionalControls: [ + { + name: "missing-witness", + negative: true, + invariant: "NoWorkloadWithoutWitness", + action: "StartWorkload", + fields: [ + 'phase = "running"', + "expected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 0", + "unsafeStart = TRUE", + ], + }, + { + name: "wrong-witness", + negative: true, + invariant: "NoWorkloadWithoutWitness", + action: "StartWorkload", + fields: [ + 'phase = "running"', + "expected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 2", + "unsafeStart = TRUE", + ], + }, + { + name: "foreign-metadata", + negative: true, + invariant: "NoWorkloadWithoutWitness", + action: "StartWorkload", + fields: [ + 'phase = "running"', + "expected = 1", + "enrolled = 1", + "metadata = 2", + "marker = 1", + "unsafeStart = TRUE", + ], + }, + { + name: "premature-completion", + negative: true, + invariant: "NoUnprovedCompletion", + action: "CommitEnrollment", + fields: [ + 'phase = "enrolled"', + "expected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 0", + "unsafeCompletion = TRUE", + ], + }, + { + name: "expectation-replacement", + negative: true, + invariant: "NoChangedExpectation", + action: "ReplaceExpectation", + fields: [ + 'phase = "checking"', + "expected = 2", + "originalExpected = 1", + "enrolled = 1", + "unsafeReplacement = TRUE", + ], + }, + { + name: "readonly-reenrollment", + negative: true, + invariant: "NoReadOnlyReenrollment", + action: "ReenrollReadOnly", + fields: [ + 'phase = "checked"', + "expected = 1", + "enrolled = 1", + "marker = 1", + "enrollmentWrites = 2", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeReenrollment = TRUE", + ], + }, + { + name: "recovery-seed", + negative: true, + invariant: "NoSeedOnReopen", + action: "SeedOnRecovery", + fields: [ + 'phase = "interrupted"', + 'operation = "recovery"', + "expected = 1", + "originalExpected = 1", + "enrolled = 0", + "volumePresent = TRUE", + "marker = 1", + "seedWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeSeed = TRUE", + ], + }, + { + name: "cold-reachable", + negative: true, + invariant: "NeverColdWorkload", + action: "StartWorkload", + fields: [ + 'phase = "running"', + 'operation = "cold"', + "expected = 1", + "enrolled = 1", + "marker = 1", + "metadata = 1", + "seedWrites = 1", + "enrollmentWrites = 1", + "unsafeStart = FALSE", + "unsafeCompletion = FALSE", + ], + }, + { + name: "resume-reachable", + negative: true, + invariant: "NeverReadOnlyResume", + action: "StartWorkload", + fields: [ + 'phase = "running"', + 'operation = "resume"', + "expected = 1", + "enrolled = 1", + "marker = 1", + "metadata = 1", + "resumes = 1", + "seedWrites = 1", + "enrollmentWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeSeed = FALSE", + "unsafeStart = FALSE", + ], + }, + { + name: "recovery-reachable", + negative: true, + invariant: "NeverEnrolledRecovery", + action: "StartWorkload", + fields: [ + 'phase = "running"', + 'operation = "recovery"', + "expected = 1", + "enrolled = 1", + "marker = 1", + "metadata = 1", + "crashes = 1", + "seedWrites = 1", + "enrollmentWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeSeed = FALSE", + "unsafeStart = FALSE", + ], + }, + { + name: "empty-intent-refusal-reachable", + negative: true, + invariant: "NeverEmptyIntentRefusal", + action: "RecoveryDisposition", + fields: [ + 'phase = "refused"', + 'operation = "recovery"', + 'disposition = "needs-explicit-reconcile"', + "intent = TRUE", + "expected = 0", + "originalExpected = 0", + "enrolled = 0", + "volumePresent = FALSE", + "marker = 0", + "seedWrites = 0", + "enrollmentWrites = 0", + "seedCapability = FALSE", + "completionCapability = FALSE", + ], + }, + { + name: "interrupted-missing-reachable", + negative: true, + invariant: "NeverInterruptedMissingRefusal", + action: "RecoveryDisposition", + fields: [ + 'phase = "refused"', + 'operation = "recovery"', + 'disposition = "needs-explicit-reconcile"', + "expected = 1", + "originalExpected = 1", + "enrolled = 0", + "volumePresent = TRUE", + "metadata = 1", + "marker = 0", + "seedWrites = 0", + "enrollmentWrites = 0", + "seedCapability = FALSE", + "completionCapability = FALSE", + ], + }, + { + name: "interrupted-matching-reachable", + negative: true, + invariant: "NeverInterruptedMatchingRefusal", + action: "RecoveryDisposition", + fields: [ + 'phase = "refused"', + 'operation = "recovery"', + 'disposition = "needs-explicit-reconcile"', + "expected = 1", + "originalExpected = 1", + "enrolled = 0", + "volumePresent = TRUE", + "metadata = 1", + "marker = 1", + "seedWrites = 1", + "enrollmentWrites = 0", + "seedCapability = FALSE", + "completionCapability = FALSE", + ], + }, + { + name: "missing-resume-refusal-reachable", + negative: true, + invariant: "NeverMissingResumeRefusal", + action: "RefuseResume", + fields: [ + 'phase = "refused"', + 'operation = "resume"', + 'disposition = "witness-mismatch"', + "expected = 1", + "originalExpected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 0", + "seedWrites = 1", + "enrollmentWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeStart = FALSE", + "unsafeSeed = FALSE", + ], + }, + { + name: "wrong-resume-refusal-reachable", + negative: true, + invariant: "NeverWrongResumeRefusal", + action: "RefuseResume", + fields: [ + 'phase = "refused"', + 'operation = "resume"', + 'disposition = "witness-mismatch"', + "expected = 1", + "originalExpected = 1", + "enrolled = 1", + "metadata = 1", + "marker = 2", + "seedWrites = 1", + "enrollmentWrites = 1", + "seedCapability = FALSE", + "completionCapability = FALSE", + "unsafeStart = FALSE", + "unsafeSeed = FALSE", + ], + }, + { + name: "stale-proof-refusal-reachable", + negative: true, + invariant: "NeverStaleProofRefusal", + action: "RefuseEnrollment", + fields: [ + 'phase = "refused"', + 'operation = "cold"', + 'disposition = "needs-explicit-reconcile"', + "expected = 1", + "originalExpected = 1", + "enrolled = 0", + "metadata = 1", + "marker = 0", + "proof = TRUE", + "unsafeCompletion = FALSE", + ], + }, + { + name: "legacy-refusal-reachable", + negative: true, + invariant: "NeverLegacyRefusal", + action: "RefuseLegacy", + fields: [ + 'phase = "refused"', + 'disposition = "needs-adoption"', + "expected = 0", + "originalExpected = 0", + "enrolled = 0", + "volumePresent = TRUE", + "seedWrites = 0", + "enrollmentWrites = 0", + "seedCapability = FALSE", + "completionCapability = FALSE", + ], + }, + ], + }, { name: "native-file-material", module: "Material", diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index c147bdd0..afcac090 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -16,6 +16,77 @@ pinned artifact. Each check has a 120-second timeout, 512 MiB Java heap, two wor and bounded output; temporary TLC metadata is removed after success or failure. No credentials or running VM are needed. +## Native storage witness enrollment and read-only resume + +`native-storage-witness/Witness.tla` checks a proposed storage continuity protocol. +This maintained model is separate from the storage-witness owner/codec foundation +and from its future command, receipt and engine-carrier integration. It does not +qualify those implementations. The positive configuration exhausts **352 distinct +states** (550 generated, maximum depth 18), with four initial states: absent +storage, or untracked existing storage with a missing, matching or wrong marker. +Existing untracked storage requires explicit adoption; matching bytes alone do +not silently enroll it. + +An exclusive enrollment slot is published before the immutable `Expected` record; +a crash between them leaves a retained empty slot that refuses ordinary retry. +The expectation is durably published before creation or seeding. +Only its original live opaque capability may arm the seed once; arming consumes +that capability before the effect can await. Creation, marker publication, +observation and enrolled-record completion are separate crash boundaries. The +completion step rechecks both the observed proof and the current exact witness. +A crash revokes initial seed and completion authority while retaining published +records. Reopening an interrupted `Expected` refuses even if the marker now +matches: it preserves the anchor and reports a need for explicit reconciliation. +That future reconciliation contract is not modeled as automatic promotion. + +After successful enrollment, down/up and recovery use the saved expectation and +read-only witness comparison. Neither operation can seed or append another +enrollment. Workload admission requires the enrolled reference, matching marker, +matching resource binding and current mutation authority. `EmptyReplacement` +keeps metadata unchanged while removing the marker, representing a replacement +whose physical name, labels and reported birth cannot distinguish it. A copied +matching marker with foreign resource metadata also refuses. + +Eight unsafe controls remove one obligation each: seed-only-initial authority for +resume and recovery; missing, wrong or foreign-binding witness admission; +completion after exact proof; immutable expectation; and read-only enrollment. +Ten safe-path controls deliberately violate reachability predicates: cold +creation, down/up without reseeding, enrolled recovery without reseeding, +interrupted slot publication, enrollment with missing or matching marker, +missing/wrong marker resume refusal, stale proof refusal and untracked legacy refusal. The interrupted +missing-marker witness requires an already-created volume, so it cannot pass only +through a pre-effect crash. All controls require TLC exit 12, the intended +invariant and complete action/field evidence in one state. Parser errors, +arbitrary failure, split-state evidence and an incomplete positive exploration +fail the runner. + +| Model action/state | Intended foundation mapping and separate implementation obligation | +| --- | --- | +| `ReserveIntent` / `intent` | `prepareNativeComposeStorageWitness` exclusively creates and synchronizes a private per-volume slot before writing the expectation. An empty interrupted slot remains retained and cannot be recreated. This is distinct from the future mandatory generation-receipt enrollment intent; that command/receipt wiring is not implemented by the foundation. | +| `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` in the separate `native-compose-storage-witness.ts` foundation: validate active pending-generation authority, exclusively publish/sync an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then return an opaque live capability. A failed publication must not grant one. | +| `ArmSeed` / `PublishMarker` | `enrollNativeComposeStorageWitness` consumes the original capability before awaiting its single seed callback. Callback transport, create-only behavior, child ownership/reaping and no writes to an existing witness require implementation and carrier tests. `CreateVolume` is an abstract cold-creation boundary, not a claim that the foundation creates Docker volumes. | +| `ObserveEnrollment` / `CommitEnrollment` | The foundation verifies one observed archive through `verifyNativeComposeStorageWitnessArchive`, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. The model additionally requires the current exact witness at commit; qualifying that predicate across actual carrier awaits remains a separate engine-boundary obligation. Only the exact expected bytes and binding may form its private reference. The model's completion is this enrollment record, not a generation's ready receipt. | +| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh carrier observation and exact archive, then re-reads and rechecks ownership without writing. Ordinary command/receipt wiring and actual down/up are separate unqualified gates. | +| `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. A future explicit read-only reconciliation interface requires its own source review and tests; this model does not implement it. | +| `EmptyReplacement` / `WrongMarker` / `ForeignMetadata` | Owner/codec tests must distinguish absent/wrong bytes, fresh foreign binding, a same-metadata empty replacement and a copied witness. `encodeNativeComposeStorageWitnessArchive` is the proposed carrier codec; decoding bounds, archive path validation and read-only engine transport are outside this model. | +| `DriftAuthority` | Existing opaque generation authority must reject changed pending selection or revoked lease before seed/completion, and recheck after asynchronous proofs. The model summarizes those identities by one version; it cannot qualify real lease/token/inode checks. | +| `ReplaceExpectation` / `ReenrollReadOnly` | Immutable anchored records must reject replacement and read-only reopen must not append enrollment, even with matching marker bytes. Filesystem substitution and exact write counts require separate source regressions. | + +The finite bound has one volume, one crash, one down/up, two binding versions and +two distinguishable nonempty markers; marker `1` abstracts expected random bytes, +`0` absence and `2` wrong bytes. A single storage fault can occur before completion +or subsequent workload admission, including between observation and completion. +It is not an active watcher for corruption after a workload has started. Each +durable publication is modeled as one completed write; partial writes, fsync +durability, archive bytes/limits, entropy, secrecy, filesystem symlinks/inode reuse, +malicious same-user replacement, multiple-volume concurrency, repeated crashes, +real resource creation and child effects are omitted. Current resource and owner +proofs are abstract predicates; arbitrary concurrent engine changes and atomicity +between an actual carrier read and workload launch require separate qualification. +Explicit authorized legacy adoption is not modeled. No fairness, eventual +recovery, data-payload integrity, legacy historical continuity, command activation +or whole NC03 completion is claimed. + ## Native file-material retirement `native-file-material/Material.tla` checks the private file retirement protocol. diff --git a/tests/models/tla/native-storage-witness/Witness.tla b/tests/models/tla/native-storage-witness/Witness.tla new file mode 100644 index 00000000..f248c326 --- /dev/null +++ b/tests/models/tla/native-storage-witness/Witness.tla @@ -0,0 +1,328 @@ +----------------------------- MODULE Witness ----------------------------- +EXTENDS Naturals, TLC +CONSTANTS EnforceSeedOnlyCold, UnsafeSeedOperation, EnforceWitness, EnforceCompletionProof, + EnforceImmutableExpectation, EnforceReadOnlyEnrollment, FaultKind +VARIABLES intent, expected, originalExpected, enrolled, volumePresent, metadata, marker, phase, operation, seedCapability, completionCapability, proof, authority, crashes, resumes, seedWrites, enrollmentWrites, faulted, unsafeSeed, unsafeCompletion, unsafeStart, unsafeReplacement, unsafeReenrollment, disposition +vars == <> + +\* One volume and two distinguishable marker/binding values. Existing untracked +\* storage is included so cold success cannot hide silent legacy enrollment. +Init == /\ intent = FALSE + /\ expected = 0 + /\ originalExpected = 0 + /\ enrolled = 0 + /\ volumePresent \in BOOLEAN + /\ metadata = 1 + /\ marker \in (IF volumePresent THEN 0..2 ELSE {0}) + /\ phase = "cold" + /\ operation = "cold" + /\ seedCapability = FALSE + /\ completionCapability = FALSE + /\ proof = FALSE + /\ authority = 1 + /\ crashes = 0 + /\ resumes = 0 + /\ seedWrites = 0 + /\ enrollmentWrites = 0 + /\ faulted = FALSE + /\ unsafeSeed = FALSE + /\ unsafeCompletion = FALSE + /\ unsafeStart = FALSE + /\ unsafeReplacement = FALSE + /\ unsafeReenrollment = FALSE + /\ disposition = "none" +ExactWitness == expected # 0 /\ volumePresent /\ metadata = 1 /\ marker = expected +CanFault == volumePresent /\ ~faulted + /\ phase \in {"created", "published", "observed", "enrolled", + "stopped", "checking", "checked", "crashed", "interrupted"} + +\* Exclusive slot publication precedes expectation bytes; a crash can leave an empty retained intent. +ReserveIntent == /\ phase = "cold" + /\ ~volumePresent + /\ expected = 0 + /\ ~intent + /\ authority = 1 + /\ intent' = TRUE + /\ phase' = "intent" + /\ UNCHANGED <> + +\* An immutable durable expectation precedes all create/write effects. +PublishExpected == /\ phase = "intent" + /\ intent + /\ ~volumePresent + /\ expected = 0 + /\ authority = 1 + /\ expected' = 1 + /\ originalExpected' = 1 + /\ seedCapability' = TRUE + /\ completionCapability' = TRUE + /\ phase' = "expected" + /\ UNCHANGED <> + +\* Existing storage without an expectation is not silently enrolled. +RefuseLegacy == /\ phase = "cold" + /\ volumePresent + /\ phase' = "refused" + /\ disposition' = "needs-adoption" + /\ UNCHANGED <> + +\* Only the original live opaque capability arms once, before awaiting effects. +ArmSeed == /\ phase = "expected" + /\ seedCapability + /\ authority = 1 + /\ seedCapability' = FALSE + /\ phase' = "armed" + /\ UNCHANGED <> + +\* Creation and marker publication are distinct crash boundaries. +CreateVolume == /\ phase = "armed" + /\ ~volumePresent + /\ authority = 1 + /\ completionCapability + /\ volumePresent' = TRUE + /\ phase' = "created" + /\ UNCHANGED <> + +\* An initial seed can finish only in its original live invocation. +PublishMarker == /\ phase = "created" + /\ volumePresent + /\ metadata = 1 + /\ authority = 1 + /\ completionCapability + /\ seedWrites = 0 + /\ marker' = expected + /\ seedWrites' = 1 + /\ phase' = "published" + /\ UNCHANGED <> + +\* Read-only comparison is separate from committing the enrolled record. +ObserveEnrollment == /\ phase = "published" + /\ proof' = ExactWitness + /\ phase' = "observed" + /\ UNCHANGED <> + +\* Recheck exact current marker and metadata after the observation await. +CommitEnrollment == /\ phase = "observed" + /\ completionCapability + /\ authority = 1 + /\ enrolled = 0 + /\ (EnforceCompletionProof => (proof /\ ExactWitness)) + /\ enrolled' = expected + /\ enrollmentWrites' = enrollmentWrites + 1 + /\ completionCapability' = FALSE + /\ phase' = "enrolled" + /\ unsafeCompletion' = ~(proof /\ ExactWitness) + /\ UNCHANGED <> + +\* Failure preserves the original durable expected bytes and never claims completion. +RefuseEnrollment == /\ phase = "observed" + /\ ~(proof /\ ExactWitness /\ authority = 1) + /\ phase' = "refused" + /\ completionCapability' = FALSE + /\ disposition' = "needs-explicit-reconcile" + /\ UNCHANGED <> + +\* Workload admission requires an enrolled reference and a fresh exact witness. +StartWorkload == /\ phase \in {"enrolled", "checked"} + /\ enrolled # 0 + /\ enrolled = expected + /\ authority = 1 + /\ (EnforceWitness => (proof /\ ExactWitness)) + /\ phase' = "running" + /\ unsafeStart' = ~(proof /\ ExactWitness) + /\ UNCHANGED <> + +\* Stop retains the enrollment and data witness; it cannot enroll a successor. +Down == /\ phase = "running" + /\ resumes = 0 + /\ phase' = "stopped" + /\ UNCHANGED <> + +\* A new invocation receives only read-only authority, not initial seed/completion capabilities. +BeginResume == /\ phase = "stopped" + /\ resumes = 0 + /\ operation' = "resume" + /\ phase' = "checking" + /\ seedCapability' = FALSE + /\ completionCapability' = FALSE + /\ proof' = FALSE + /\ resumes' = 1 + /\ UNCHANGED <> + +\* Resume and recovery observe the exact retained expectation without writing data. +ReadWitness == /\ phase = "checking" + /\ enrolled # 0 + /\ proof' = ExactWitness + /\ phase' = "checked" + /\ UNCHANGED <> + +\* Missing or wrong witness refuses even if physical name, labels and birth still match. +RefuseResume == /\ phase = "checked" + /\ ~(proof /\ ExactWitness /\ authority = 1) + /\ phase' = "refused" + /\ disposition' = "witness-mismatch" + /\ UNCHANGED <> + +\* One crash revokes live capabilities while retaining all published records and marker bytes. +Crash == /\ crashes = 0 + /\ phase \notin {"running", "refused", "crashed"} + /\ phase' = "crashed" + /\ seedCapability' = FALSE + /\ completionCapability' = FALSE + /\ proof' = FALSE + /\ crashes' = 1 + /\ UNCHANGED <> + +\* A fresh validated mutation can read saved state, but cannot mint initial enrollment authority. +Recover == /\ phase = "crashed" + /\ operation' = "recovery" + /\ phase' = (IF enrolled # 0 THEN "checking" ELSE "interrupted") + /\ seedCapability' = FALSE + /\ completionCapability' = FALSE + /\ proof' = FALSE + /\ authority' = 1 + /\ UNCHANGED <> + +\* An interrupted Expected refuses even when its marker matches; reconciliation is a separate future contract. +RecoveryDisposition == /\ phase = "interrupted" + /\ phase' = "refused" + /\ disposition' = (IF intent THEN "needs-explicit-reconcile" ELSE "needs-adoption") + /\ UNCHANGED <> + +\* Same metadata and physical name can describe a new empty volume; only the witness changes. +EmptyReplacement == /\ CanFault + /\ FaultKind \in {"all", "empty"} + /\ marker' = 0 + /\ faulted' = TRUE + /\ UNCHANGED <> + +\* Wrong contents are independently distinguishable from an absent marker. +WrongMarker == /\ CanFault + /\ FaultKind \in {"all", "wrong"} + /\ marker' = 2 + /\ faulted' = TRUE + /\ UNCHANGED <> + +\* Even a copied marker does not authorize a different engine/owner/resource binding. +ForeignMetadata == /\ CanFault + /\ FaultKind \in {"all", "foreign"} + /\ metadata' = 2 + /\ faulted' = TRUE + /\ UNCHANGED <> + +\* Ownership or pending selection may drift during an await. +DriftAuthority == /\ phase \in {"intent", "expected", "armed", "created", "published", "observed", "enrolled", "stopped", "checking", "checked"} + /\ authority = 1 + /\ authority' = 2 + /\ UNCHANGED <> + +\* Intentionally unsafe control: healing a missing witness on resume destroys continuity evidence. +SeedOnResume == /\ ~EnforceSeedOnlyCold + /\ UnsafeSeedOperation = "resume" + /\ operation = "resume" + /\ phase \in {"checking", "checked"} + /\ expected # 0 + /\ marker # expected + /\ seedWrites = 1 + /\ marker' = expected + /\ seedWrites' = 2 + /\ unsafeSeed' = TRUE + /\ UNCHANGED <> + +\* Intentionally unsafe control: replacing old expected bytes must never make new storage acceptable. +ReplaceExpectation == /\ ~EnforceImmutableExpectation + /\ phase = "checking" + /\ expected = 1 + /\ expected' = 2 + /\ unsafeReplacement' = TRUE + /\ UNCHANGED <> + +\* Intentionally unsafe control: read-only reopen must not append a new enrollment. +ReenrollReadOnly == /\ ~EnforceReadOnlyEnrollment + /\ operation \in {"resume", "recovery"} + /\ phase = "checked" + /\ proof /\ ExactWitness + /\ enrolled = expected + /\ enrollmentWrites = 1 + /\ enrollmentWrites' = 2 + /\ unsafeReenrollment' = TRUE + /\ UNCHANGED <> + +\* Intentionally unsafe control: interrupted saved Expected never grants a recovery seed. +SeedOnRecovery == /\ ~EnforceSeedOnlyCold + /\ UnsafeSeedOperation = "recovery" + /\ operation = "recovery" + /\ phase = "interrupted" + /\ volumePresent + /\ expected # 0 + /\ marker # expected + /\ seedWrites = 0 + /\ marker' = expected + /\ seedWrites' = 1 + /\ unsafeSeed' = TRUE + /\ UNCHANGED <> + +Next == ReserveIntent \/ PublishExpected \/ RefuseLegacy \/ ArmSeed \/ CreateVolume \/ PublishMarker \/ ObserveEnrollment \/ CommitEnrollment \/ RefuseEnrollment \/ StartWorkload \/ Down \/ BeginResume \/ ReadWitness \/ RefuseResume \/ Crash \/ Recover \/ RecoveryDisposition \/ EmptyReplacement \/ WrongMarker \/ ForeignMetadata \/ DriftAuthority \/ SeedOnResume \/ SeedOnRecovery \/ ReplaceExpectation \/ ReenrollReadOnly +Spec == Init /\ [][Next]_vars +TypeOK == /\ intent \in BOOLEAN + /\ expected \in 0..2 + /\ originalExpected \in 0..1 + /\ enrolled \in 0..2 + /\ volumePresent \in BOOLEAN + /\ metadata \in 1..2 + /\ marker \in 0..2 + /\ phase \in {"cold", "intent", "expected", "armed", "created", "published", "observed", "enrolled", "running", "stopped", "checking", "checked", "crashed", "interrupted", "refused"} + /\ operation \in {"cold", "resume", "recovery"} + /\ seedCapability \in BOOLEAN + /\ completionCapability \in BOOLEAN + /\ proof \in BOOLEAN + /\ authority \in 1..2 + /\ crashes \in 0..1 + /\ resumes \in 0..1 + /\ seedWrites \in 0..2 + /\ enrollmentWrites \in 0..2 + /\ faulted \in BOOLEAN + /\ unsafeSeed \in BOOLEAN + /\ unsafeCompletion \in BOOLEAN + /\ unsafeStart \in BOOLEAN + /\ unsafeReplacement \in BOOLEAN + /\ unsafeReenrollment \in BOOLEAN + /\ disposition \in {"none", "needs-adoption", "needs-explicit-reconcile", "witness-mismatch"} +NoUnanchoredCapability == (seedCapability \/ completionCapability) => (intent /\ expected # 0) +NoSeedOnReopen == ~unsafeSeed +NoUnprovedCompletion == ~unsafeCompletion +NoWorkloadWithoutWitness == ~unsafeStart +NoChangedExpectation == ~unsafeReplacement /\ expected = originalExpected +NoReadOnlyReenrollment == ~unsafeReenrollment /\ enrollmentWrites <= 1 +NoRecoveredCapability == operation # "cold" => (~seedCapability /\ ~completionCapability) + +\* Required violations of these predicates demonstrate safe useful paths. +NeverColdWorkload == ~(phase = "running" /\ operation = "cold") +NeverReadOnlyResume == ~(phase = "running" /\ operation = "resume" + /\ resumes = 1 /\ seedWrites = 1 /\ enrollmentWrites = 1) +NeverEnrolledRecovery == ~(phase = "running" /\ operation = "recovery" + /\ crashes = 1 /\ seedWrites = 1 /\ enrollmentWrites = 1) +NeverEmptyIntentRefusal == + ~(phase = "refused" /\ operation = "recovery" /\ intent /\ expected = 0 + /\ enrolled = 0 /\ ~volumePresent /\ marker = 0 + /\ disposition = "needs-explicit-reconcile") +NeverInterruptedMissingRefusal == + ~(phase = "refused" /\ operation = "recovery" /\ expected = 1 /\ enrolled = 0 + /\ volumePresent /\ marker = 0 /\ metadata = 1 + /\ seedWrites = 0 /\ disposition = "needs-explicit-reconcile") +NeverInterruptedMatchingRefusal == + ~(phase = "refused" /\ operation = "recovery" /\ expected = 1 /\ enrolled = 0 + /\ volumePresent /\ marker = 1 /\ metadata = 1 + /\ seedWrites = 1 /\ disposition = "needs-explicit-reconcile") +NeverMissingResumeRefusal == + ~(phase = "refused" /\ operation = "resume" /\ expected = 1 /\ enrolled = 1 + /\ marker = 0 /\ metadata = 1 /\ disposition = "witness-mismatch") +NeverWrongResumeRefusal == + ~(phase = "refused" /\ operation = "resume" /\ expected = 1 /\ enrolled = 1 + /\ marker = 2 /\ metadata = 1 /\ disposition = "witness-mismatch") +NeverStaleProofRefusal == + ~(phase = "refused" /\ operation = "cold" /\ expected = 1 /\ enrolled = 0 + /\ marker = 0 /\ proof /\ disposition = "needs-explicit-reconcile") +NeverLegacyRefusal == ~(phase = "refused" /\ expected = 0 /\ volumePresent + /\ disposition = "needs-adoption") +======================================================================= diff --git a/tests/models/tla/native-storage-witness/cold-reachable.cfg b/tests/models/tla/native-storage-witness/cold-reachable.cfg new file mode 100644 index 00000000..fb7a851e --- /dev/null +++ b/tests/models/tla/native-storage-witness/cold-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverColdWorkload +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/empty-intent-refusal-reachable.cfg b/tests/models/tla/native-storage-witness/empty-intent-refusal-reachable.cfg new file mode 100644 index 00000000..6d44ffec --- /dev/null +++ b/tests/models/tla/native-storage-witness/empty-intent-refusal-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverEmptyIntentRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/expectation-replacement.cfg b/tests/models/tla/native-storage-witness/expectation-replacement.cfg new file mode 100644 index 00000000..3612879a --- /dev/null +++ b/tests/models/tla/native-storage-witness/expectation-replacement.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = FALSE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/foreign-metadata.cfg b/tests/models/tla/native-storage-witness/foreign-metadata.cfg new file mode 100644 index 00000000..f32f8dce --- /dev/null +++ b/tests/models/tla/native-storage-witness/foreign-metadata.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = FALSE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "foreign" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/interrupted-matching-reachable.cfg b/tests/models/tla/native-storage-witness/interrupted-matching-reachable.cfg new file mode 100644 index 00000000..c4db4189 --- /dev/null +++ b/tests/models/tla/native-storage-witness/interrupted-matching-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverInterruptedMatchingRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/interrupted-missing-reachable.cfg b/tests/models/tla/native-storage-witness/interrupted-missing-reachable.cfg new file mode 100644 index 00000000..53fb5867 --- /dev/null +++ b/tests/models/tla/native-storage-witness/interrupted-missing-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverInterruptedMissingRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/legacy-refusal-reachable.cfg b/tests/models/tla/native-storage-witness/legacy-refusal-reachable.cfg new file mode 100644 index 00000000..cf21720d --- /dev/null +++ b/tests/models/tla/native-storage-witness/legacy-refusal-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverLegacyRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/missing-resume-refusal-reachable.cfg b/tests/models/tla/native-storage-witness/missing-resume-refusal-reachable.cfg new file mode 100644 index 00000000..b41c7c82 --- /dev/null +++ b/tests/models/tla/native-storage-witness/missing-resume-refusal-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "empty" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverMissingResumeRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/missing-witness.cfg b/tests/models/tla/native-storage-witness/missing-witness.cfg new file mode 100644 index 00000000..223cda36 --- /dev/null +++ b/tests/models/tla/native-storage-witness/missing-witness.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = FALSE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "empty" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/negative.cfg b/tests/models/tla/native-storage-witness/negative.cfg new file mode 100644 index 00000000..2ee9e472 --- /dev/null +++ b/tests/models/tla/native-storage-witness/negative.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = FALSE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "empty" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/positive.cfg b/tests/models/tla/native-storage-witness/positive.cfg new file mode 100644 index 00000000..eaf2a4bb --- /dev/null +++ b/tests/models/tla/native-storage-witness/positive.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/premature-completion.cfg b/tests/models/tla/native-storage-witness/premature-completion.cfg new file mode 100644 index 00000000..9e525a47 --- /dev/null +++ b/tests/models/tla/native-storage-witness/premature-completion.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = FALSE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "empty" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/readonly-reenrollment.cfg b/tests/models/tla/native-storage-witness/readonly-reenrollment.cfg new file mode 100644 index 00000000..78f5e4ac --- /dev/null +++ b/tests/models/tla/native-storage-witness/readonly-reenrollment.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = FALSE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/recovery-reachable.cfg b/tests/models/tla/native-storage-witness/recovery-reachable.cfg new file mode 100644 index 00000000..71c92e83 --- /dev/null +++ b/tests/models/tla/native-storage-witness/recovery-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverEnrolledRecovery +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/recovery-seed.cfg b/tests/models/tla/native-storage-witness/recovery-seed.cfg new file mode 100644 index 00000000..88d23c3e --- /dev/null +++ b/tests/models/tla/native-storage-witness/recovery-seed.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = FALSE + UnsafeSeedOperation = "recovery" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/resume-reachable.cfg b/tests/models/tla/native-storage-witness/resume-reachable.cfg new file mode 100644 index 00000000..15739b9f --- /dev/null +++ b/tests/models/tla/native-storage-witness/resume-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverReadOnlyResume +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/stale-proof-refusal-reachable.cfg b/tests/models/tla/native-storage-witness/stale-proof-refusal-reachable.cfg new file mode 100644 index 00000000..b7207bcb --- /dev/null +++ b/tests/models/tla/native-storage-witness/stale-proof-refusal-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "all" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverStaleProofRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/wrong-resume-refusal-reachable.cfg b/tests/models/tla/native-storage-witness/wrong-resume-refusal-reachable.cfg new file mode 100644 index 00000000..6c00d28b --- /dev/null +++ b/tests/models/tla/native-storage-witness/wrong-resume-refusal-reachable.cfg @@ -0,0 +1,18 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = TRUE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "wrong" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability + NeverWrongResumeRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-storage-witness/wrong-witness.cfg b/tests/models/tla/native-storage-witness/wrong-witness.cfg new file mode 100644 index 00000000..f1467a94 --- /dev/null +++ b/tests/models/tla/native-storage-witness/wrong-witness.cfg @@ -0,0 +1,17 @@ +CONSTANTS EnforceSeedOnlyCold = TRUE + UnsafeSeedOperation = "resume" + EnforceWitness = FALSE + EnforceCompletionProof = TRUE + EnforceImmutableExpectation = TRUE + EnforceReadOnlyEnrollment = TRUE + FaultKind = "wrong" +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnanchoredCapability + NoSeedOnReopen + NoUnprovedCompletion + NoWorkloadWithoutWitness + NoChangedExpectation + NoReadOnlyReenrollment + NoRecoveredCapability +CHECK_DEADLOCK FALSE diff --git a/tests/tla-runtime-models.test.ts b/tests/tla-runtime-models.test.ts index d00d738f..46b8a32d 100644 --- a/tests/tla-runtime-models.test.ts +++ b/tests/tla-runtime-models.test.ts @@ -80,6 +80,114 @@ test("runtime model config selection rejects paths and arbitrary filenames", () } }); +test("storage witness controls distinguish missing bytes from foreign metadata", () => { + const model = runtimeModels.find( + (entry) => entry.name === "native-storage-witness" + ); + for (const name of ["missing-witness", "wrong-witness", "foreign-metadata"]) { + const control = model?.additionalControls.find( + (entry) => entry.name === name + ); + expect(control).toBeDefined(); + if (!control) { + throw new Error("Missing storage witness control"); + } + const valid = tlaWitness(control); + expect( + control.verify({ negative: true, exitCode: 12, output: valid }) + ).toBe(true); + for (const invalid of [ + valid.replace("enrolled = 1", "enrolled = 0"), + valid.replace("unsafeStart = TRUE", "unsafeStart = FALSE"), + valid.replace("\n/\\ marker =" + ), + `${valid}\nError: unrelated checker failure`, + ]) { + expect( + control.verify({ negative: true, exitCode: 12, output: invalid }) + ).toBe(false); + } + } +}); + +test("storage witness resume and recovery evidence retains read-only authority", () => { + const model = runtimeModels.find( + (entry) => entry.name === "native-storage-witness" + ); + for (const name of [ + "resume-reachable", + "recovery-reachable", + "interrupted-missing-reachable", + "interrupted-matching-reachable", + "recovery-seed", + ]) { + const control = model?.additionalControls.find( + (entry) => entry.name === name + ); + expect(control).toBeDefined(); + if (!control) { + throw new Error("Missing storage witness control"); + } + const valid = tlaWitness(control); + expect( + control.verify({ negative: true, exitCode: 12, output: valid }) + ).toBe(true); + for (const invalid of [ + valid.replace("seedCapability = FALSE", "seedCapability = TRUE"), + valid.replace( + "completionCapability = FALSE", + "completionCapability = TRUE" + ), + valid + .replace("originalExpected = 1", "originalExpected = 2") + .replace("expected = 1", "expected = 2"), + valid.replace( + "/\\ completionCapability = FALSE", + "State 3: \n/\\ completionCapability = FALSE" + ), + ]) { + expect( + control.verify({ negative: true, exitCode: 12, output: invalid }) + ).toBe(false); + } + } +}); + +test("interrupted empty witness slot evidence cannot imply enrollment or effects", () => { + const model = runtimeModels.find( + (entry) => entry.name === "native-storage-witness" + ); + const control = model?.additionalControls.find( + (entry) => entry.name === "empty-intent-refusal-reachable" + ); + expect(control).toBeDefined(); + if (!control) { + throw new Error("Missing storage witness control"); + } + const valid = tlaWitness(control); + expect(control.verify({ negative: true, exitCode: 12, output: valid })).toBe( + true + ); + for (const invalid of [ + valid.replace("intent = TRUE", "intent = FALSE"), + valid.replace("expected = 0", "expected = 1"), + valid.replace("enrolled = 0", "enrolled = 1"), + valid.replace("volumePresent = FALSE", "volumePresent = TRUE"), + valid.replace("seedWrites = 0", "seedWrites = 1"), + valid.replace( + 'disposition = "needs-explicit-reconcile"', + 'disposition = "none"' + ), + ]) { + expect( + control.verify({ negative: true, exitCode: 12, output: invalid }) + ).toBe(false); + } +}); + test("registry ownership control requires live successor deletion in the same Reap state", () => { const model = runtimeModels.find((entry) => entry.name === "registry-writer"); expect(model).toBeDefined(); From 2449a6257016a84f314ec502449ae4f44d7bbca2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 10:40:26 -0400 Subject: [PATCH 09/23] docs: map witness model to required receipt foundation --- tests/models/tla/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index afcac090..f9d8e669 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -19,9 +19,9 @@ No credentials or running VM are needed. ## Native storage witness enrollment and read-only resume `native-storage-witness/Witness.tla` checks a proposed storage continuity protocol. -This maintained model is separate from the storage-witness owner/codec foundation -and from its future command, receipt and engine-carrier integration. It does not -qualify those implementations. The positive configuration exhausts **352 distinct +This maintained model is separate from the storage-witness owner, codec and +required version-three generation-receipt foundation. It does not qualify those +implementations or their future engine carrier and CLI activation. The positive configuration exhausts **352 distinct states** (550 generated, maximum depth 18), with four initial states: absent storage, or untracked existing storage with a missing, matching or wrong marker. Existing untracked storage requires explicit adoption; matching bytes alone do @@ -62,12 +62,12 @@ fail the runner. | Model action/state | Intended foundation mapping and separate implementation obligation | | --- | --- | -| `ReserveIntent` / `intent` | `prepareNativeComposeStorageWitness` exclusively creates and synchronizes a private per-volume slot before writing the expectation. An empty interrupted slot remains retained and cannot be recreated. This is distinct from the future mandatory generation-receipt enrollment intent; that command/receipt wiring is not implemented by the foundation. | +| `ReserveIntent` / `intent` | `prepareNativeComposeStorageWitness` first calls `armNativeComposeStorageWitnessIntent` to publish a required v3 `Expected` entry under the existing generation mutation, then exclusively creates and synchronizes the private per-volume slot before writing the expectation. An empty or absent interrupted slot retains the receipt intent and cannot be recreated. The model's slot intent abstracts that refusal; it does not model the enclosing generation receipt. | | `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` in the separate `native-compose-storage-witness.ts` foundation: validate active pending-generation authority, exclusively publish/sync an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then return an opaque live capability. A failed publication must not grant one. | | `ArmSeed` / `PublishMarker` | `enrollNativeComposeStorageWitness` consumes the original capability before awaiting its single seed callback. Callback transport, create-only behavior, child ownership/reaping and no writes to an existing witness require implementation and carrier tests. `CreateVolume` is an abstract cold-creation boundary, not a claim that the foundation creates Docker volumes. | -| `ObserveEnrollment` / `CommitEnrollment` | The foundation verifies one observed archive through `verifyNativeComposeStorageWitnessArchive`, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. The model additionally requires the current exact witness at commit; qualifying that predicate across actual carrier awaits remains a separate engine-boundary obligation. Only the exact expected bytes and binding may form its private reference. The model's completion is this enrollment record, not a generation's ready receipt. | -| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh carrier observation and exact archive, then re-reads and rechecks ownership without writing. Ordinary command/receipt wiring and actual down/up are separate unqualified gates. | -| `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. A future explicit read-only reconciliation interface requires its own source review and tests; this model does not implement it. | +| `ObserveEnrollment` / `CommitEnrollment` | The owner verifies an observed archive through `verifyNativeComposeStorageWitnessArchive`, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. It issues a private one-use proof bound to the exact live authority and generation; `publishNativeComposeStorageWitnessEnrollment` consumes only that issued proof and obtains its reference and fresh verifier from the owner. `createNativeComposeStorageWitnessReceiptProtocol` verifies again before attaching `Enrolled` and rechecks live authority after the final filesystem await before receipt publication. The model additionally requires the current exact witness at commit; qualifying that predicate across actual carrier awaits remains a separate engine-boundary obligation. Its completion is the enrollment record, not a generation's ready receipt. | +| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh carrier observation and exact archive, then re-reads and rechecks ownership without writing. The receipt protocol requires read-only proof before/after startup/run and at both completion receipt boundaries. The CLI refuses witness-bearing startup/run/exec while carrier authority is withheld; actual down/up activation remains unqualified. | +| `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. Explicit saved `down --recover` can stop engine resources but returns incomplete with `Expected` and pending generation retained, skipping dependent ownership retirement. An explicit read-only reconciliation interface requires its own source review and tests; neither this model nor the foundation implements it. | | `EmptyReplacement` / `WrongMarker` / `ForeignMetadata` | Owner/codec tests must distinguish absent/wrong bytes, fresh foreign binding, a same-metadata empty replacement and a copied witness. `encodeNativeComposeStorageWitnessArchive` is the proposed carrier codec; decoding bounds, archive path validation and read-only engine transport are outside this model. | | `DriftAuthority` | Existing opaque generation authority must reject changed pending selection or revoked lease before seed/completion, and recheck after asynchronous proofs. The model summarizes those identities by one version; it cannot qualify real lease/token/inode checks. | | `ReplaceExpectation` / `ReenrollReadOnly` | Immutable anchored records must reject replacement and read-only reopen must not append enrollment, even with matching marker bytes. Filesystem substitution and exact write counts require separate source regressions. | @@ -78,7 +78,7 @@ two distinguishable nonempty markers; marker `1` abstracts expected random bytes or subsequent workload admission, including between observation and completion. It is not an active watcher for corruption after a workload has started. Each durable publication is modeled as one completed write; partial writes, fsync -durability, archive bytes/limits, entropy, secrecy, filesystem symlinks/inode reuse, +durability, generation receipt decoding/publication, archive bytes/limits, entropy, secrecy, filesystem symlinks/inode reuse, malicious same-user replacement, multiple-volume concurrency, repeated crashes, real resource creation and child effects are omitted. Current resource and owner proofs are abstract predicates; arbitrary concurrent engine changes and atomicity From 9829037199d1b0344b6a8627fb5d558d44841a0a Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 11:56:08 -0400 Subject: [PATCH 10/23] feat(runtime): define directory xattr witness carrier source --- .../native-compose-storage-witness.md | 5 + .../native-compose-storage-xattr-carrier.md | 157 ++++++ scripts/native-storage-witness-helper.ts | 81 +++ ...ive-compose-storage-witness-xattr-codec.ts | 267 +++++++++ ...ve-compose-storage-witness-xattr-helper.ts | 133 +++++ ...ive-compose-storage-witness-xattr-linux.ts | 220 ++++++++ ...tive-compose-storage-witness-xattr.test.ts | 527 ++++++++++++++++++ 7 files changed, 1390 insertions(+) create mode 100644 docs/reference/native-compose-storage-xattr-carrier.md create mode 100644 scripts/native-storage-witness-helper.ts create mode 100644 src/lib/native-compose-storage-witness-xattr-codec.ts create mode 100644 src/lib/native-compose-storage-witness-xattr-helper.ts create mode 100644 src/lib/native-compose-storage-witness-xattr-linux.ts create mode 100644 tests/native-compose-storage-witness-xattr.test.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index f4359fbe..95944bd9 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -76,3 +76,8 @@ compiled CLI coverage and real engine acceptance remain required gates. No carri or helper download is implemented by this foundation. Store and source-CLI tests use synthetic observation ports and engine transports; they do not qualify Docker write or archive semantics. + +The separate [directory-xattr helper source](native-compose-storage-xattr-carrier.md) +explores a create-only token without a PostgreSQL data-root directory entry. It +has a distinct codec and no Docker transport, receipt integration or CLI activation. +Its offline syscall controls do not qualify a carrier. diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md new file mode 100644 index 00000000..60065bcb --- /dev/null +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -0,0 +1,157 @@ +# Native Compose directory-xattr carrier source + +This is an unactivated Linux helper source and an offline protocol. It does not +enroll CLI volumes or qualify an engine transport. The [witness foundation](native-compose-storage-witness.md) +still accepts only its regular-file/USTAR format. Ordinary CLI instances retain +the metadata guard's same-metadata replacement limitation. + +The proposed directory xattr avoids adding a directory entry to PostgreSQL's +authored data root. It detects a replacement missing its enrolled random witness; +it does not prove database integrity or detect a clone containing that witness. +No `PGDATA` rewrite, root marker directory, package install or helper image pull +is part of this source. + +## Fixed helper contract + +`scripts/native-storage-witness-helper.ts` is the future helper entry. The CLI +does not invoke it. A qualified artifact must bundle this entry with its strict +codec and kernel adapter; an exact declared local Linux image, Bun executable, +libc and program digest must be checked before any carrier effect. The candidate +dependency is an explicitly selected local `oven/bun:1.4.2-slim` image. A tag +alone is not an artifact pin. Bun FFI remains experimental; source inspection and +mocked ports do not qualify its behavior. + +The input is one canonical JSON record, at most 4096 bytes, with required +`kind: "directory-xattr"` and `version: 1`. Output is at most 1024 bytes. Names +contain `user.hack.storage.` plus 256 random bits; values contain an independent +32-byte random token. Both travel only through bounded stdin/private stdout, +never command arguments, inherited environment, public errors or Docker logs. +Every failure emits only the fixed refused response and returns a failure code. +The enclosing carrier must set `logging=none` before start and privately capture +both streams. + +The only operations are metadata discovery, seed and verify. There is no +enumeration, replace, remove, repair or caller-selected path. Device and inode +facts use canonical unsigned 64-bit decimal strings; UID/GID are unsigned +32-bit integers. Unknown kinds/versions, alternate encodings, duplicate JSON +keys, trailing data and extra fields refuse. This format cannot be relabeled as +the foundation's USTAR proof or attached to its existing completion reference. +An explicit required reference/expectation codec change and owner review must +precede xattr receipt integration; older owners must continue to refuse it. + +The Linux adapter fixes the root at `/hack-storage-witness`. It checks Linux +arm64 or x64 open constants and fixed architecture-specific glibc paths, uses +64-bit `size_t`/`ssize_t`, and checks the directory descriptor's close-on-exec +flag. It opens with `O_RDONLY | O_DIRECTORY | O_NOFOLLOW`, checks descriptor +metadata and requires real/effective credentials to agree. `O_NOFOLLOW` covers +the leaf; trusted image parents and the daemon-side source chain remain separate +mount admission requirements. + +Seed requires the directory's exact UID/GID, calls `fsetxattr` with +`XATTR_CREATE` once, requires directory `fsync`, and rechecks root facts. An +existing attribute, including empty, wrong or matching contents, refuses without +overwrite. There is no write retry. A synchronization or close failure is an +uncertain attempt. A `seeded` response is not enrollment, durability proof or +generation readiness: the owner must obtain a separate fresh read-only proof +before publishing completion. + +Verify requires matching credentials and root facts, performs two exact bounded +`fgetxattr` reads with intervening/final root checks, and closes the descriptor. +It never writes or synchronizes storage. Absent, empty, oversized, wrong or +changing bytes refuse. These observations are finite fences, not an atomic +guard against later changes or a hostile daemon. UID/GID discovery only selects +an explicitly authorized reader; it is not witness proof. The reader must match +the same freshly observed root. Cold UID 0 to PostgreSQL UID 70 is covered by +mocked credential controls; real chown, mode 0700 and xattr access remain live +qualification cells. There is no capability or ownership-changing fallback. + +## Required cold provisioning and receipt order + +`startNativeComposeWorkloads` currently launches one Compose `up -d` child. +Carrier activation must add the following boundary before that child, under the +same existing instance mutation and generation effect authority: + +1. Reacquire source, engine identity and exact owned inventory. Initial admission + requires no retained storage history or earlier witness intent, the selected + physical name absent, and no conflicting pending operation. Explicit adoption + instead pins an already selected stopped volume and its birth; it cannot claim + historical continuity. Existing v1/v2 or interrupted state never becomes cold + merely because its data volume is missing. +2. Publish required `Expected` and the immutable expectation before provisioning. + Cold intent contains the prospective name and no invented birth. The original + one-shot enrollment capability is consumed before its effect callback. +3. Provision only that admitted cold volume with exact owner/storage labels. + Check the create result, then freshly inspect and capture the actual birth, + local driver/options, current Mountpoint and holders before seed. Idempotent + `volume create` is not proof of original cold admission. No recovery invocation + gains provisioning or seed authority. The final cold transaction and engine + observation contract remain unimplemented and require review. +4. Run one create-only seed while all selected workloads remain stopped and no + foreign or unaccounted holder exists. Require stopped, exact owned helper + absence before non-force helper removal. Obtain fresh read-only marker and + metadata proof, then let the owner publish its opaque completion proof and + required `Enrolled` reference. +5. Recheck witness/source/owner immediately before Compose workload admission and + after startup, as well as both final receipt boundaries. Read-only proofs may + coexist with only the exact admitted running generation's holders. They never + stop an application to obtain a proof or omit the existing final fences. + +Any crash before enrollment keeps `Expected`, even if the token matches. Saved +recovery may stop exact owned resources but must preserve the intent and pending +anchor, report incomplete and skip dependent retirement. No automatic promotion, +reseed, journal rewrite or volume deletion is authorized by a successful helper +response. + +## Non-creating transport prerequisite + +A Docker named-volume mount can auto-create an absent name even when readonly +and `volume-nocopy`; it is not an admitted resume transport. The candidate is a +fresh non-creating `--mount type=bind` of the exact current qualified local +volume's daemon-side Mountpoint, with `readonly,bind-recursive=disabled`, no +`bind-create-src`, no `-v` and no fallback. Write access is permitted only for the +original seed attempt. Local driver, empty options, canonical source/parent chain, +engine support and exact volume-root correspondence must all be qualified. A +missing source refuses without creating a volume or directory. + +Each proof must create a fresh carrier from a freshly checked current root. An +old carrier can retain a removed root even when the replacement repeats its +name, birth and Mountpoint string. Do not accept its token or reuse that pinned +mount across operations. Metadata discovery and the matching-UID reader must +each be bracketed by fresh engine/birth/Mountpoint/holder checks. Drift refuses; +there is no UID retry loop or permission repair. + +Durable carrier intent, exact image/program/ID labels, fixed mount correspondence, +no image-declared anonymous volumes, dropped capabilities, no network/ports/socket, +readonly root filesystem, finite CPU/memory/PID limits and absolute deadline/group +reaping are mandatory before implementing the engine owner. Cancellation or an +unknown create/start outcome keeps the anchor. Cleanup must prove the exact helper +stopped before non-force removal and never remove data, witness, material or +journals. This source contains no Docker transport or helper cleanup implementation. + +## Evidence limits and remaining gates + +Offline controls substitute synchronous syscall ports. They check strict input, +one create request, no overwrite/repair requests, missing/wrong witness under +unchanged metadata, root/credential drift, callback capture and uncertain fsync +or close. They do not execute the Linux adapter or qualify kernel atomicity, +permissions, xattr persistence, filesystem crash durability or Docker semantics. + +Before activation: qualify exact source/artifact/image/Bun/libc ABI and filesystem; +non-creating current-root bind behavior; create-only existing/symlink/unsupported +refusals; unchanged PostgreSQL root initialization, real SQL retention, chown and +post-start proofs; same-metadata replacement including an old pinned carrier; +crash/timeout/cancellation with exact helper absence and retained uncertainty; +no daemon-log token persistence; full original engine/resource restoration. + +Count every helper process, container and query. The first straightforward proof +needs metadata discovery followed by a matching-UID read; initial enrollment adds +a write helper and a separate read-only proof. Required pre/post/final proofs +therefore have a real startup cost. No performance improvement or production +activation is claimed, and no persistent helper is proposed. + +Primary contracts: Linux [fsetxattr](https://man7.org/linux/man-pages/man2/fsetxattr.2.html), +[fgetxattr](https://man7.org/linux/man-pages/man2/getxattr.2.html), +[fsync](https://man7.org/linux/man-pages/man2/fsync.2.html), +[arm64 open flags](https://github.com/torvalds/linux/blob/master/arch/arm64/include/uapi/asm/fcntl.h), +[generic open flags](https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/fcntl.h), +[Bun FFI](https://bun.sh/docs/runtime/ffi), and Docker [bind mounts](https://docs.docker.com/engine/storage/bind-mounts/). diff --git a/scripts/native-storage-witness-helper.ts b/scripts/native-storage-witness-helper.ts new file mode 100644 index 00000000..e8c65ec2 --- /dev/null +++ b/scripts/native-storage-witness-helper.ts @@ -0,0 +1,81 @@ +import { + encodeNativeComposeStorageXattrResponse, + NATIVE_STORAGE_XATTR_INPUT_LIMIT, + NATIVE_STORAGE_XATTR_KIND, + NATIVE_STORAGE_XATTR_VERSION, + type NativeComposeStorageXattrResponse, + parseNativeComposeStorageXattrRequest, + refuseNativeComposeStorageXattr, +} from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; +import { runNativeComposeStorageXattrHelper } from "../src/lib/native-compose-storage-witness-xattr-helper.ts"; +import { createNativeComposeStorageXattrLinuxKernel } from "../src/lib/native-compose-storage-witness-xattr-linux.ts"; + +async function input(): Promise { + const reader = Bun.stdin.stream().getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + length += next.value.byteLength; + if (length > NATIVE_STORAGE_XATTR_INPUT_LIMIT) { + await reader.cancel(); + return refuseNativeComposeStorageXattr(); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder("utf-8", { fatal: true }).decode( + Buffer.concat(chunks) + ); +} +function refused(): NativeComposeStorageXattrResponse { + return { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "refused", + }; +} +/** Bundling and exact artifact/ABI/engine qualification are prerequisites; this entry is never launched by the CLI. */ +export async function nativeStorageWitnessHelperMain(): Promise { + let response = refused(); + let close: (() => void) | null = null; + try { + const request = parseNativeComposeStorageXattrRequest(await input()); + const selected = await createNativeComposeStorageXattrLinuxKernel(); + close = selected.close; + response = runNativeComposeStorageXattrHelper({ + request, + kernel: selected.kernel, + }); + } catch { + response = refused(); + } finally { + if (close) { + try { + close(); + } catch { + response = refused(); + } + } + } + // The owner must force logging=none and capture this bounded response privately. + try { + await Bun.write( + Bun.stdout, + encodeNativeComposeStorageXattrResponse(response) + ); + } catch { + // A truncated private response is uncertain, not a successful helper proof. + return 1; + } + return response.outcome === "refused" ? 1 : 0; +} +if (import.meta.main) { + process.exitCode = await nativeStorageWitnessHelperMain(); +} diff --git a/src/lib/native-compose-storage-witness-xattr-codec.ts b/src/lib/native-compose-storage-witness-xattr-codec.ts new file mode 100644 index 00000000..5443d280 --- /dev/null +++ b/src/lib/native-compose-storage-witness-xattr-codec.ts @@ -0,0 +1,267 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { isRecord } from "./guards.ts"; + +/** Distinct from the regular-file/USTAR carrier; never reinterpret one as the other. */ +export const NATIVE_STORAGE_XATTR_KIND = "directory-xattr" as const; +export const NATIVE_STORAGE_XATTR_VERSION = 1 as const; +export const NATIVE_STORAGE_XATTR_BYTES = 32; +export const NATIVE_STORAGE_XATTR_INPUT_LIMIT = 4096; +export const NATIVE_STORAGE_XATTR_OUTPUT_LIMIT = 1024; +const NAME = /^user\.hack\.storage\.[a-f0-9]{64}$/; +const VALUE = /^[a-f0-9]{64}$/; +const DECIMAL = /^(0|[1-9][0-9]{0,19})$/; +const UINT64_MAX = 18_446_744_073_709_551_615n; +const UINT32_MAX = 4_294_967_295; + +export type NativeComposeStorageXattrRoot = { + readonly device: string; + readonly inode: string; + readonly uid: number; + readonly gid: number; +}; +export type NativeComposeStorageXattrMarker = { + readonly kind: typeof NATIVE_STORAGE_XATTR_KIND; + readonly version: typeof NATIVE_STORAGE_XATTR_VERSION; + readonly name: string; + readonly valueHex: string; +}; +export type NativeComposeStorageXattrRequest = + | { + readonly kind: typeof NATIVE_STORAGE_XATTR_KIND; + readonly version: typeof NATIVE_STORAGE_XATTR_VERSION; + readonly operation: "root"; + } + | (NativeComposeStorageXattrMarker & { + readonly operation: "seed" | "verify"; + readonly root: NativeComposeStorageXattrRoot; + }); +export type NativeComposeStorageXattrResponse = + | { + readonly kind: typeof NATIVE_STORAGE_XATTR_KIND; + readonly version: typeof NATIVE_STORAGE_XATTR_VERSION; + readonly outcome: "refused"; + } + | { + readonly kind: typeof NATIVE_STORAGE_XATTR_KIND; + readonly version: typeof NATIVE_STORAGE_XATTR_VERSION; + readonly outcome: "root" | "seeded"; + readonly root: NativeComposeStorageXattrRoot; + } + | { + readonly kind: typeof NATIVE_STORAGE_XATTR_KIND; + readonly version: typeof NATIVE_STORAGE_XATTR_VERSION; + readonly outcome: "verified"; + readonly root: NativeComposeStorageXattrRoot; + readonly valueHex: string; + }; + +export function refuseNativeComposeStorageXattr(): never { + throw new Error("Native storage xattr proof refused; values omitted."); +} +function keys(value: Record, expected: string): boolean { + return Object.keys(value).sort().join(",") === expected; +} +function uint32(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + !Object.is(value, -0) && + value >= 0 && + value <= UINT32_MAX + ); +} +function uint64(value: unknown): value is string { + return ( + typeof value === "string" && + DECIMAL.test(value) && + BigInt(value) <= UINT64_MAX + ); +} +export function nativeComposeStorageXattrRootValid( + value: unknown +): value is NativeComposeStorageXattrRoot { + return ( + isRecord(value) && + keys(value, "device,gid,inode,uid") && + uint64(value.device) && + uint64(value.inode) && + value.inode !== "0" && + uint32(value.uid) && + uint32(value.gid) + ); +} +export function sameNativeComposeStorageXattrRoot( + left: NativeComposeStorageXattrRoot, + right: NativeComposeStorageXattrRoot +): boolean { + return ( + left.device === right.device && + left.inode === right.inode && + left.uid === right.uid && + left.gid === right.gid + ); +} +function markerValid( + value: Record +): value is Record & NativeComposeStorageXattrMarker { + return ( + value.kind === NATIVE_STORAGE_XATTR_KIND && + value.version === NATIVE_STORAGE_XATTR_VERSION && + typeof value.name === "string" && + NAME.test(value.name) && + typeof value.valueHex === "string" && + VALUE.test(value.valueHex) + ); +} +export function createNativeComposeStorageXattrMarker(): NativeComposeStorageXattrMarker { + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + name: `user.hack.storage.${randomBytes(32).toString("hex")}`, + valueHex: randomBytes(NATIVE_STORAGE_XATTR_BYTES).toString("hex"), + }); +} +export function decodeNativeComposeStorageXattrRequest( + value: unknown +): NativeComposeStorageXattrRequest { + if (!isRecord(value)) { + return refuseNativeComposeStorageXattr(); + } + if ( + keys(value, "kind,operation,version") && + value.kind === NATIVE_STORAGE_XATTR_KIND && + value.version === NATIVE_STORAGE_XATTR_VERSION && + value.operation === "root" + ) { + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + operation: "root", + }); + } + if ( + !( + keys(value, "kind,name,operation,root,valueHex,version") && + markerValid(value) && + (value.operation === "seed" || value.operation === "verify") && + nativeComposeStorageXattrRootValid(value.root) + ) + ) { + return refuseNativeComposeStorageXattr(); + } + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + operation: value.operation, + name: value.name, + valueHex: value.valueHex, + root: Object.freeze({ ...value.root }), + }); +} +export function decodeNativeComposeStorageXattrResponse( + value: unknown +): NativeComposeStorageXattrResponse { + if ( + !( + isRecord(value) && + value.kind === NATIVE_STORAGE_XATTR_KIND && + value.version === NATIVE_STORAGE_XATTR_VERSION + ) + ) { + return refuseNativeComposeStorageXattr(); + } + if (value.outcome === "refused" && keys(value, "kind,outcome,version")) { + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "refused", + }); + } + if (!nativeComposeStorageXattrRootValid(value.root)) { + return refuseNativeComposeStorageXattr(); + } + const root = Object.freeze({ ...value.root }); + if ( + (value.outcome === "root" || value.outcome === "seeded") && + keys(value, "kind,outcome,root,version") + ) { + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: value.outcome, + root, + }); + } + if ( + value.outcome === "verified" && + keys(value, "kind,outcome,root,valueHex,version") && + typeof value.valueHex === "string" && + VALUE.test(value.valueHex) + ) { + return Object.freeze({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "verified", + root, + valueHex: value.valueHex, + }); + } + return refuseNativeComposeStorageXattr(); +} +export function encodeNativeComposeStorageXattrRequest( + value: NativeComposeStorageXattrRequest +): string { + return `${JSON.stringify(decodeNativeComposeStorageXattrRequest(value))}\n`; +} +export function encodeNativeComposeStorageXattrResponse( + value: NativeComposeStorageXattrResponse +): string { + return `${JSON.stringify(decodeNativeComposeStorageXattrResponse(value))}\n`; +} +function parse( + text: string, + limit: number, + decode: (value: unknown) => T +): T { + if (Buffer.byteLength(text) > limit) { + return refuseNativeComposeStorageXattr(); + } + try { + const value = decode(JSON.parse(text)); + // One canonical member rejects duplicate keys, suffixes and alternate encodings. + if (text !== `${JSON.stringify(value)}\n`) { + return refuseNativeComposeStorageXattr(); + } + return value; + } catch { + return refuseNativeComposeStorageXattr(); + } +} +export function parseNativeComposeStorageXattrRequest( + text: string +): NativeComposeStorageXattrRequest { + return parse( + text, + NATIVE_STORAGE_XATTR_INPUT_LIMIT, + decodeNativeComposeStorageXattrRequest + ); +} +export function parseNativeComposeStorageXattrResponse( + text: string +): NativeComposeStorageXattrResponse { + return parse( + text, + NATIVE_STORAGE_XATTR_OUTPUT_LIMIT, + decodeNativeComposeStorageXattrResponse + ); +} +export function nativeComposeStorageXattrValueMatches( + actual: Uint8Array, + expectedHex: string +): boolean { + return ( + actual.byteLength === NATIVE_STORAGE_XATTR_BYTES && + VALUE.test(expectedHex) && + timingSafeEqual(actual, Buffer.from(expectedHex, "hex")) + ); +} diff --git a/src/lib/native-compose-storage-witness-xattr-helper.ts b/src/lib/native-compose-storage-witness-xattr-helper.ts new file mode 100644 index 00000000..149918c4 --- /dev/null +++ b/src/lib/native-compose-storage-witness-xattr-helper.ts @@ -0,0 +1,133 @@ +import { + decodeNativeComposeStorageXattrRequest, + NATIVE_STORAGE_XATTR_BYTES, + NATIVE_STORAGE_XATTR_KIND, + NATIVE_STORAGE_XATTR_VERSION, + type NativeComposeStorageXattrResponse, + type NativeComposeStorageXattrRoot, + nativeComposeStorageXattrRootValid, + nativeComposeStorageXattrValueMatches, + refuseNativeComposeStorageXattr as refuse, + sameNativeComposeStorageXattrRoot, +} from "./native-compose-storage-witness-xattr-codec.ts"; + +/** Synchronous fixed-root syscall boundary. Offline tests substitute ports; no engine transport lives here. */ +export type NativeComposeStorageXattrKernel = { + readonly effectiveUid: () => number; + readonly effectiveGid: () => number; + readonly probeRoot: () => NativeComposeStorageXattrRoot; + readonly openRoot: () => number; + readonly statRoot: (fd: number) => NativeComposeStorageXattrRoot; + readonly createXattr: (fd: number, name: string, value: Uint8Array) => void; + readonly syncRoot: (fd: number) => void; + readonly readXattr: (fd: number, name: string, bytes: number) => Uint8Array; + readonly closeRoot: (fd: number) => void; +}; + +function root(value: unknown): NativeComposeStorageXattrRoot { + if (!nativeComposeStorageXattrRootValid(value)) { + return refuse(); + } + return Object.freeze({ ...value }); +} +function requireRoot( + actual: NativeComposeStorageXattrRoot, + expected: NativeComposeStorageXattrRoot +): void { + if (!sameNativeComposeStorageXattrRoot(root(actual), expected)) { + refuse(); + } +} +/** Never reports enrollment or generation readiness. Only the enclosing owner can publish either. */ +export function runNativeComposeStorageXattrHelper(opts: { + readonly request: unknown; + readonly kernel: NativeComposeStorageXattrKernel; +}): NativeComposeStorageXattrResponse { + let fd: number | null = null; + let capturedClose: ((fd: number) => void) | null = null; + try { + const request = decodeNativeComposeStorageXattrRequest(opts.request); + const { + effectiveUid, + effectiveGid, + probeRoot, + openRoot, + statRoot, + createXattr, + syncRoot, + readXattr, + closeRoot, + } = opts.kernel; + capturedClose = closeRoot; + if (request.operation === "root") { + const before = root(probeRoot()); + requireRoot(probeRoot(), before); + return { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "root", + root: before, + }; + } + if ( + effectiveUid() !== request.root.uid || + effectiveGid() !== request.root.gid + ) { + return refuse(); + } + fd = openRoot(); + if (!Number.isInteger(fd) || fd < 0) { + return refuse(); + } + requireRoot(statRoot(fd), request.root); + let response: NativeComposeStorageXattrResponse; + if (request.operation === "seed") { + createXattr(fd, request.name, Buffer.from(request.valueHex, "hex")); + syncRoot(fd); + requireRoot(statRoot(fd), request.root); + response = { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "seeded", + root: request.root, + }; + } else { + const first = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES); + if (!nativeComposeStorageXattrValueMatches(first, request.valueHex)) { + return refuse(); + } + requireRoot(statRoot(fd), request.root); + const latest = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES); + if (!nativeComposeStorageXattrValueMatches(latest, request.valueHex)) { + return refuse(); + } + requireRoot(statRoot(fd), request.root); + response = { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "verified", + root: request.root, + valueHex: Buffer.from(latest).toString("hex"), + }; + } + // A failed close is not a completed helper proof, and must never be hidden. + const selected = fd; + fd = null; + closeRoot(selected); + return response; + } catch { + return { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "refused", + }; + } finally { + if (fd !== null && Number.isInteger(fd) && fd >= 0 && capturedClose) { + try { + capturedClose(fd); + } catch { + // The fixed refusal already represents uncertain completion; no error text escapes. + } + } + } +} diff --git a/src/lib/native-compose-storage-witness-xattr-linux.ts b/src/lib/native-compose-storage-witness-xattr-linux.ts new file mode 100644 index 00000000..bf7816c1 --- /dev/null +++ b/src/lib/native-compose-storage-witness-xattr-linux.ts @@ -0,0 +1,220 @@ +import { + type BigIntStats, + closeSync, + constants, + fstatSync, + fsyncSync, + lstatSync, + openSync, +} from "node:fs"; +import { + NATIVE_STORAGE_XATTR_BYTES, + type NativeComposeStorageXattrRoot, + nativeComposeStorageXattrRootValid, + refuseNativeComposeStorageXattr as refuse, +} from "./native-compose-storage-witness-xattr-codec.ts"; +import type { NativeComposeStorageXattrKernel } from "./native-compose-storage-witness-xattr-helper.ts"; + +const XATTR_NAME = /^user\.hack\.storage\.[a-f0-9]{64}$/; + +/** Source contract only. Image/Bun/libc/program hashes and engine mounts require external qualification. */ +export const NATIVE_STORAGE_XATTR_LINUX_ABI = Object.freeze({ + version: 1, + root: "/hack-storage-witness", + xattrCreate: 1, + fGetFd: 1, + fdCloseExec: 1, + pointerBits: 64, + sizeTBits: 64, + signedSizeBits: 64, + arm64: Object.freeze({ + libc: "/lib/aarch64-linux-gnu/libc.so.6", + directory: 1 << 14, + noFollow: 1 << 15, + }), + x64: Object.freeze({ + libc: "/lib/x86_64-linux-gnu/libc.so.6", + directory: 1 << 16, + noFollow: 1 << 17, + }), +}); + +function directory(info: BigIntStats): NativeComposeStorageXattrRoot { + if (!info.isDirectory() || info.isSymbolicLink()) { + return refuse(); + } + const value = { + device: info.dev.toString(), + inode: info.ino.toString(), + uid: Number(info.uid), + gid: Number(info.gid), + }; + if (!nativeComposeStorageXattrRootValid(value)) { + return refuse(); + } + return Object.freeze(value); +} +function libc(): string { + let abi: { + readonly libc: string; + readonly directory: number; + readonly noFollow: number; + } | null = null; + if (process.arch === "arm64") { + abi = NATIVE_STORAGE_XATTR_LINUX_ABI.arm64; + } else if (process.arch === "x64") { + abi = NATIVE_STORAGE_XATTR_LINUX_ABI.x64; + } + if ( + process.platform !== "linux" || + !abi || + constants.O_DIRECTORY !== abi.directory || + constants.O_NOFOLLOW !== abi.noFollow || + constants.O_RDONLY !== 0 || + !process.geteuid || + !process.getegid || + !process.getuid || + !process.getgid || + process.getuid() !== process.geteuid() || + process.getgid() !== process.getegid() + ) { + return refuse(); + } + return abi.libc; +} + +/** Never called by the CLI or offline controls. A qualified Linux helper is its only intended caller. */ +export async function createNativeComposeStorageXattrLinuxKernel(): Promise<{ + readonly kernel: NativeComposeStorageXattrKernel; + readonly close: () => void; +}> { + const libraryPath = libc(); + const { dlopen, FFIType, ptr } = await import("bun:ffi"); + const library = dlopen(libraryPath, { + fsetxattr: { + args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.i32], + returns: FFIType.i32, + }, + fgetxattr: { + args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64], + returns: FFIType.i64, + }, + fcntl: { + args: [FFIType.i32, FFIType.i32, FFIType.i32], + returns: FFIType.i32, + }, + }); + const { fsetxattr, fgetxattr, fcntl } = library.symbols; + const held = new Set(); + let closed = false; + const active = () => { + if (closed) { + refuse(); + } + }; + const fdOwned = (fd: number) => { + active(); + if (!held.has(fd)) { + refuse(); + } + }; + const nameBytes = (name: string) => { + if (!XATTR_NAME.test(name)) { + return refuse(); + } + return Buffer.from(`${name}\0`, "ascii"); + }; + const kernel: NativeComposeStorageXattrKernel = Object.freeze({ + effectiveUid: () => process.geteuid?.() ?? refuse(), + effectiveGid: () => process.getegid?.() ?? refuse(), + probeRoot() { + active(); + return directory( + lstatSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, { + bigint: true, + }) + ); + }, + openRoot() { + active(); + const fd = openSync( + NATIVE_STORAGE_XATTR_LINUX_ABI.root, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + ); + held.add(fd); + const flags = fcntl(fd, NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd, 0); + if ( + flags < 0 || + (flags & NATIVE_STORAGE_XATTR_LINUX_ABI.fdCloseExec) === 0 + ) { + held.delete(fd); + closeSync(fd); + return refuse(); + } + return fd; + }, + statRoot(fd) { + fdOwned(fd); + return directory(fstatSync(fd, { bigint: true })); + }, + createXattr(fd, name, value) { + fdOwned(fd); + if (value.byteLength !== NATIVE_STORAGE_XATTR_BYTES) { + return refuse(); + } + const selectedName = nameBytes(name); + const bytes = Buffer.from(value); + if ( + fsetxattr( + fd, + ptr(selectedName), + ptr(bytes), + BigInt(bytes.length), + NATIVE_STORAGE_XATTR_LINUX_ABI.xattrCreate + ) !== 0 + ) { + return refuse(); + } + }, + syncRoot(fd) { + fdOwned(fd); + // Metadata synchronization is mandatory; never substitute fdatasync. + fsyncSync(fd); + }, + readXattr(fd, name, bytes) { + fdOwned(fd); + if (bytes !== NATIVE_STORAGE_XATTR_BYTES) { + return refuse(); + } + const selectedName = nameBytes(name); + const value = Buffer.alloc(bytes); + if ( + fgetxattr(fd, ptr(selectedName), ptr(value), BigInt(bytes)) !== + BigInt(bytes) + ) { + return refuse(); + } + return new Uint8Array(value); + }, + closeRoot(fd) { + fdOwned(fd); + held.delete(fd); + closeSync(fd); + }, + }); + return Object.freeze({ + kernel, + close() { + active(); + closed = true; + try { + for (const fd of held) { + closeSync(fd); + } + } finally { + held.clear(); + library.close(); + } + }, + }); +} diff --git a/tests/native-compose-storage-witness-xattr.test.ts b/tests/native-compose-storage-witness-xattr.test.ts new file mode 100644 index 00000000..3a878f56 --- /dev/null +++ b/tests/native-compose-storage-witness-xattr.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, test } from "bun:test"; +import { + createNativeComposeStorageXattrMarker, + decodeNativeComposeStorageXattrRequest, + decodeNativeComposeStorageXattrResponse, + encodeNativeComposeStorageXattrRequest, + encodeNativeComposeStorageXattrResponse, + NATIVE_STORAGE_XATTR_KIND, + NATIVE_STORAGE_XATTR_VERSION, + type NativeComposeStorageXattrRequest, + type NativeComposeStorageXattrRoot, + parseNativeComposeStorageXattrRequest, + parseNativeComposeStorageXattrResponse, +} from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; +import { + type NativeComposeStorageXattrKernel, + runNativeComposeStorageXattrHelper, +} from "../src/lib/native-compose-storage-witness-xattr-helper.ts"; +import { NATIVE_STORAGE_XATTR_LINUX_ABI } from "../src/lib/native-compose-storage-witness-xattr-linux.ts"; + +const root: NativeComposeStorageXattrRoot = { + device: "1234567890123456789", + inode: "9876543210123456789", + uid: 0, + gid: 0, +}; +const marker = { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + name: `user.hack.storage.${"a".repeat(64)}`, + valueHex: "b".repeat(64), +}; +function request( + operation: "seed" | "verify" +): NativeComposeStorageXattrRequest { + return { ...marker, operation, root }; +} +function fake() { + const calls: string[] = []; + const attributes = new Map(); + const state = { + root: { ...root }, + uid: 0, + gid: 0, + readonly: false, + }; + const kernel: NativeComposeStorageXattrKernel = { + effectiveUid: () => state.uid, + effectiveGid: () => state.gid, + probeRoot() { + calls.push("probe"); + return state.root; + }, + openRoot() { + calls.push("open"); + return 42; + }, + statRoot(fd) { + expect(fd).toBe(42); + calls.push("stat"); + return state.root; + }, + createXattr(fd, name, value) { + expect(fd).toBe(42); + calls.push("create"); + if (state.readonly || attributes.has(name)) { + throw new Error("EEXIST/EROFS private diagnostic must not escape"); + } + attributes.set(name, new Uint8Array(value)); + }, + syncRoot(fd) { + expect(fd).toBe(42); + calls.push("sync"); + }, + readXattr(fd, name, bytes) { + expect(fd).toBe(42); + expect(bytes).toBe(32); + calls.push("read"); + const value = attributes.get(name); + if (!value) { + throw new Error(`ENODATA ${name} private diagnostic`); + } + return new Uint8Array(value); + }, + closeRoot(fd) { + expect(fd).toBe(42); + calls.push("close"); + }, + }; + return { kernel, calls, attributes, state }; +} +function refused(value: unknown) { + expect(value).toEqual({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "refused", + }); + expect(JSON.stringify(value)).not.toContain(marker.name); + expect(JSON.stringify(value)).not.toContain(marker.valueHex); +} + +describe("distinct bounded directory-xattr codec", () => { + test("Linux open flags retain the separate arm64 and x64 UAPI contracts without loading libc", () => { + expect(NATIVE_STORAGE_XATTR_LINUX_ABI.arm64).toEqual({ + libc: "/lib/aarch64-linux-gnu/libc.so.6", + directory: 16_384, + noFollow: 32_768, + }); + expect(NATIVE_STORAGE_XATTR_LINUX_ABI.x64).toEqual({ + libc: "/lib/x86_64-linux-gnu/libc.so.6", + directory: 65_536, + noFollow: 131_072, + }); + }); + test("canonical requests and responses retain 64-bit root facts without number rounding", () => { + const selected = request("verify"); + expect( + parseNativeComposeStorageXattrRequest( + encodeNativeComposeStorageXattrRequest(selected) + ) + ).toEqual(selected); + const response = { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "verified" as const, + root, + valueHex: marker.valueHex, + }; + expect( + parseNativeComposeStorageXattrResponse( + encodeNativeComposeStorageXattrResponse(response) + ) + ).toEqual(response); + }); + test("metadata discovery contains neither marker bytes nor enrollment authority", () => { + const discovery = { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + operation: "root" as const, + }; + expect( + parseNativeComposeStorageXattrRequest( + encodeNativeComposeStorageXattrRequest(discovery) + ) + ).toEqual(discovery); + const selected = fake(); + const result = runNativeComposeStorageXattrHelper({ + request: discovery, + kernel: selected.kernel, + }); + expect(result).toEqual({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "root", + root, + }); + expect(selected.calls).toEqual(["probe", "probe"]); + }); + test("random names and values are independent opaque 256-bit selections", () => { + const first = createNativeComposeStorageXattrMarker(); + const second = createNativeComposeStorageXattrMarker(); + expect(first.name).not.toBe(second.name); + expect(first.valueHex).not.toBe(second.valueHex); + expect(first.name.endsWith(first.valueHex)).toBe(false); + expect(Object.isFrozen(first)).toBe(true); + }); + test.each([ + ["file kind", { ...request("seed"), kind: "regular-file" }], + [ + "USTAR leaf", + { ...request("seed"), name: `.hack-storage-${"a".repeat(64)}.witness` }, + ], + ["unknown version", { ...request("seed"), version: 2 }], + ["repair operation", { ...request("seed"), operation: "replace" }], + ["extra path", { ...request("seed"), path: "/etc/passwd" }], + ["name NUL", { ...request("seed"), name: `${marker.name}\0suffix` }], + ["short value", { ...request("seed"), valueHex: "a".repeat(62) }], + ["uppercase value", { ...request("seed"), valueHex: "B".repeat(64) }], + ["negative uid", { ...request("seed"), root: { ...root, uid: -1 } }], + ["negative zero uid", { ...request("seed"), root: { ...root, uid: -0 } }], + [ + "oversized uid", + { ...request("seed"), root: { ...root, uid: 4_294_967_296 } }, + ], + ["float uid", { ...request("seed"), root: { ...root, uid: 1.5 } }], + ["number inode", { ...request("seed"), root: { ...root, inode: 42 } }], + ["zero inode", { ...request("seed"), root: { ...root, inode: "0" } }], + [ + "rounded inode", + { ...request("seed"), root: { ...root, inode: "18446744073709551616" } }, + ], + ["leading zero", { ...request("seed"), root: { ...root, device: "001" } }], + [ + "extra root field", + { ...request("seed"), root: { ...root, path: "/private" } }, + ], + ])("refuses %s before any syscall", (_, value) => { + expect(() => decodeNativeComposeStorageXattrRequest(value)).toThrow( + "values omitted" + ); + const selected = fake(); + refused( + runNativeComposeStorageXattrHelper({ + request: value, + kernel: selected.kernel, + }) + ); + expect(selected.calls).toEqual([]); + }); + test.each([ + `{"kind":"directory-xattr","version":1,"operation":"root","operation":"root"}\n`, + `{"kind":"directory-xattr","version":1,"operation":"root"}\n\n`, + `${encodeNativeComposeStorageXattrRequest(request("verify"))}suffix`, + " ".repeat(4097), + ])("refuses noncanonical input without printing it", (text) => { + expect(() => parseNativeComposeStorageXattrRequest(text)).toThrow( + "values omitted" + ); + }); + test("unknown responses cannot turn metadata discovery into marker proof", () => { + expect(() => + decodeNativeComposeStorageXattrResponse({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "verified", + root, + }) + ).toThrow("values omitted"); + expect(() => + decodeNativeComposeStorageXattrResponse({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "refused", + error: marker.valueHex, + }) + ).toThrow("values omitted"); + }); +}); + +describe("offline syscall boundary, no Linux helper execution", () => { + test("seed performs one exclusive creation, synchronization and root recheck", () => { + const selected = fake(); + const result = runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }); + expect(result.outcome).toBe("seeded"); + expect(selected.calls).toEqual([ + "open", + "stat", + "create", + "sync", + "stat", + "close", + ]); + expect(selected.attributes.get(marker.name)).toEqual( + Buffer.from(marker.valueHex, "hex") + ); + expect(result).not.toHaveProperty("valueHex"); + }); + test.each([ + new Uint8Array(), + Buffer.from("c".repeat(64), "hex"), + Buffer.from(marker.valueHex, "hex"), + ])("existing empty, wrong or matching attribute is never overwritten", (existing) => { + const selected = fake(); + selected.attributes.set(marker.name, new Uint8Array(existing)); + refused( + runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }) + ); + expect(selected.attributes.get(marker.name)).toEqual(existing); + expect(selected.calls).toEqual(["open", "stat", "create", "close"]); + }); + test("read-only verification never creates, synchronizes or repairs storage", () => { + const selected = fake(); + selected.state.readonly = true; + selected.attributes.set(marker.name, Buffer.from(marker.valueHex, "hex")); + const result = runNativeComposeStorageXattrHelper({ + request: request("verify"), + kernel: selected.kernel, + }); + expect(result).toEqual({ + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + outcome: "verified", + root, + valueHex: marker.valueHex, + }); + expect(selected.calls).toEqual([ + "open", + "stat", + "read", + "stat", + "read", + "stat", + "close", + ]); + }); + test("an enrolled-token stand-in refuses an empty replacement with every observed metadata field unchanged", () => { + const metadata = Object.freeze({ + name: "owned-data", + storage: "data", + createdAt: "2026-10-08T12:00:00Z", + owner: "a".repeat(32), + root: { ...root }, + }); + const selected = fake(); + expect( + runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }).outcome + ).toBe("seeded"); + expect( + runNativeComposeStorageXattrHelper({ + request: request("verify"), + kernel: selected.kernel, + }).outcome + ).toBe("verified"); + const replacementMetadata = structuredClone(metadata); + selected.attributes.clear(); + selected.state.readonly = true; + selected.calls.length = 0; + expect(replacementMetadata).toEqual(metadata); + refused( + runNativeComposeStorageXattrHelper({ + request: request("verify"), + kernel: selected.kernel, + }) + ); + expect(selected.calls).toEqual(["open", "stat", "read", "close"]); + expect(selected.attributes.size).toBe(0); + }); + test.each([ + undefined, + new Uint8Array(), + Buffer.from("c".repeat(64), "hex"), + new Uint8Array(33), + ])("same name/labels/birth with absent, empty, wrong or oversized bytes refuses without seed", (replacement) => { + const selected = fake(); + selected.state.readonly = true; + if (replacement) { + selected.attributes.set(marker.name, replacement); + } + refused( + runNativeComposeStorageXattrHelper({ + request: request("verify"), + kernel: selected.kernel, + }) + ); + expect(selected.calls).not.toContain("create"); + expect(selected.calls).not.toContain("sync"); + expect(selected.calls.at(-1)).toBe("close"); + }); + test("a post-read marker change is detected by a second bounded read", () => { + const selected = fake(); + selected.attributes.set(marker.name, Buffer.from(marker.valueHex, "hex")); + const original = selected.kernel.readXattr; + let reads = 0; + const kernel = { + ...selected.kernel, + readXattr(fd: number, name: string, bytes: number) { + const value = original(fd, name, bytes); + if (++reads === 1) { + selected.attributes.set(name, Buffer.from("c".repeat(64), "hex")); + } + return value; + }, + }; + refused( + runNativeComposeStorageXattrHelper({ request: request("verify"), kernel }) + ); + expect(reads).toBe(2); + expect(selected.calls).not.toContain("create"); + }); + test("replacement behind a pinned root and UID drift refuse", () => { + const selected = fake(); + selected.attributes.set(marker.name, Buffer.from(marker.valueHex, "hex")); + const original = selected.kernel.readXattr; + const kernel = { + ...selected.kernel, + readXattr(fd: number, name: string, bytes: number) { + const value = original(fd, name, bytes); + selected.state.root.inode = "54321"; + selected.state.root.uid = 70; + return value; + }, + }; + refused( + runNativeComposeStorageXattrHelper({ request: request("verify"), kernel }) + ); + expect(selected.calls).toEqual(["open", "stat", "read", "stat", "close"]); + }); + test("cold UID0 then Postgres70 requires explicit matching credentials; actual permissions are unqualified", () => { + const selected = fake(); + expect( + runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }).outcome + ).toBe("seeded"); + selected.state.root.uid = 70; + selected.state.root.gid = 70; + const postStart = { + ...request("verify"), + root: { ...selected.state.root }, + }; + selected.calls.length = 0; + refused( + runNativeComposeStorageXattrHelper({ + request: postStart, + kernel: selected.kernel, + }) + ); + expect(selected.calls).toEqual([]); + selected.state.uid = 70; + selected.state.gid = 70; + expect( + runNativeComposeStorageXattrHelper({ + request: postStart, + kernel: selected.kernel, + }).outcome + ).toBe("verified"); + expect(selected.calls).not.toContain("create"); + }); + test("synchronization failure retains the attempted value without a success or repair", () => { + const selected = fake(); + const kernel = { + ...selected.kernel, + syncRoot() { + throw new Error(marker.valueHex); + }, + }; + refused( + runNativeComposeStorageXattrHelper({ request: request("seed"), kernel }) + ); + expect(selected.attributes.get(marker.name)).toEqual( + Buffer.from(marker.valueHex, "hex") + ); + expect(selected.calls).toEqual(["open", "stat", "create", "close"]); + }); + test("kernel callback substitution cannot replace captured seed authority", () => { + const selected = fake(); + let substituted = 0; + const kernel = { + ...selected.kernel, + effectiveUid() { + kernel.createXattr = () => { + substituted++; + }; + return 0; + }, + }; + expect( + runNativeComposeStorageXattrHelper({ request: request("seed"), kernel }) + .outcome + ).toBe("seeded"); + expect(substituted).toBe(0); + expect(selected.attributes.has(marker.name)).toBe(true); + }); + test("root drift before seed prevents creation and closes the descriptor", () => { + const selected = fake(); + selected.state.root.inode = "12345"; + refused( + runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }) + ); + expect(selected.calls).toEqual(["open", "stat", "close"]); + expect(selected.attributes.size).toBe(0); + }); + test("read-only mount refuses a seed attempt without synchronizing or repairing", () => { + const selected = fake(); + selected.state.readonly = true; + refused( + runNativeComposeStorageXattrHelper({ + request: request("seed"), + kernel: selected.kernel, + }) + ); + expect(selected.calls).toEqual(["open", "stat", "create", "close"]); + expect(selected.attributes.size).toBe(0); + }); + test("close failure does not publish a successful token proof", () => { + const selected = fake(); + selected.attributes.set(marker.name, Buffer.from(marker.valueHex, "hex")); + const kernel = { + ...selected.kernel, + closeRoot() { + throw new Error(marker.valueHex); + }, + }; + refused( + runNativeComposeStorageXattrHelper({ request: request("verify"), kernel }) + ); + expect(selected.calls).not.toContain("create"); + }); + test("metadata discovery drift is a refusal, never an unverified UID switch", () => { + const selected = fake(); + let probes = 0; + const kernel = { + ...selected.kernel, + probeRoot() { + if (++probes === 2) { + selected.state.root.uid = 70; + } + return selected.state.root; + }, + }; + refused( + runNativeComposeStorageXattrHelper({ + request: { + kind: NATIVE_STORAGE_XATTR_KIND, + version: NATIVE_STORAGE_XATTR_VERSION, + operation: "root", + }, + kernel, + }) + ); + expect(probes).toBe(2); + expect(selected.attributes.size).toBe(0); + }); +}); From 2edcc9afc3837728fd0048f5dbed77229fa048c5 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 13:24:31 -0400 Subject: [PATCH 11/23] feat: bind storage witnesses to finite xattr proofs --- .../native-compose-storage-witness.md | 10 +- .../native-compose-storage-xattr-carrier.md | 67 +- src/lib/native-compose-generation.ts | 30 +- .../native-compose-storage-witness-receipt.ts | 16 + .../native-compose-storage-witness-state.ts | 104 ++- ...e-compose-storage-witness-xattr-carrier.ts | 380 ++++++++ src/lib/native-compose-storage-witness.ts | 731 +++++++++++++-- tests/models/tla/README.md | 10 +- ...ompose-storage-witness-xattr-owner.test.ts | 861 ++++++++++++++++++ 9 files changed, 2073 insertions(+), 136 deletions(-) create mode 100644 src/lib/native-compose-storage-witness-xattr-carrier.ts create mode 100644 tests/native-compose-storage-witness-xattr-owner.test.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 95944bd9..3bb56f94 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -77,7 +77,9 @@ or helper download is implemented by this foundation. Store and source-CLI tests use synthetic observation ports and engine transports; they do not qualify Docker write or archive semantics. -The separate [directory-xattr helper source](native-compose-storage-xattr-carrier.md) -explores a create-only token without a PostgreSQL data-root directory entry. It -has a distinct codec and no Docker transport, receipt integration or CLI activation. -Its offline syscall controls do not qualify a carrier. +The separate [directory-xattr owner and helper source](native-compose-storage-xattr-carrier.md) +uses a create-only token without a PostgreSQL data-root directory entry. Required +version-two expectation/reference records and tagged artifact-bound intent keep +it distinct from USTAR under the same version-three receipt owner. It adds +injectable cold provisioning and fresh read-only proof ports; no Docker transport +or CLI activation is implemented. Its offline controls do not qualify a carrier. diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 60065bcb..cb4c2f28 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -1,9 +1,11 @@ # Native Compose directory-xattr carrier source -This is an unactivated Linux helper source and an offline protocol. It does not -enroll CLI volumes or qualify an engine transport. The [witness foundation](native-compose-storage-witness.md) -still accepts only its regular-file/USTAR format. Ordinary CLI instances retain -the metadata guard's same-metadata replacement limitation. +This is an unactivated Linux helper source, injectable owner protocol and offline +controls. The [witness foundation](native-compose-storage-witness.md) accepts its +distinct xattr expectation/reference format under the same opaque generation +authority. There is no Docker implementation of the carrier ports, and the CLI +does not enroll volume witnesses. Ordinary CLI instances retain the metadata +guard's same-metadata replacement limitation. The proposed directory xattr avoids adding a directory entry to PostgreSQL's authored data root. It detects a replacement missing its enrolled random witness; @@ -35,9 +37,12 @@ enumeration, replace, remove, repair or caller-selected path. Device and inode facts use canonical unsigned 64-bit decimal strings; UID/GID are unsigned 32-bit integers. Unknown kinds/versions, alternate encodings, duplicate JSON keys, trailing data and extra fields refuse. This format cannot be relabeled as -the foundation's USTAR proof or attached to its existing completion reference. -An explicit required reference/expectation codec change and owner review must -precede xattr receipt integration; older owners must continue to refuse it. +the foundation's USTAR proof or attached to its version-one completion reference. +Xattr expectations and references require version two, `kind: "directory-xattr"`, +and exact image/platform/Bun/libc/helper and kernel-ABI pins. Their version-three +generation intent requires the carrier tag and those artifact pins even while +Expected. Older strict owners reject the new required fields; absence of an +artifact or tag cannot downgrade to USTAR. The Linux adapter fixes the root at `/hack-storage-witness`. It checks Linux arm64 or x64 open constants and fixed architecture-specific glibc paths, uses @@ -68,7 +73,8 @@ qualification cells. There is no capability or ownership-changing fallback. ## Required cold provisioning and receipt order `startNativeComposeWorkloads` currently launches one Compose `up -d` child. -Carrier activation must add the following boundary before that child, under the +The injectable owner implements the following order; activation must supply a +qualified cold provisioning and carrier transport before that child, under the same existing instance mutation and generation effect authority: 1. Reacquire source, engine identity and exact owned inventory. Initial admission @@ -84,8 +90,10 @@ same existing instance mutation and generation effect authority: Check the create result, then freshly inspect and capture the actual birth, local driver/options, current Mountpoint and holders before seed. Idempotent `volume create` is not proof of original cold admission. No recovery invocation - gains provisioning or seed authority. The final cold transaction and engine - observation contract remain unimplemented and require review. + gains provisioning or seed authority. The owner exposes exactly one captured + provisioning callback in the original consumed enrollment. A real engine + implementation must prove newness across that transaction; idempotent create + and synthetic observations do not qualify it. 4. Run one create-only seed while all selected workloads remain stopped and no foreign or unaccounted holder exists. Require stopped, exact owned helper absence before non-force helper removal. Obtain fresh read-only marker and @@ -96,6 +104,14 @@ same existing instance mutation and generation effect authority: coexist with only the exact admitted running generation's holders. They never stop an application to obtain a proof or omit the existing final fences. +The carrier handle synchronously captures its callbacks, immutable artifact, +AbortSignal and absolute deadline. Preparation captures that exact handle and +the source selection before its first await. The existing owner, private slot, +one-use enrollment and owner-issued publication proof serve both formats; there +is no second enrollment controller. Xattr completion additionally binds the +canonical kernel response hash and observed root. Fabricated references and +caller-supplied no-op verifiers cannot publish Enrolled. + Any crash before enrollment keeps `Expected`, even if the token matches. Saved recovery may stop exact owned resources but must preserve the intent and pending anchor, report incomplete and skip dependent retirement. No automatic promotion, @@ -117,8 +133,22 @@ Each proof must create a fresh carrier from a freshly checked current root. An old carrier can retain a removed root even when the replacement repeats its name, birth and Mountpoint string. Do not accept its token or reuse that pinned mount across operations. Metadata discovery and the matching-UID reader must -each be bracketed by fresh engine/birth/Mountpoint/holder checks. Drift refuses; -there is no UID retry loop or permission repair. +each be bracketed by fresh engine/birth/Mountpoint/holder checks. After the token +read, a third fresh metadata carrier must still observe that exact root. This +detects a reader pinned to an old removed root across its await even when Docker +metadata repeats; it is a finite observation fence, not an atomic exclusion of +later swaps. Drift refuses; there is no UID retry loop or permission repair. + +The source port contract requires a complete selected-volume holder inventory, +the exact daemon and Compose owner, canonical local Mountpoint and empty local +driver options. Only current/pending owned generations may hold the volume; +enrollment forbids every running holder. Every invocation has a fresh nonce, +exact generation/pending context, artifact and UID/GID. Its response must match +those fields, contain one strict canonical helper result, and report exact +stopped helper identity followed by empty helper inventory. Replayed accepted +carrier IDs or invocation IDs refuse. These predicates validate an injectable +contract; they are not proof of actual engine identity or cleanup without the +qualified transport. Durable carrier intent, exact image/program/ID labels, fixed mount correspondence, no image-declared anonymous volumes, dropped capabilities, no network/ports/socket, @@ -130,10 +160,14 @@ journals. This source contains no Docker transport or helper cleanup implementat ## Evidence limits and remaining gates -Offline controls substitute synchronous syscall ports. They check strict input, +Offline controls substitute synchronous syscall ports and synthetic engine/carrier +observations through the public generation store. They check strict input, one create request, no overwrite/repair requests, missing/wrong witness under unchanged metadata, root/credential drift, callback capture and uncertain fsync -or close. They do not execute the Linux adapter or qualify kernel atomicity, +or close; required Expected before cold provisioning; one seed; exact stopped +adoption; resumed missing/same-birth-empty/foreign-root refusals; artifact, +holder, context and cleanup drift; cancellation; and retained interrupted state. +They do not execute the Linux adapter or qualify kernel atomicity, permissions, xattr persistence, filesystem crash durability or Docker semantics. Before activation: qualify exact source/artifact/image/Bun/libc ABI and filesystem; @@ -144,8 +178,9 @@ crash/timeout/cancellation with exact helper absence and retained uncertainty; no daemon-log token persistence; full original engine/resource restoration. Count every helper process, container and query. The first straightforward proof -needs metadata discovery followed by a matching-UID read; initial enrollment adds -a write helper and a separate read-only proof. Required pre/post/final proofs +needs metadata discovery, a matching-UID read and a fresh metadata observation +after that read. Initial enrollment adds a discovery and write helper before its +separate three-invocation read-only proof. Required pre/post/final proofs therefore have a real startup cost. No performance improvement or production activation is claimed, and no persistent helper is proposed. diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 70706125..d15dee94 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -36,6 +36,7 @@ import { nativeComposeStorageWitnessIntentValid, nativeComposeStorageWitnessStatesValid, } from "./native-compose-storage-witness-state.ts"; +import type { NativeComposeStorageXattrCarrier } from "./native-compose-storage-witness-xattr-carrier.ts"; // biome-ignore lint/performance/noBarrelFile: Preserve the generation store's public topology contract after the single-parser extraction. export { @@ -859,17 +860,24 @@ export type NativeComposeEffectOptions = { /** Reuse the exact preceding ownership observation; never perform extra probes here. */ readonly captureStorage?: () => readonly NativeComposeRetainedVolume[]; /** Required for previously enrolled v3 startup/run. Carrier remains unactivated in the CLI. */ - readonly storageWitnesses?: { - readonly engineId: string; - readonly observe: (selection: { - readonly name: string; - readonly storage: string; - readonly markerName: string; - }) => Promise<{ - readonly volume: NativeComposeRetainedVolume; - readonly archive: Uint8Array; - }>; - }; + readonly storageWitnesses?: + | { + readonly kind?: "file-ustar"; + readonly engineId: string; + readonly observe: (selection: { + readonly name: string; + readonly storage: string; + readonly markerName: string; + }) => Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly archive: Uint8Array; + }>; + } + | { + readonly kind: "directory-xattr"; + readonly engineId: string; + readonly carrier: NativeComposeStorageXattrCarrier; + }; readonly recoverPending?: boolean; /** Store-derived immutable one-off delivery, verified before/after run effects. */ readonly projection?: NativeComposeRunProjection; diff --git a/src/lib/native-compose-storage-witness-receipt.ts b/src/lib/native-compose-storage-witness-receipt.ts index 80f11642..85ccd285 100644 --- a/src/lib/native-compose-storage-witness-receipt.ts +++ b/src/lib/native-compose-storage-witness-receipt.ts @@ -152,6 +152,22 @@ export function createNativeComposeStorageWitnessReceiptProtocol< const { verifyNativeComposeStorageWitness } = await import( "./native-compose-storage-witness.ts" ); + if (entry.reference.version === 2) { + if (carrier.kind !== "directory-xattr") { + return refuse(); + } + await verifyNativeComposeStorageWitness({ + authority: opts.authority, + generation, + engineId: carrier.engineId, + reference: entry.reference, + xattrCarrier: carrier.carrier, + }); + return; + } + if (carrier.kind === "directory-xattr") { + return refuse(); + } await verifyNativeComposeStorageWitness({ authority: opts.authority, generation, diff --git a/src/lib/native-compose-storage-witness-state.ts b/src/lib/native-compose-storage-witness-state.ts index f9234736..b4b70097 100644 --- a/src/lib/native-compose-storage-witness-state.ts +++ b/src/lib/native-compose-storage-witness-state.ts @@ -4,6 +4,11 @@ import { type NativeComposeRetainedVolume, nativeComposeRetainedVolumesValid, } from "./native-compose-retained-storage.ts"; +import { + captureNativeComposeStorageXattrArtifact, + type NativeComposeStorageXattrArtifact, + nativeComposeStorageXattrArtifactValid, +} from "./native-compose-storage-witness-xattr-carrier.ts"; const HASH = /^[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; @@ -11,19 +16,27 @@ const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; type DirectoryAnchor = { readonly dev: number; readonly ino: number }; type Anchor = DirectoryAnchor & { readonly hash: string }; /** Private immutable extension references. Never include in a public DTO. */ -export type NativeComposeStorageWitnessReference = { - readonly version: 1; +type ReferenceAnchors = { readonly volume: NativeComposeRetainedVolume; readonly root: DirectoryAnchor; readonly directory: DirectoryAnchor; readonly expectation: Anchor; readonly completion: Anchor; }; +export type NativeComposeStorageWitnessReference = ReferenceAnchors & + ( + | { readonly version: 1 } + | { + readonly version: 2; + readonly kind: "directory-xattr"; + readonly artifact: NativeComposeStorageXattrArtifact; + } + ); /** Issued privately by the witness owner. An object copy cannot authorize receipt publication. */ export type NativeComposeStorageWitnessCompletionProof = Readonly< Record >; -export type NativeComposeStorageWitnessIntent = { +type IntentBinding = { readonly name: string; readonly storage: string; readonly engineId: string; @@ -32,6 +45,14 @@ export type NativeComposeStorageWitnessIntent = { readonly admission: "initial-create" | "explicit-adoption"; readonly originalVolume: NativeComposeRetainedVolume | null; }; +export type NativeComposeStorageWitnessIntent = IntentBinding & + ( + | { readonly carrier?: never; readonly artifact?: never } + | { + readonly carrier: "directory-xattr"; + readonly artifact: NativeComposeStorageXattrArtifact; + } + ); /** Required v3 state: Expected survives every interruption; only this invocation can enroll. */ export type NativeComposeStorageWitnessState = NativeComposeStorageWitnessIntent & @@ -70,8 +91,15 @@ export function nativeComposeStorageWitnessReferenceValid( ): value is NativeComposeStorageWitnessReference { return ( isRecord(value) && - keys(value, "completion,directory,expectation,root,version,volume") && - value.version === 1 && + ((value.version === 1 && + keys(value, "completion,directory,expectation,root,version,volume")) || + (value.version === 2 && + keys( + value, + "artifact,completion,directory,expectation,kind,root,version,volume" + ) && + value.kind === "directory-xattr" && + nativeComposeStorageXattrArtifactValid(value.artifact))) && nativeComposeRetainedVolumesValid([value.volume]) && directoryValid(value.root) && directoryValid(value.directory) && @@ -85,10 +113,17 @@ export function nativeComposeStorageWitnessIntentValid( if ( !( isRecord(value) && - keys( - value, - "admission,engineId,generationId,name,originalVolume,pendingToken,storage" - ) && + (Object.hasOwn(value, "carrier") + ? keys( + value, + "admission,artifact,carrier,engineId,generationId,name,originalVolume,pendingToken,storage" + ) && + value.carrier === "directory-xattr" && + nativeComposeStorageXattrArtifactValid(value.artifact) + : keys( + value, + "admission,engineId,generationId,name,originalVolume,pendingToken,storage" + )) && nativeComposeRetainedVolumesValid([ { name: value.name, @@ -117,6 +152,31 @@ export function nativeComposeStorageWitnessIntentValid( value.originalVolume.storage === value.storage ); } +function enrolledReferenceMatches( + intent: NativeComposeStorageWitnessIntent, + reference: unknown +): boolean { + if (!nativeComposeStorageWitnessReferenceValid(reference)) { + return false; + } + const carrierMatches = + intent.carrier === "directory-xattr" + ? reference.version === 2 && + JSON.stringify( + captureNativeComposeStorageXattrArtifact(reference.artifact) + ) === + JSON.stringify( + captureNativeComposeStorageXattrArtifact(intent.artifact) + ) + : reference.version === 1; + return ( + carrierMatches && + reference.volume.name === intent.name && + reference.volume.storage === intent.storage && + (intent.originalVolume === null || + reference.volume.createdAt === intent.originalVolume.createdAt) + ); +} export function nativeComposeStorageWitnessStatesValid( value: unknown ): value is readonly NativeComposeStorageWitnessState[] { @@ -132,9 +192,20 @@ export function nativeComposeStorageWitnessStatesValid( isRecord(entry) && keys( entry, - entry.state === "expected" - ? "admission,engineId,generationId,name,originalVolume,pendingToken,state,storage" - : "admission,engineId,generationId,name,originalVolume,pendingToken,reference,state,storage" + [ + "admission", + "engineId", + "generationId", + "name", + "originalVolume", + "pendingToken", + "state", + "storage", + ...(entry.state === "expected" ? [] : ["reference"]), + ...(Object.hasOwn(entry, "carrier") ? ["artifact", "carrier"] : []), + ] + .sort() + .join(",") ) ) ) { @@ -154,14 +225,7 @@ export function nativeComposeStorageWitnessStatesValid( engineId = intent.engineId; if ( state !== "expected" && - !( - state === "enrolled" && - nativeComposeStorageWitnessReferenceValid(reference) && - reference.volume.name === intent.name && - reference.volume.storage === intent.storage && - (intent.originalVolume === null || - reference.volume.createdAt === intent.originalVolume.createdAt) - ) + !(state === "enrolled" && enrolledReferenceMatches(intent, reference)) ) { return false; } diff --git a/src/lib/native-compose-storage-witness-xattr-carrier.ts b/src/lib/native-compose-storage-witness-xattr-carrier.ts new file mode 100644 index 00000000..9362f558 --- /dev/null +++ b/src/lib/native-compose-storage-witness-xattr-carrier.ts @@ -0,0 +1,380 @@ +import { createHash } from "node:crypto"; +import { isRecord } from "./guards.ts"; +import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; +import { keys } from "./native-compose-private-state.ts"; +import { + type NativeComposeRetainedVolume, + nativeComposeRetainedVolumesValid, +} from "./native-compose-retained-storage.ts"; +import { + type NativeComposeStorageXattrRequest, + type NativeComposeStorageXattrResponse, + parseNativeComposeStorageXattrResponse, + refuseNativeComposeStorageXattr as refuse, +} from "./native-compose-storage-witness-xattr-codec.ts"; + +const HASH = /^[a-f0-9]{64}$/; +const TOKEN = /^[a-f0-9]{32}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; + +/** Required private pins. A well-shaped pin is not an artifact/runtime qualification. */ +export type NativeComposeStorageXattrArtifact = { + readonly version: 1; + readonly imageId: string; + readonly platform: "linux/arm64" | "linux/amd64"; + readonly bunVersion: "1.4.2"; + readonly bunHash: string; + readonly libcHash: string; + readonly helperHash: string; + readonly kernelAbi: 1; +}; +export function nativeComposeStorageXattrArtifactValid( + value: unknown +): value is NativeComposeStorageXattrArtifact { + return ( + isRecord(value) && + keys( + value, + "bunHash,bunVersion,helperHash,imageId,kernelAbi,libcHash,platform,version" + ) && + value.version === 1 && + value.kernelAbi === 1 && + value.bunVersion === "1.4.2" && + (value.platform === "linux/arm64" || value.platform === "linux/amd64") && + typeof value.imageId === "string" && + IMAGE.test(value.imageId) && + typeof value.bunHash === "string" && + HASH.test(value.bunHash) && + typeof value.libcHash === "string" && + HASH.test(value.libcHash) && + typeof value.helperHash === "string" && + HASH.test(value.helperHash) + ); +} +export function captureNativeComposeStorageXattrArtifact( + value: unknown +): NativeComposeStorageXattrArtifact { + if (!nativeComposeStorageXattrArtifactValid(value)) { + return refuse(); + } + return Object.freeze({ + version: 1, + imageId: value.imageId, + platform: value.platform, + bunVersion: "1.4.2", + bunHash: value.bunHash, + libcHash: value.libcHash, + helperHash: value.helperHash, + kernelAbi: 1, + }); +} +type Holder = { + readonly id: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; + readonly generationId: string; + readonly running: boolean; +}; +/** A complete selected-volume holder inventory, not merely project-label filtered containers. */ +export type NativeComposeStorageXattrTarget = { + readonly engineId: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; + readonly name: string; + readonly storage: string; + readonly volume: NativeComposeRetainedVolume | null; + readonly mountpoint: string | null; + readonly driver: "local" | null; + readonly options: Readonly> | null; + readonly holders: readonly Holder[]; +}; +export type NativeComposeStorageXattrInvocation = { + readonly invocationId: string; + readonly artifact: NativeComposeStorageXattrArtifact; + readonly target: NativeComposeStorageXattrTarget; + readonly readonly: boolean; + readonly uid: number; + readonly gid: number; + readonly request: NativeComposeStorageXattrRequest; + readonly scope: { + readonly generationId: string; + readonly currentGenerationId: string | null; + readonly pendingGenerationId: string | null; + readonly pendingToken: string | null; + }; +}; +/** Only a future qualified engine owner may implement these ports; the CLI supplies none. */ +export type NativeComposeStorageXattrPorts = { + readonly inspect: (selection: { + readonly name: string; + readonly storage: string; + }) => Promise; + /** Exactly one original cold attempt. Idempotent create alone cannot establish admission. */ + readonly provision?: (selection: { + readonly name: string; + readonly storage: string; + readonly engineId: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; + }) => Promise; + /** Fresh non-creating current-root mount; durable intent before effects and exact cleanup before return. */ + readonly invoke: ( + input: NativeComposeStorageXattrInvocation + ) => Promise; +}; +export type NativeComposeStorageXattrCarrier = Readonly>; +type Captured = { + readonly artifact: NativeComposeStorageXattrArtifact; + readonly ports: NativeComposeStorageXattrPorts; + readonly invocationIds: Set; + readonly carrierIds: Set; + readonly signal: AbortSignal; + readonly deadline: number; +}; +const carriers = new WeakMap(); +/** Synchronous capture prevents later callback, scalar or artifact retargeting. It grants no effect authority. */ +export function captureNativeComposeStorageXattrCarrier(opts: { + readonly artifact: NativeComposeStorageXattrArtifact; + readonly ports: NativeComposeStorageXattrPorts; + readonly signal: AbortSignal; + readonly deadline: number; +}): NativeComposeStorageXattrCarrier { + const artifact = captureNativeComposeStorageXattrArtifact(opts.artifact); + const { signal, deadline } = opts; + if ( + !(signal instanceof AbortSignal && Number.isSafeInteger(deadline)) || + deadline <= Date.now() + ) { + return refuse(); + } + const { inspect, provision, invoke } = opts.ports; + if ( + typeof inspect !== "function" || + typeof invoke !== "function" || + (provision !== undefined && typeof provision !== "function") + ) { + return refuse(); + } + const handle = Object.freeze({}); + carriers.set(handle, { + artifact, + ports: Object.freeze({ inspect, provision, invoke }), + invocationIds: new Set(), + carrierIds: new Set(), + signal, + deadline, + }); + return handle; +} +function parts(handle: NativeComposeStorageXattrCarrier): Captured { + return carriers.get(handle) ?? refuse(); +} +export function nativeComposeStorageXattrCarrierArtifact( + handle: NativeComposeStorageXattrCarrier +): NativeComposeStorageXattrArtifact { + return parts(handle).artifact; +} +export function nativeComposeStorageXattrCarrierPorts( + handle: NativeComposeStorageXattrCarrier +): NativeComposeStorageXattrPorts { + return parts(handle).ports; +} +export function checkNativeComposeStorageXattrCarrierLifetime( + handle: NativeComposeStorageXattrCarrier +): void { + const { signal, deadline } = parts(handle); + if (signal.aborted || deadline <= Date.now()) { + refuse(); + } +} +export function checkNativeComposeStorageXattrTarget(opts: { + readonly value: unknown; + readonly current: NativeComposeMaterialBinding; + readonly engineId: string; + readonly selection: { readonly name: string; readonly storage: string }; + readonly stopped: boolean; +}): NativeComposeStorageXattrTarget { + const { value, current, engineId, selection, stopped } = opts; + if ( + !( + isRecord(value) && + keys( + value, + "driver,engineId,holders,mountpoint,name,options,ownerToken,runtimeIdentity,storage,volume" + ) && + typeof value.engineId === "string" && + ENGINE.test(value.engineId) && + value.engineId === engineId && + value.runtimeIdentity === current.identity.composeProject && + value.ownerToken === current.identity.ownerToken && + value.name === selection.name && + value.storage === selection.storage && + Array.isArray(value.holders) && + value.holders.length <= 256 + ) + ) { + return refuse(); + } + let volume: NativeComposeRetainedVolume | null = null; + if (value.volume === null) { + if ( + value.mountpoint !== null || + value.driver !== null || + value.options !== null || + value.holders.length !== 0 + ) { + return refuse(); + } + } else { + if ( + !( + nativeComposeRetainedVolumesValid([value.volume]) && + isRecord(value.volume) && + value.volume.name === selection.name && + value.volume.storage === selection.storage && + value.driver === "local" && + isRecord(value.options) && + keys(value.options, "") && + value.mountpoint === `/var/lib/docker/volumes/${selection.name}/_data` + ) + ) { + return refuse(); + } + volume = Object.freeze({ + name: selection.name, + storage: selection.storage, + createdAt: String(value.volume.createdAt), + }); + } + const ids = new Set(); + const holders = value.holders + .map((holder: unknown): Holder => { + if ( + !( + isRecord(holder) && + keys(holder, "generationId,id,ownerToken,running,runtimeIdentity") && + typeof holder.id === "string" && + HASH.test(holder.id) && + !ids.has(holder.id) && + holder.runtimeIdentity === current.identity.composeProject && + holder.ownerToken === current.identity.ownerToken && + typeof holder.generationId === "string" && + TOKEN.test(holder.generationId) && + (holder.generationId === current.currentGenerationId || + holder.generationId === current.pendingGenerationId) && + typeof holder.running === "boolean" && + !(stopped && holder.running) + ) + ) { + return refuse(); + } + ids.add(holder.id); + return Object.freeze({ + id: holder.id, + runtimeIdentity: current.identity.composeProject, + ownerToken: current.identity.ownerToken, + generationId: holder.generationId, + running: holder.running, + }); + }) + .sort((a, b) => a.id.localeCompare(b.id)); + return Object.freeze({ + engineId, + runtimeIdentity: current.identity.composeProject, + ownerToken: current.identity.ownerToken, + name: selection.name, + storage: selection.storage, + volume, + mountpoint: volume + ? `/var/lib/docker/volumes/${selection.name}/_data` + : null, + driver: volume ? "local" : null, + options: volume ? Object.freeze({}) : null, + holders: Object.freeze(holders), + }); +} +/** Validate the exact captured artifact, fresh invocation/current-root target and finite cleanup observations. */ +export function checkNativeComposeStorageXattrResult(opts: { + readonly value: unknown; + readonly input: NativeComposeStorageXattrInvocation; + readonly carrier: NativeComposeStorageXattrCarrier; + readonly current: NativeComposeMaterialBinding; +}): { + readonly response: NativeComposeStorageXattrResponse; + readonly responseHash: string; +} { + const { value, input } = opts; + const captured = parts(opts.carrier); + if ( + !( + isRecord(value) && + keys( + value, + "artifact,carrierId,containersAfterCleanup,engineId,exitCode,gid,invocationId,outcome,readonly,response,scope,stopped,target,uid" + ) && + value.outcome === "complete" && + value.exitCode === 0 && + value.invocationId === input.invocationId && + TOKEN.test(input.invocationId) && + !captured.invocationIds.has(input.invocationId) && + typeof value.carrierId === "string" && + HASH.test(value.carrierId) && + !captured.carrierIds.has(value.carrierId) && + value.engineId === input.target.engineId && + value.readonly === input.readonly && + value.uid === input.uid && + value.gid === input.gid && + isRecord(value.scope) && + keys( + value.scope, + "currentGenerationId,generationId,pendingGenerationId,pendingToken" + ) && + value.scope.generationId === input.scope.generationId && + value.scope.currentGenerationId === input.scope.currentGenerationId && + value.scope.pendingGenerationId === input.scope.pendingGenerationId && + value.scope.pendingToken === input.scope.pendingToken && + nativeComposeStorageXattrArtifactValid(value.artifact) && + JSON.stringify( + captureNativeComposeStorageXattrArtifact(value.artifact) + ) === JSON.stringify(captured.artifact) && + isRecord(value.stopped) && + keys(value.stopped, "exitCode,id,pid,running") && + value.stopped.id === value.carrierId && + value.stopped.running === false && + value.stopped.pid === 0 && + value.stopped.exitCode === 0 && + Array.isArray(value.containersAfterCleanup) && + value.containersAfterCleanup.length === 0 && + typeof value.response === "string" + ) + ) { + return refuse(); + } + const target = checkNativeComposeStorageXattrTarget({ + value: value.target, + current: opts.current, + engineId: input.target.engineId, + selection: input.target, + stopped: !input.readonly, + }); + if (JSON.stringify(target) !== JSON.stringify(input.target)) { + return refuse(); + } + const response = parseNativeComposeStorageXattrResponse(value.response); + let outcome = "verified"; + if (input.request.operation === "root") { + outcome = "root"; + } else if (input.request.operation === "seed") { + outcome = "seeded"; + } + if (response.outcome !== outcome) { + return refuse(); + } + captured.invocationIds.add(input.invocationId); + captured.carrierIds.add(value.carrierId); + return Object.freeze({ + response, + responseHash: createHash("sha256").update(value.response).digest("hex"), + }); +} diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index a3b5fa3e..2535123e 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -39,6 +39,27 @@ import { type NativeComposeStorageWitnessReference, nativeComposeStorageWitnessReferenceValid, } from "./native-compose-storage-witness-state.ts"; +import { + captureNativeComposeStorageXattrArtifact, + checkNativeComposeStorageXattrCarrierLifetime, + checkNativeComposeStorageXattrResult, + checkNativeComposeStorageXattrTarget, + type NativeComposeStorageXattrArtifact, + type NativeComposeStorageXattrCarrier, + type NativeComposeStorageXattrInvocation, + type NativeComposeStorageXattrTarget, + nativeComposeStorageXattrCarrierArtifact, + nativeComposeStorageXattrCarrierPorts, +} from "./native-compose-storage-witness-xattr-carrier.ts"; +import { + createNativeComposeStorageXattrMarker, + decodeNativeComposeStorageXattrRequest, + type NativeComposeStorageXattrMarker, + type NativeComposeStorageXattrRoot, + nativeComposeStorageXattrRootValid, + nativeComposeStorageXattrValueMatches, + sameNativeComposeStorageXattrRoot, +} from "./native-compose-storage-witness-xattr-codec.ts"; export type { NativeComposeStorageWitnessReference } from "./native-compose-storage-witness-state.ts"; @@ -61,18 +82,42 @@ type Binding = Selection & { readonly generationId: string; readonly pendingToken: string; }; -type Expectation = { - readonly version: 1; +type ExpectationBinding = { readonly binding: Binding; readonly admission: "initial-create" | "explicit-adoption"; readonly originalVolume: NativeComposeRetainedVolume | null; - readonly marker: NativeComposeStorageWitnessMarker; }; -type Completion = { - readonly version: 1; +type XattrExpectation = ExpectationBinding & { + readonly version: 2; + readonly kind: "directory-xattr"; + readonly artifact: NativeComposeStorageXattrArtifact; + readonly marker: NativeComposeStorageXattrMarker; +}; +type Expectation = ExpectationBinding & + ( + | { + readonly version: 1; + readonly marker: NativeComposeStorageWitnessMarker; + } + | XattrExpectation + ); +type CompletionBinding = { readonly expectationHash: string; readonly volume: NativeComposeRetainedVolume; }; +type KernelProof = { + readonly root: NativeComposeStorageXattrRoot; + readonly responseHash: string; +}; +type Completion = CompletionBinding & + ( + | { readonly version: 1 } + | { + readonly version: 2; + readonly kind: "directory-xattr"; + readonly kernelProof: KernelProof; + } + ); export type NativeComposeStorageWitnessEnrollment = Readonly< Record >; @@ -84,6 +129,7 @@ type Enrollment = { readonly root: DirectoryAnchor; readonly directory: DirectoryAnchor; readonly assertAdmission: () => Promise; + readonly carrier?: NativeComposeStorageXattrCarrier; consumed: boolean; }; const enrollments = new WeakMap< @@ -163,28 +209,43 @@ function bindingValid(value: unknown): value is Binding { TOKEN.test(value.pendingToken) ); } +function expectationRecordValid(value: unknown): value is Record< + string, + unknown +> & { + readonly binding: Binding; + readonly marker: Record; +} { + return ( + isRecord(value) && + ((value.version === 1 && + keys(value, "admission,binding,marker,originalVolume,version")) || + (value.version === 2 && + value.kind === "directory-xattr" && + keys( + value, + "admission,artifact,binding,kind,marker,originalVolume,version" + ))) && + bindingValid(value.binding) && + isRecord(value.marker) && + (value.version === 1 + ? keys(value.marker, "name,token") && + typeof value.marker.name === "string" && + typeof value.marker.token === "string" && + nativeComposeStorageWitnessMarkerValid({ + name: value.marker.name, + token: value.marker.token, + }) + : keys(value.marker, "kind,name,valueHex,version")) && + (value.admission === "initial-create" + ? value.originalVolume === null + : value.admission === "explicit-adoption" && + nativeComposeRetainedVolumesValid([value.originalVolume])) + ); +} function expectation(text: string): Expectation { const value = parsePrivateJson(text); - if ( - !( - isRecord(value) && - keys(value, "admission,binding,marker,originalVolume,version") && - value.version === 1 && - bindingValid(value.binding) && - isRecord(value.marker) && - keys(value.marker, "name,token") && - typeof value.marker.name === "string" && - typeof value.marker.token === "string" && - nativeComposeStorageWitnessMarkerValid({ - name: value.marker.name, - token: value.marker.token, - }) && - (value.admission === "initial-create" - ? value.originalVolume === null - : value.admission === "explicit-adoption" && - nativeComposeRetainedVolumesValid([value.originalVolume])) - ) - ) { + if (!expectationRecordValid(value)) { return refuse(); } const originalVolume = @@ -196,15 +257,46 @@ function expectation(text: string): Expectation { ) { return refuse(); } - return { - version: 1, + const common: ExpectationBinding = { binding: value.binding, admission: value.admission === "initial-create" ? "initial-create" : "explicit-adoption", originalVolume, - marker: { name: value.marker.name, token: value.marker.token }, + }; + if (value.version === 1) { + if ( + typeof value.marker.name !== "string" || + typeof value.marker.token !== "string" + ) { + return refuse(); + } + return { + ...common, + version: 1, + marker: { name: value.marker.name, token: value.marker.token }, + }; + } + const marker = decodeNativeComposeStorageXattrRequest({ + ...value.marker, + operation: "verify", + root: { device: "1", inode: "1", uid: 0, gid: 0 }, + }); + if (marker.operation === "root") { + return refuse(); + } + return { + ...common, + version: 2, + kind: "directory-xattr", + artifact: captureNativeComposeStorageXattrArtifact(value.artifact), + marker: { + kind: marker.kind, + version: marker.version, + name: marker.name, + valueHex: marker.valueHex, + }, }; } function parseVolume(value: unknown): NativeComposeRetainedVolume { @@ -212,26 +304,53 @@ function parseVolume(value: unknown): NativeComposeRetainedVolume { if (!nativeComposeRetainedVolumesValid(entries)) { return refuse(); } - return entries[0] ?? refuse(); + return Object.freeze({ ...(entries[0] ?? refuse()) }); } function completion(text: string): Completion { const value = parsePrivateJson(text); if ( !( isRecord(value) && - keys(value, "expectationHash,version,volume") && - value.version === 1 && + ((value.version === 1 && keys(value, "expectationHash,version,volume")) || + (value.version === 2 && + value.kind === "directory-xattr" && + keys(value, "expectationHash,kernelProof,kind,version,volume") && + isRecord(value.kernelProof) && + keys(value.kernelProof, "responseHash,root") && + nativeComposeStorageXattrRootValid(value.kernelProof.root) && + typeof value.kernelProof.responseHash === "string" && + HASH.test(value.kernelProof.responseHash))) && typeof value.expectationHash === "string" && HASH.test(value.expectationHash) ) ) { return refuse(); } - return { - version: 1, + const common: CompletionBinding = { expectationHash: value.expectationHash, volume: parseVolume(value.volume), }; + if (value.version === 1) { + return { ...common, version: 1 }; + } + if ( + !( + isRecord(value.kernelProof) && + nativeComposeStorageXattrRootValid(value.kernelProof.root) + ) || + typeof value.kernelProof.responseHash !== "string" + ) { + return refuse(); + } + return { + ...common, + version: 2, + kind: "directory-xattr", + kernelProof: { + root: { ...value.kernelProof.root }, + responseHash: value.kernelProof.responseHash, + }, + }; } function sameVolume( left: NativeComposeRetainedVolume, @@ -265,6 +384,234 @@ function matchesAdmission( current.pendingGenerationId === saved.generationId ); } +type XattrCheck = () => Promise; +function checkXattrArtifact( + saved: XattrExpectation, + carrier: NativeComposeStorageXattrCarrier +): void { + checkNativeComposeStorageXattrCarrierLifetime(carrier); + if ( + JSON.stringify(saved.artifact) !== + JSON.stringify(nativeComposeStorageXattrCarrierArtifact(carrier)) + ) { + refuse(); + } +} +async function inspectXattr(opts: { + readonly carrier: NativeComposeStorageXattrCarrier; + readonly saved: XattrExpectation; + readonly stopped: boolean; + readonly check: XattrCheck; +}): Promise { + checkXattrArtifact(opts.saved, opts.carrier); + const before = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + const value = await nativeComposeStorageXattrCarrierPorts( + opts.carrier + ).inspect( + Object.freeze({ + name: opts.saved.binding.name, + storage: opts.saved.binding.storage, + }) + ); + const current = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + if (JSON.stringify(before) !== JSON.stringify(current)) { + return refuse(); + } + return checkNativeComposeStorageXattrTarget({ + value, + current, + engineId: opts.saved.binding.engineId, + selection: opts.saved.binding, + stopped: opts.stopped, + }); +} +function requireXattrAdmission( + saved: XattrExpectation, + target: NativeComposeStorageXattrTarget +): void { + if (saved.admission === "initial-create") { + if (target.volume !== null) { + refuse(); + } + } else if ( + !( + target.volume && + saved.originalVolume && + sameVolume(target.volume, saved.originalVolume) + ) + ) { + refuse(); + } +} +async function invokeXattr(opts: { + readonly carrier: NativeComposeStorageXattrCarrier; + readonly saved: XattrExpectation; + readonly volume: NativeComposeRetainedVolume; + readonly stopped: boolean; + readonly check: XattrCheck; + readonly request: Parameters< + typeof decodeNativeComposeStorageXattrRequest + >[0]; +}) { + const request = decodeNativeComposeStorageXattrRequest(opts.request); + const target = await inspectXattr(opts); + if (!(target.volume && sameVolume(target.volume, opts.volume))) { + return refuse(); + } + const current = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + const input: NativeComposeStorageXattrInvocation = Object.freeze({ + invocationId: randomBytes(16).toString("hex"), + artifact: opts.saved.artifact, + target, + readonly: request.operation !== "seed", + uid: request.operation === "root" ? 0 : request.root.uid, + gid: request.operation === "root" ? 0 : request.root.gid, + request, + scope: Object.freeze({ + generationId: current.generationId, + currentGenerationId: current.currentGenerationId, + pendingGenerationId: current.pendingGenerationId, + pendingToken: current.pendingToken, + }), + }); + const value = await nativeComposeStorageXattrCarrierPorts( + opts.carrier + ).invoke(input); + const latest = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + if (JSON.stringify(current) !== JSON.stringify(latest)) { + return refuse(); + } + const result = checkNativeComposeStorageXattrResult({ + value, + input, + carrier: opts.carrier, + current: latest, + }); + const after = await inspectXattr(opts); + if (JSON.stringify(after) !== JSON.stringify(target)) { + return refuse(); + } + if ( + request.operation !== "root" && + (result.response.outcome === "refused" || + !sameNativeComposeStorageXattrRoot(result.response.root, request.root)) + ) { + return refuse(); + } + return result; +} +async function observeXattr(opts: { + readonly carrier: NativeComposeStorageXattrCarrier; + readonly saved: XattrExpectation; + readonly volume: NativeComposeRetainedVolume; + readonly stopped: boolean; + readonly check: XattrCheck; +}): Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly kernelProof: KernelProof; +}> { + const discovered = await invokeXattr({ + ...opts, + request: { kind: "directory-xattr", version: 1, operation: "root" }, + }); + if (discovered.response.outcome !== "root") { + return refuse(); + } + const proof = await invokeXattr({ + ...opts, + request: { + ...opts.saved.marker, + operation: "verify", + root: discovered.response.root, + }, + }); + if ( + proof.response.outcome !== "verified" || + !nativeComposeStorageXattrValueMatches( + Buffer.from(proof.response.valueHex, "hex"), + opts.saved.marker.valueHex + ) + ) { + return refuse(); + } + // A bind reader may retain an unlinked root across its await. Observe the current + // namespace with another fresh non-creating carrier; Docker metadata alone cannot + // detect a replacement which repeats both name and birth. This remains a finite fence. + const after = await invokeXattr({ + ...opts, + request: { kind: "directory-xattr", version: 1, operation: "root" }, + }); + if ( + after.response.outcome !== "root" || + !sameNativeComposeStorageXattrRoot(after.response.root, proof.response.root) + ) { + return refuse(); + } + return { + volume: opts.volume, + kernelProof: { + root: proof.response.root, + responseHash: proof.responseHash, + }, + }; +} +async function seedXattr(opts: { + readonly carrier: NativeComposeStorageXattrCarrier; + readonly saved: XattrExpectation; + readonly check: XattrCheck; +}): Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly kernelProof: KernelProof; +}> { + const initial = await inspectXattr({ ...opts, stopped: true }); + requireXattrAdmission(opts.saved, initial); + if (opts.saved.admission === "initial-create") { + const provision = + nativeComposeStorageXattrCarrierPorts(opts.carrier).provision ?? refuse(); + const current = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + await provision( + Object.freeze({ + name: opts.saved.binding.name, + storage: opts.saved.binding.storage, + engineId: opts.saved.binding.engineId, + runtimeIdentity: current.identity.composeProject, + ownerToken: opts.saved.binding.ownerToken, + }) + ); + await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + } + const created = await inspectXattr({ ...opts, stopped: true }); + const volume = created.volume ?? refuse(); + if ( + opts.saved.originalVolume && + !sameVolume(volume, opts.saved.originalVolume) + ) { + return refuse(); + } + const stopped = { ...opts, stopped: true, volume }; + const discovered = await invokeXattr({ + ...stopped, + request: { kind: "directory-xattr", version: 1, operation: "root" }, + }); + if (discovered.response.outcome !== "root") { + return refuse(); + } + await invokeXattr({ + ...stopped, + request: { + ...opts.saved.marker, + operation: "seed", + root: discovered.response.root, + }, + }); + return await observeXattr(stopped); +} async function directories(opts: { readonly binding: NativeComposeMaterialBinding; readonly name: string; @@ -364,6 +711,75 @@ async function checkedExpectation( return expectation(read.text); } +type EnrollmentObservation = { + readonly volume: NativeComposeRetainedVolume; + readonly archive?: Uint8Array; + readonly kernelProof?: KernelProof; +}; +async function observeEnrollment(opts: { + readonly saved: Expectation; + readonly carrier?: NativeComposeStorageXattrCarrier; + readonly seed?: (archive: Uint8Array) => Promise; + readonly observe?: () => Promise<{ + readonly volume: NativeComposeRetainedVolume; + readonly archive: Uint8Array; + }>; + readonly check: XattrCheck; +}): Promise { + const { saved, carrier, seed, observe, check } = opts; + if (saved.version === 2) { + if (seed || observe || !carrier) { + return refuse(); + } + return await seedXattr({ carrier, saved, check }); + } + if (!(seed && observe) || carrier) { + return refuse(); + } + await seed(encodeNativeComposeStorageWitnessArchive(saved.marker)); + const observed = await observe(); + verifyNativeComposeStorageWitnessArchive({ + marker: saved.marker, + archive: observed.archive, + }); + return observed; +} +function completionMatches(opts: { + readonly record: Completion; + readonly reference: NativeComposeStorageWitnessReference; + readonly saved: Expectation; +}): boolean { + const { record, reference, saved } = opts; + return ( + record.version === reference.version && + record.expectationHash === reference.expectation.hash && + sameVolume(record.volume, reference.volume) && + (saved.originalVolume === null || + sameVolume(saved.originalVolume, record.volume)) && + record.volume.name === saved.binding.name && + record.volume.storage === saved.binding.storage + ); +} +function expectationMatchesReference(opts: { + readonly saved: Expectation; + readonly reference: NativeComposeStorageWitnessReference; +}): boolean { + const { saved, reference } = opts; + if (saved.version !== reference.version) { + return false; + } + if (saved.version === 1) { + return true; + } + return ( + reference.version === 2 && + JSON.stringify(saved.artifact) === + JSON.stringify( + captureNativeComposeStorageXattrArtifact(reference.artifact) + ) + ); +} + /** * Internal foundation: the CLI refuses witness-bearing workload admission until its carrier is qualified. * The owning generation mutation must prove a genuinely absent new volume or @@ -379,6 +795,8 @@ export async function prepareNativeComposeStorageWitness(opts: { readonly admission: "initial-create" | "explicit-adoption"; readonly originalVolume?: NativeComposeRetainedVolume; readonly assertAdmission: () => Promise; + /** Explicit internal xattr path; no CLI caller supplies this unactivated carrier. */ + readonly xattrCarrier?: NativeComposeStorageXattrCarrier; }): Promise { const input = Object.freeze({ ...opts, @@ -387,6 +805,12 @@ export async function prepareNativeComposeStorageWitness(opts: { ? Object.freeze({ ...opts.originalVolume }) : undefined, }); + const artifact = input.xattrCarrier + ? nativeComposeStorageXattrCarrierArtifact(input.xattrCarrier) + : null; + if (input.xattrCarrier) { + checkNativeComposeStorageXattrCarrierLifetime(input.xattrCarrier); + } return await runNativeComposeMaterialAction({ authority: input.authority, run: async () => { @@ -403,34 +827,68 @@ export async function prepareNativeComposeStorageWitness(opts: { generationId: current.generationId, pendingToken: current.pendingToken ?? refuse(), }; - const record: Expectation = { - version: 1, + const common: ExpectationBinding = { binding, admission: input.admission, originalVolume: input.originalVolume ?? null, - marker: { - name: `.hack-storage-${randomBytes(32).toString("hex")}.witness`, - token: randomBytes(32).toString("hex"), - }, }; + const record: Expectation = artifact + ? { + ...common, + version: 2, + kind: "directory-xattr", + artifact, + marker: createNativeComposeStorageXattrMarker(), + } + : { + ...common, + version: 1, + marker: { + name: `.hack-storage-${randomBytes(32).toString("hex")}.witness`, + token: randomBytes(32).toString("hex"), + }, + }; const text = JSON.stringify(record); expectation(text); if (!matchesAdmission(binding, current)) { return refuse(); } await input.assertAdmission(); + if (input.xattrCarrier && record.version === 2) { + const target = await inspectXattr({ + carrier: input.xattrCarrier, + saved: record, + stopped: true, + check: async () => { + const latest = await assertNativeComposeMaterialAuthority({ + authority: input.authority, + generation: input.generation, + phase: "effect", + }); + if (!matchesAdmission(binding, latest)) { + return refuse(); + } + return latest; + }, + }); + requireXattrAdmission(record, target); + } + const intent = { + name: binding.name, + storage: binding.storage, + engineId: binding.engineId, + generationId: binding.generationId, + pendingToken: binding.pendingToken, + admission: record.admission, + originalVolume: record.originalVolume, + }; await armNativeComposeStorageWitnessIntent({ authority: input.authority, generation: input.generation, - intent: { - name: binding.name, - storage: binding.storage, - engineId: binding.engineId, - generationId: binding.generationId, - pendingToken: binding.pendingToken, - admission: record.admission, - originalVolume: record.originalVolume, - }, + intent: + record.version === 2 + ? { ...intent, carrier: record.kind, artifact: record.artifact } + : intent, }); const held = await directories({ binding: current, @@ -452,6 +910,9 @@ export async function prepareNativeComposeStorageWitness(opts: { if (!matchesAdmission(binding, latest)) { return refuse(); } + if (input.xattrCarrier) { + checkNativeComposeStorageXattrCarrierLifetime(input.xattrCarrier); + } const capability = Object.freeze({}); enrollments.set(capability, { authority: input.authority, @@ -461,6 +922,7 @@ export async function prepareNativeComposeStorageWitness(opts: { root: directoryAnchor(held[1] ?? refuse()), directory: directoryAnchor(last(held)), assertAdmission: input.assertAdmission, + carrier: input.xattrCarrier, consumed: false, }); return capability; @@ -479,8 +941,8 @@ export async function prepareNativeComposeStorageWitness(opts: { */ export async function enrollNativeComposeStorageWitness(opts: { readonly enrollment: NativeComposeStorageWitnessEnrollment; - readonly seed: (archive: Uint8Array) => Promise; - readonly observe: () => Promise<{ + readonly seed?: (archive: Uint8Array) => Promise; + readonly observe?: () => Promise<{ readonly volume: NativeComposeRetainedVolume; readonly archive: Uint8Array; }>; @@ -503,6 +965,9 @@ export async function enrollNativeComposeStorageWitness(opts: { if (!matchesAdmission(selected.expectation.binding, binding)) { return refuse(); } + if (selected.carrier) { + checkNativeComposeStorageXattrCarrierLifetime(selected.carrier); + } return binding; }; const held = await directories({ @@ -516,8 +981,17 @@ export async function enrollNativeComposeStorageWitness(opts: { await selected.assertAdmission(); await checkedExpectation(held, selected.anchor); await check(); - await seed(encodeNativeComposeStorageWitnessArchive(saved.marker)); - const observed = await observe(); + const live = async () => { + await checkedExpectation(held, selected.anchor); + return await check(); + }; + const observed = await observeEnrollment({ + saved, + carrier: selected.carrier, + seed, + observe, + check: live, + }); const volume = parseVolume(observed.volume); if ( volume.name !== saved.binding.name || @@ -526,17 +1000,19 @@ export async function enrollNativeComposeStorageWitness(opts: { ) { return refuse(); } - verifyNativeComposeStorageWitnessArchive({ - marker: saved.marker, - archive: observed.archive, - }); await checkedExpectation(held, selected.anchor); await check(); - const text = JSON.stringify({ - version: 1, - expectationHash: selected.anchor.hash, - volume, - } satisfies Completion); + const completedRecord: Completion = + saved.version === 1 + ? { version: 1, expectationHash: selected.anchor.hash, volume } + : { + version: 2, + kind: "directory-xattr", + expectationHash: selected.anchor.hash, + volume, + kernelProof: observed.kernelProof ?? refuse(), + }; + const text = JSON.stringify(completedRecord); const info = await writeExclusive( join(last(held).path, "enrolled.json"), text @@ -554,14 +1030,23 @@ export async function enrollNativeComposeStorageWitness(opts: { } await recheckDirectories(held); await check(); - const reference = Object.freeze({ - version: 1 as const, + const anchors = { volume: Object.freeze({ ...volume }), root: selected.root, directory: selected.directory, expectation: selected.anchor, completion: anchor({ info, text }), - }); + }; + const reference: NativeComposeStorageWitnessReference = Object.freeze( + saved.version === 1 + ? { ...anchors, version: 1 as const } + : { + ...anchors, + version: 2 as const, + kind: "directory-xattr" as const, + artifact: saved.artifact, + } + ); const proof = Object.freeze({}); completionProofs.set(proof, { authority: selected.authority, @@ -574,7 +1059,9 @@ export async function enrollNativeComposeStorageWitness(opts: { generation: selected.generation, engineId: saved.binding.engineId, reference, - observe: async () => await observe(), + ...(saved.version === 1 && observe + ? { observe: async () => await observe() } + : { xattrCarrier: selected.carrier }), }); }, }); @@ -597,16 +1084,36 @@ export async function verifyNativeComposeStorageWitness(opts: { readonly generation: NativeComposeGeneration; readonly engineId: string; readonly reference: NativeComposeStorageWitnessReference; - readonly observe: (markerName: string) => Promise<{ + readonly observe?: (markerName: string) => Promise<{ readonly volume: NativeComposeRetainedVolume; readonly archive: Uint8Array; }>; + readonly xattrCarrier?: NativeComposeStorageXattrCarrier; }): Promise { - const { authority, generation, engineId, observe } = opts; + const { authority, generation, engineId, observe, xattrCarrier } = opts; if (!nativeComposeStorageWitnessReferenceValid(opts.reference)) { return refuse(); } const reference = structuredClone(opts.reference); + if ( + reference.version === 1 + ? !observe || xattrCarrier !== undefined + : !xattrCarrier || observe !== undefined + ) { + return refuse(); + } + if ( + reference.version === 2 && + xattrCarrier && + JSON.stringify( + captureNativeComposeStorageXattrArtifact(reference.artifact) + ) !== JSON.stringify(nativeComposeStorageXattrCarrierArtifact(xattrCarrier)) + ) { + return refuse(); + } + if (xattrCarrier) { + checkNativeComposeStorageXattrCarrierLifetime(xattrCarrier); + } return await runNativeComposeMaterialAction({ authority, run: async () => { @@ -623,6 +1130,9 @@ export async function verifyNativeComposeStorageWitness(opts: { try { checkDirectoryAnchors(held, reference); const saved = await checkedExpectation(held, reference.expectation); + if (!expectationMatchesReference({ saved, reference })) { + return refuse(); + } const readCompletion = async () => { const read = await readPrivate( join(last(held).path, "enrolled.json"), @@ -632,12 +1142,7 @@ export async function verifyNativeComposeStorageWitness(opts: { if ( !sameFile(read.info, reference.completion) || hash(read.text) !== reference.completion.hash || - record.expectationHash !== reference.expectation.hash || - !sameVolume(record.volume, reference.volume) || - (saved.originalVolume !== null && - !sameVolume(saved.originalVolume, record.volume)) || - record.volume.name !== saved.binding.name || - record.volume.storage !== saved.binding.storage + !completionMatches({ record, reference, saved }) ) { return refuse(); } @@ -646,14 +1151,43 @@ export async function verifyNativeComposeStorageWitness(opts: { if (!matchesBinding(saved.binding, current, engineId)) { return refuse(); } - const observed = await observe(saved.marker.name); - if (!sameVolume(parseVolume(observed.volume), reference.volume)) { - return refuse(); + if (saved.version === 1) { + if (!observe) { + return refuse(); + } + const observed = await observe(saved.marker.name); + if (!sameVolume(parseVolume(observed.volume), reference.volume)) { + return refuse(); + } + verifyNativeComposeStorageWitnessArchive({ + marker: saved.marker, + archive: observed.archive, + }); + } else { + if (!xattrCarrier) { + return refuse(); + } + await observeXattr({ + carrier: xattrCarrier, + saved, + volume: reference.volume, + stopped: false, + check: async () => { + await checkedExpectation(held, reference.expectation); + await readCompletion(); + await recheckDirectories(held); + const latest = await assertNativeComposeMaterialAuthority({ + authority, + generation, + phase: "inspect", + }); + if (!matchesBinding(saved.binding, latest, engineId)) { + return refuse(); + } + return latest; + }, + }); } - verifyNativeComposeStorageWitnessArchive({ - marker: saved.marker, - archive: observed.archive, - }); await checkedExpectation(held, reference.expectation); await readCompletion(); await recheckDirectories(held); @@ -665,9 +1199,46 @@ export async function verifyNativeComposeStorageWitness(opts: { if (!matchesBinding(saved.binding, latest, engineId)) { refuse(); } + if (xattrCarrier) { + checkNativeComposeStorageXattrCarrierLifetime(xattrCarrier); + } } finally { await close(held); } }, }).catch(() => refuse()); } + +/** Explicitly tagged, unactivated source-only xattr enrollment path. */ +export async function prepareNativeComposeStorageXattrWitness( + opts: Omit< + Parameters[0], + "xattrCarrier" + > & { + readonly carrier: NativeComposeStorageXattrCarrier; + } +): Promise { + const { carrier, ...input } = opts; + return await prepareNativeComposeStorageWitness({ + ...input, + xattrCarrier: carrier, + }); +} +export async function enrollNativeComposeStorageXattrWitness(opts: { + readonly enrollment: NativeComposeStorageWitnessEnrollment; +}): Promise { + return await enrollNativeComposeStorageWitness({ + enrollment: opts.enrollment, + }); +} +export async function verifyNativeComposeStorageXattrWitness( + opts: Omit< + Parameters[0], + "observe" | "xattrCarrier" + > & { + readonly carrier: NativeComposeStorageXattrCarrier; + } +): Promise { + const { carrier, ...input } = opts; + await verifyNativeComposeStorageWitness({ ...input, xattrCarrier: carrier }); +} diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index f9d8e669..baad898f 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -63,12 +63,12 @@ fail the runner. | Model action/state | Intended foundation mapping and separate implementation obligation | | --- | --- | | `ReserveIntent` / `intent` | `prepareNativeComposeStorageWitness` first calls `armNativeComposeStorageWitnessIntent` to publish a required v3 `Expected` entry under the existing generation mutation, then exclusively creates and synchronizes the private per-volume slot before writing the expectation. An empty or absent interrupted slot retains the receipt intent and cannot be recreated. The model's slot intent abstracts that refusal; it does not model the enclosing generation receipt. | -| `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` in the separate `native-compose-storage-witness.ts` foundation: validate active pending-generation authority, exclusively publish/sync an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then return an opaque live capability. A failed publication must not grant one. | -| `ArmSeed` / `PublishMarker` | `enrollNativeComposeStorageWitness` consumes the original capability before awaiting its single seed callback. Callback transport, create-only behavior, child ownership/reaping and no writes to an existing witness require implementation and carrier tests. `CreateVolume` is an abstract cold-creation boundary, not a claim that the foundation creates Docker volumes. | -| `ObserveEnrollment` / `CommitEnrollment` | The owner verifies an observed archive through `verifyNativeComposeStorageWitnessArchive`, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. It issues a private one-use proof bound to the exact live authority and generation; `publishNativeComposeStorageWitnessEnrollment` consumes only that issued proof and obtains its reference and fresh verifier from the owner. `createNativeComposeStorageWitnessReceiptProtocol` verifies again before attaching `Enrolled` and rechecks live authority after the final filesystem await before receipt publication. The model additionally requires the current exact witness at commit; qualifying that predicate across actual carrier awaits remains a separate engine-boundary obligation. Its completion is the enrollment record, not a generation's ready receipt. | -| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh carrier observation and exact archive, then re-reads and rechecks ownership without writing. The receipt protocol requires read-only proof before/after startup/run and at both completion receipt boundaries. The CLI refuses witness-bearing startup/run/exec while carrier authority is withheld; actual down/up activation remains unqualified. | +| `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` validates active pending-generation authority, exclusively publishes/syncs an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then returns an opaque live capability. Its explicit `prepareNativeComposeStorageXattrWitness` path additionally requires captured artifact pins and a distinct version-two directory-xattr expectation; the v3 intent requires the carrier tag and artifact before slot creation. A failed publication must not grant a capability. | +| `ArmSeed` / `PublishMarker` | `enrollNativeComposeStorageWitness` consumes the original capability before awaiting its single seed callback. The explicit xattr path instead permits one captured cold-provision callback and one seed invocation, then separate read-only proof. `runNativeComposeStorageXattrHelper` requests `XATTR_CREATE` once through syscall ports, without retry/repair. Actual cold newness, non-creating current-root transport, child ownership/reaping, Linux ABI and kernel durability remain unqualified. `CreateVolume` is an abstract boundary, not evidence that this source creates Docker volumes. | +| `ObserveEnrollment` / `CommitEnrollment` | The owner verifies the distinct archive or xattr result, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. The xattr path binds exact artifact/context/volume/root and canonical kernel response hash; fresh root discovery, token read and post-read root discovery are separate injectable invocations. It issues a private one-use proof bound to the exact live authority and generation; `publishNativeComposeStorageWitnessEnrollment` consumes only that issued proof and obtains its reference and fresh verifier from the owner. The receipt protocol verifies again before attaching `Enrolled` and rechecks live authority after the final filesystem await before publication. The model's current exact witness predicate is stronger than finite observations across real engine awaits; that remains a separate transport obligation. Its completion is the enrollment record, not a generation's ready receipt. | +| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh observation and exact archive or xattr value, then re-reads and rechecks ownership without writing. The xattr path never provisions or seeds during verification and brackets each fresh invocation with exact daemon/birth/current-Mountpoint/holder and authority checks. The receipt protocol requires read-only proof before/after startup/run and at both completion receipt boundaries. The CLI refuses witness-bearing startup/run/exec; actual down/up activation remains unqualified. | | `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. Explicit saved `down --recover` can stop engine resources but returns incomplete with `Expected` and pending generation retained, skipping dependent ownership retirement. An explicit read-only reconciliation interface requires its own source review and tests; neither this model nor the foundation implements it. | -| `EmptyReplacement` / `WrongMarker` / `ForeignMetadata` | Owner/codec tests must distinguish absent/wrong bytes, fresh foreign binding, a same-metadata empty replacement and a copied witness. `encodeNativeComposeStorageWitnessArchive` is the proposed carrier codec; decoding bounds, archive path validation and read-only engine transport are outside this model. | +| `EmptyReplacement` / `WrongMarker` / `ForeignMetadata` | Owner/codec controls distinguish absent/wrong bytes, fresh foreign binding and same-metadata empty replacement. Xattr owner controls also refuse an old pinned-root result and same-birth root exchange during final proof. `encodeNativeComposeStorageWitnessArchive` and the tagged xattr codec are distinct; byte decoding, namespace transport and read-only engine behavior are outside this model. A copied matching witness with otherwise identical metadata is outside the empty-replacement guarantee. | | `DriftAuthority` | Existing opaque generation authority must reject changed pending selection or revoked lease before seed/completion, and recheck after asynchronous proofs. The model summarizes those identities by one version; it cannot qualify real lease/token/inode checks. | | `ReplaceExpectation` / `ReenrollReadOnly` | Immutable anchored records must reject replacement and read-only reopen must not append enrollment, even with matching marker bytes. Filesystem substitution and exact write counts require separate source regressions. | diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts new file mode 100644 index 00000000..a8bd62bc --- /dev/null +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -0,0 +1,861 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type NativeComposeGeneration, + type NativeComposeGenerationStore, + type NativeComposeMutation, + openNativeComposeGenerationStore, +} from "../src/lib/native-compose-generation.ts"; +import { + enrollNativeComposeStorageXattrWitness, + type NativeComposeStorageWitnessEnrollment, + prepareNativeComposeStorageXattrWitness, + verifyNativeComposeStorageXattrWitness, +} from "../src/lib/native-compose-storage-witness.ts"; +import { + nativeComposeStorageWitnessReferenceValid, + nativeComposeStorageWitnessStatesValid, +} from "../src/lib/native-compose-storage-witness-state.ts"; +import { + captureNativeComposeStorageXattrCarrier, + type NativeComposeStorageXattrArtifact, + type NativeComposeStorageXattrInvocation, + type NativeComposeStorageXattrPorts, + type NativeComposeStorageXattrTarget, +} from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; +import { encodeNativeComposeStorageXattrResponse } from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; +import { runNativeComposeStorageXattrHelper } from "../src/lib/native-compose-storage-witness-xattr-helper.ts"; + +const engineId = "d".repeat(64); +const volume = { + name: "owned_data", + storage: "data", + createdAt: "2026-10-08T12:00:00Z", +}; +const selection = { name: volume.name, storage: volume.storage }; +const artifact: NativeComposeStorageXattrArtifact = { + version: 1, + imageId: `sha256:${"a".repeat(64)}`, + platform: "linux/arm64", + bunVersion: "1.4.2", + bunHash: "b".repeat(64), + libcHash: "c".repeat(64), + helperHash: "e".repeat(64), + kernelAbi: 1, +}; +const roots: string[] = []; +const stores: NativeComposeGenerationStore[] = []; +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.close())); + await Promise.all( + roots.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ); +}); +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-xattr-owner-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await Bun.write( + join(root, ".hack", "hack.project.json"), + JSON.stringify({ schema_version: 1, name: "xattr" }) + ); + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + }); + stores.push(store); + return store; +} +async function publish(mutation: NativeComposeMutation) { + const reservation = mutation.reserveGeneration(); + const labels = { + "io.hack.native-config.version": "1", + "io.hack.native-config.instance": reservation.identity.composeProject, + "io.hack.native-config.owner": reservation.identity.ownerToken, + }; + return await mutation.publish({ + reservation, + profiles: [], + inputRevision: "a".repeat(64), + assertFresh: async () => {}, + composeJson: JSON.stringify({ + name: reservation.identity.composeProject, + services: { + app: { + image: "synthetic:1", + labels: { + ...labels, + "io.hack.native-config.generation": reservation.generationId, + "io.hack.native-config.workload": "service", + }, + }, + }, + volumes: { + data: { + name: volume.name, + labels: { ...labels, "io.hack.native-config.storage": "data" }, + }, + }, + }), + }); +} +function fake(store: NativeComposeGenerationStore) { + const calls: string[] = []; + const attributes = new Map(); + const state = { + volume: null as typeof volume | null, + root: { device: "1", inode: "42", uid: 0, gid: 0 }, + holders: [] as NativeComposeStorageXattrTarget["holders"], + failure: null as + | "provision" + | "seed" + | "verify" + | "cleanup" + | "artifact" + | "pinned-root" + | "scope" + | "readonly" + | "uid" + | "replay" + | null, + generation: null as NativeComposeGeneration | null, + afterInvoke: null as + | ((input: NativeComposeStorageXattrInvocation) => void) + | null, + }; + let nextId = 1; + const ports: NativeComposeStorageXattrPorts = { + async inspect(selection) { + calls.push("inspect"); + return { + engineId, + runtimeIdentity: store.identity.composeProject, + ownerToken: store.identity.ownerToken, + ...selection, + volume: state.volume, + mountpoint: state.volume + ? `/var/lib/docker/volumes/${selection.name}/_data` + : null, + driver: state.volume ? "local" : null, + options: state.volume ? {} : null, + holders: state.holders, + }; + }, + async provision() { + calls.push("provision"); + const saved = await store.loadCurrent(); + expect(saved.storageWitnessesPending).toBe(true); + expect(saved.storageWitnesses?.[0]).toMatchObject({ + state: "expected", + carrier: "directory-xattr", + artifact, + originalVolume: null, + }); + expect(saved.pending?.generationId).toBe(state.generation?.generationId); + if (state.failure === "provision") { + throw new Error("private create uncertainty"); + } + state.volume = { ...volume }; + }, + async invoke(input: NativeComposeStorageXattrInvocation) { + calls.push(input.request.operation); + if (state.failure === input.request.operation) { + throw new Error("private child uncertainty"); + } + const selectedRoot = + state.failure === "pinned-root" && input.request.operation === "verify" + ? { ...state.root, inode: "41" } + : state.root; + const response = runNativeComposeStorageXattrHelper({ + request: input.request, + kernel: { + effectiveUid: () => input.uid, + effectiveGid: () => input.gid, + probeRoot: () => selectedRoot, + openRoot: () => 42, + statRoot: () => selectedRoot, + createXattr: (_, name, bytes) => { + if (input.readonly || attributes.has(name)) { + throw new Error("EEXIST/EROFS"); + } + attributes.set(name, new Uint8Array(bytes)); + }, + syncRoot: () => {}, + readXattr: (_, name) => attributes.get(name) ?? new Uint8Array(), + closeRoot: () => {}, + }, + }); + const carrierId = (state.failure === "replay" ? 1 : nextId++) + .toString(16) + .padStart(64, "0"); + state.afterInvoke?.(input); + return { + artifact: + state.failure === "artifact" + ? { ...artifact, helperHash: "f".repeat(64) } + : input.artifact, + carrierId, + engineId, + invocationId: input.invocationId, + readonly: + state.failure === "readonly" ? !input.readonly : input.readonly, + uid: state.failure === "uid" ? input.uid + 1 : input.uid, + gid: input.gid, + target: input.target, + scope: + state.failure === "scope" + ? { ...input.scope, pendingToken: "f".repeat(32) } + : input.scope, + response: encodeNativeComposeStorageXattrResponse(response), + outcome: "complete", + exitCode: response.outcome === "refused" ? 1 : 0, + stopped: { id: carrierId, running: false, pid: 0, exitCode: 0 }, + containersAfterCleanup: state.failure === "cleanup" ? [carrierId] : [], + }; + }, + }; + const controller = new AbortController(); + const carrier = captureNativeComposeStorageXattrCarrier({ + artifact, + ports, + signal: controller.signal, + deadline: Date.now() + 60_000, + }); + return { state, ports, carrier, calls, attributes, controller }; +} +async function active() { + const store = await fixture(); + const transport = fake(store); + const { generation, reference } = await store.withMutation( + async (mutation) => { + const published = await publish(mutation); + transport.state.generation = published; + const result = await mutation.runEffect({ + generation: published, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation: published, + engineId, + volume: selection, + admission: "initial-create", + assertAdmission: async () => {}, + carrier: transport.carrier, + }); + const enrolled = await enrollNativeComposeStorageXattrWitness({ + enrollment, + }); + transport.calls.push("workload"); + return { value: enrolled, outcome: "complete" }; + }, + }); + expect(result.outcome).toBe("complete"); + return { generation: published, reference: result.value }; + } + ); + return { store, transport, generation, reference }; +} + +test("durable tagged Expected precedes one cold provision/seed and fresh proof before workload", async () => { + const { store, transport, reference } = await active(); + expect(reference).toMatchObject({ + version: 2, + kind: "directory-xattr", + artifact, + volume, + }); + expect(transport.calls.filter((call) => call === "provision")).toHaveLength( + 1 + ); + expect(transport.calls.filter((call) => call === "seed")).toHaveLength(1); + expect(transport.calls.indexOf("provision")).toBeLessThan( + transport.calls.indexOf("seed") + ); + expect(transport.calls.indexOf("seed")).toBeLessThan( + transport.calls.indexOf("verify") + ); + expect(transport.calls.indexOf("verify")).toBeLessThan( + transport.calls.indexOf("workload") + ); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); + expect((await store.loadCurrent()).pending).toBeNull(); + expect(JSON.stringify(await store.loadCurrent())).not.toContain( + "user.hack.storage." + ); +}); + +test("required xattr artifact/kind cannot be removed or relabeled as a legacy USTAR reference", async () => { + const { store, reference } = await active(); + expect(nativeComposeStorageWitnessReferenceValid(reference)).toBe(true); + if (reference.version !== 2) { + throw new Error("Missing xattr reference"); + } + const { artifact: ignoredArtifact, ...missingArtifact } = reference; + expect(nativeComposeStorageWitnessReferenceValid(missingArtifact)).toBe( + false + ); + expect( + nativeComposeStorageWitnessReferenceValid({ + ...reference, + kind: "file-ustar", + }) + ).toBe(false); + expect( + nativeComposeStorageWitnessReferenceValid({ ...reference, version: 1 }) + ).toBe(false); + const states = (await store.loadCurrent()).storageWitnesses; + expect(nativeComposeStorageWitnessStatesValid(states)).toBe(true); + const tagged = states?.[0]; + if (!tagged || tagged.carrier !== "directory-xattr") { + throw new Error("Missing tagged state"); + } + const { + carrier: ignoredCarrier, + artifact: ignoredPins, + ...downgraded + } = tagged; + expect(nativeComposeStorageWitnessStatesValid([downgraded])).toBe(false); + expect( + nativeComposeStorageWitnessStatesValid([ + { ...tagged, artifact: { ...artifact, kernelAbi: 2 } }, + ]) + ).toBe(false); + // Keep the destructuring intentional: these fields are the adversarial deletion. + expect(ignoredArtifact).toEqual(artifact); + expect(ignoredCarrier).toBe("directory-xattr"); + expect(ignoredPins).toEqual(artifact); +}); + +test.each([ + "missing", + "birth", + "same-birth-empty", + "pinned-root", + "artifact", + "scope", + "cleanup", + "readonly", + "uid", + "replay", +] as const)("resume refuses %s before workload and never provisions/seeds", async (change) => { + const { store, transport, generation } = await active(); + transport.calls.length = 0; + if (change === "missing") { + transport.state.volume = null; + } else if (change === "birth") { + transport.state.volume = { ...volume, createdAt: "2026-10-08T12:00:01Z" }; + } else if (change === "same-birth-empty") { + transport.attributes.clear(); + } else { + transport.state.failure = change; + } + let workloads = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + workloads++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toThrow("values omitted"); + }); + expect(workloads).toBe(0); + expect(transport.calls).not.toContain("provision"); + expect(transport.calls).not.toContain("seed"); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); +}); + +test("fresh readonly proofs tolerate observed UID change and exact owned running holders through run and final fences", async () => { + const { store, transport, generation, reference } = await active(); + transport.state.root.uid = 70; + transport.state.root.gid = 70; + transport.state.holders = [ + { + id: "f".repeat(64), + runtimeIdentity: store.identity.composeProject, + ownerToken: store.identity.ownerToken, + generationId: generation.generationId, + running: true, + }, + ]; + transport.calls.length = 0; + await store.withMutation(async (mutation) => { + await verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + carrier: transport.carrier, + }); + await mutation.runEffect({ + generation, + operation: "run", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + beforeComplete: async () => { + transport.calls.push("finalizer"); + }, + effect: async () => { + transport.calls.push("workload"); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect( + transport.calls.filter((call) => call === "verify").length + ).toBeGreaterThanOrEqual(4); + expect(transport.calls.indexOf("verify")).toBeLessThan( + transport.calls.indexOf("workload") + ); + expect(transport.calls.lastIndexOf("verify")).toBeGreaterThan( + transport.calls.indexOf("finalizer") + ); + expect(transport.calls).not.toContain("provision"); + expect(transport.calls).not.toContain("seed"); +}); + +test.each([ + "provision", + "seed", + "verify", + "cleanup", +] as const)("cold %s uncertainty retains required Expected; saved stop never reenrolls", async (failure) => { + const store = await fixture(); + const transport = fake(store); + transport.state.failure = failure; + let enrollment: NativeComposeStorageWitnessEnrollment | null = null; + const generation = await store.withMutation(async (mutation) => { + const published = await publish(mutation); + transport.state.generation = published; + await expect( + mutation.runEffect({ + generation: published, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + enrollment = await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation: published, + engineId, + volume: selection, + admission: "initial-create", + assertAdmission: async () => {}, + carrier: transport.carrier, + }); + await enrollNativeComposeStorageXattrWitness({ enrollment }); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + return published; + }); + expect((await store.loadCurrent()).storageWitnesses?.[0]).toMatchObject({ + state: "expected", + carrier: "directory-xattr", + artifact, + }); + const attempts = transport.calls.filter( + (call) => call === "seed" || call === "provision" + ); + await expect( + enrollNativeComposeStorageXattrWitness({ enrollment: enrollment ?? {} }) + ).rejects.toThrow("values omitted"); + await store.withMutation(async (mutation) => { + expect( + await mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => ({ value: 0, outcome: "complete" }), + beforeComplete: async () => { + throw new Error("Must not retire"); + }, + }) + ).toEqual({ value: 0, outcome: "uncertain" }); + }); + expect( + transport.calls.filter((call) => call === "seed" || call === "provision") + ).toEqual(attempts); + expect((await store.loadCurrent()).storageWitnessesPending).toBe(true); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); +}); + +test("post-workload marker loss refuses completion, retaining exact pending generation", async () => { + const { store, transport, generation } = await active(); + transport.calls.length = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + transport.calls.push("workload"); + transport.attributes.clear(); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(transport.calls).toContain("workload"); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); + expect(transport.calls).not.toContain("seed"); +}); + +test("same-birth root replacement while awaiting the final reader cannot publish ready from its pinned old directory", async () => { + const { store, transport, generation } = await active(); + let exchanged = false; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + beforeComplete: async () => { + transport.state.afterInvoke = (input) => { + if (!exchanged && input.request.operation === "verify") { + exchanged = true; + transport.state.root = { ...transport.state.root, inode: "43" }; + transport.attributes.clear(); + } + }; + }, + effect: async () => ({ value: 0, outcome: "complete" }), + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(exchanged).toBe(true); + expect(transport.state.volume).toEqual(volume); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); +}); + +test("captured artifacts/ports cannot be substituted and cancelled lifetime stops before engine callbacks", async () => { + const store = await fixture(); + const transport = fake(store); + const originalInspect = transport.ports.inspect; + let substituted = 0; + const mutable = { + artifact: { ...artifact }, + ports: { ...transport.ports }, + signal: transport.controller.signal, + deadline: Date.now() + 60_000, + }; + const carrier = captureNativeComposeStorageXattrCarrier(mutable); + mutable.artifact.helperHash = "f".repeat(64); + mutable.ports.inspect = async () => { + substituted++; + return null; + }; + mutable.ports.invoke = async () => { + substituted++; + return null; + }; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + transport.state.generation = generation; + await mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: selection, + admission: "initial-create", + assertAdmission: async () => {}, + carrier, + }); + await enrollNativeComposeStorageXattrWitness({ enrollment }); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(substituted).toBe(0); + expect(transport.ports.inspect).toBe(originalInspect); + transport.controller.abort(); + transport.calls.length = 0; + const current = await store.loadCurrent(); + const generation = current.generation; + if (!generation) { + throw new Error("Missing generation"); + } + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { kind: "directory-xattr", engineId, carrier }, + effect: async () => ({ value: 0, outcome: "complete" }), + }) + ).rejects.toThrow("values omitted"); + }); + expect(transport.calls).toEqual([]); +}); + +test("prepare captures original carrier, selection and admission callback before its first await", async () => { + const store = await fixture(); + const transport = fake(store); + let substituted = 0; + const poison = captureNativeComposeStorageXattrCarrier({ + artifact, + signal: transport.controller.signal, + deadline: Date.now() + 60_000, + ports: { + inspect: async () => { + substituted++; + return null; + }, + invoke: async () => { + substituted++; + return null; + }, + }, + }); + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + transport.state.generation = generation; + await mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const input = { + authority: mutation.materialAuthority, + generation, + engineId, + volume: { ...selection }, + admission: "initial-create" as const, + carrier: transport.carrier, + assertAdmission: async () => { + input.carrier = poison; + input.engineId = "wrong-engine"; + input.volume.name = "wrong_name"; + input.assertAdmission = async () => { + substituted++; + }; + }, + }; + const enrollment = await prepareNativeComposeStorageXattrWitness(input); + await enrollNativeComposeStorageXattrWitness({ enrollment }); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(substituted).toBe(0); + expect((await store.loadCurrent()).storageWitnesses?.[0]).toMatchObject({ + name: volume.name, + engineId, + state: "enrolled", + carrier: "directory-xattr", + }); +}); + +test("explicit stopped adoption pins the original birth and seeds once without cold provisioning", async () => { + const store = await fixture(); + const transport = fake(store); + transport.state.volume = { ...volume }; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + transport.state.generation = generation; + await mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: selection, + admission: "explicit-adoption", + originalVolume: volume, + assertAdmission: async () => {}, + carrier: transport.carrier, + }); + await enrollNativeComposeStorageXattrWitness({ enrollment }); + return { value: 0, outcome: "complete" }; + }, + }); + }); + expect(transport.calls).not.toContain("provision"); + expect(transport.calls.filter((call) => call === "seed")).toHaveLength(1); + expect((await store.loadCurrent()).storageWitnesses?.[0]).toMatchObject({ + admission: "explicit-adoption", + originalVolume: volume, + state: "enrolled", + }); +}); + +test("initial admission refuses a preexisting volume before required intent, provisioning or seed", async () => { + const store = await fixture(); + const transport = fake(store); + transport.state.volume = { ...volume }; + await store.withMutation(async (mutation) => { + const generation = await publish(mutation); + transport.state.generation = generation; + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + effect: async () => { + await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + volume: selection, + admission: "initial-create", + assertAdmission: async () => {}, + carrier: transport.carrier, + }); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(transport.calls).toEqual(["inspect"]); + expect((await store.loadCurrent()).storageWitnesses).toBeNull(); +}); + +test.each([ + "generation", + "owner", + "runtime", +] as const)("readonly admission refuses a foreign %s holder before helper or workload", async (change) => { + const { store, transport, generation } = await active(); + const holder = { + id: "f".repeat(64), + runtimeIdentity: store.identity.composeProject, + ownerToken: store.identity.ownerToken, + generationId: generation.generationId, + running: true, + }; + if (change === "generation") { + holder.generationId = "f".repeat(32); + } + if (change === "owner") { + holder.ownerToken = "f".repeat(32); + } + if (change === "runtime") { + holder.runtimeIdentity = "foreign"; + } + transport.state.holders = [holder]; + transport.calls.length = 0; + let workloads = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + workloads++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toThrow("values omitted"); + }); + expect(workloads).toBe(0); + expect(transport.calls).toEqual(["inspect"]); +}); + +test("cancellation during readonly carrier await refuses before workload without seed or repair", async () => { + const { store, transport, generation } = await active(); + transport.calls.length = 0; + transport.state.afterInvoke = (input) => { + if (input.request.operation === "verify") { + transport.controller.abort(); + } + }; + let workloads = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "run", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + workloads++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toThrow("values omitted"); + }); + expect(workloads).toBe(0); + expect(transport.calls.filter((call) => call === "verify")).toHaveLength(1); + expect(transport.calls).not.toContain("seed"); + expect(transport.calls).not.toContain("provision"); +}); From 4f3888a26a9a34284f9c5155f7dd4d3ebcf1563c Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 14:08:50 -0400 Subject: [PATCH 12/23] feat: retain finite storage carrier uncertainty --- .../native-compose-storage-witness.md | 6 +- .../native-compose-storage-xattr-carrier.md | 45 ++- src/lib/native-compose-command.ts | 13 +- src/lib/native-compose-generation.ts | 94 ++++- .../native-compose-storage-carrier-journal.ts | 345 ++++++++++++++++++ .../native-compose-storage-witness-receipt.ts | 2 +- .../native-compose-storage-witness-state.ts | 21 +- ...e-compose-storage-witness-xattr-carrier.ts | 31 +- src/lib/native-compose-storage-witness.ts | 109 +++++- tests/models/tla/README.md | 6 +- ...ve-compose-storage-witness-command.test.ts | 194 ++++++++++ ...ompose-storage-witness-xattr-owner.test.ts | 288 ++++++++++++++- 12 files changed, 1074 insertions(+), 80 deletions(-) create mode 100644 src/lib/native-compose-storage-carrier-journal.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 3bb56f94..83a4a536 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -79,7 +79,9 @@ write or archive semantics. The separate [directory-xattr owner and helper source](native-compose-storage-xattr-carrier.md) uses a create-only token without a PostgreSQL data-root directory entry. Required -version-two expectation/reference records and tagged artifact-bound intent keep +version-three expectation/reference records and tagged artifact/journal-bound intent keep it distinct from USTAR under the same version-three receipt owner. It adds injectable cold provisioning and fresh read-only proof ports; no Docker transport -or CLI activation is implemented. Its offline controls do not qualify a carrier. +or CLI activation is implemented. Required finite carrier intent survives unknown +helper outcomes even after enrollment; saved recovery stops resources without +retiring that anchor. Its offline controls do not qualify a carrier. diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index cb4c2f28..18089898 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -38,11 +38,12 @@ facts use canonical unsigned 64-bit decimal strings; UID/GID are unsigned 32-bit integers. Unknown kinds/versions, alternate encodings, duplicate JSON keys, trailing data and extra fields refuse. This format cannot be relabeled as the foundation's USTAR proof or attached to its version-one completion reference. -Xattr expectations and references require version two, `kind: "directory-xattr"`, -and exact image/platform/Bun/libc/helper and kernel-ABI pins. Their version-three -generation intent requires the carrier tag and those artifact pins even while -Expected. Older strict owners reject the new required fields; absence of an -artifact or tag cannot downgrade to USTAR. +Xattr expectations and references require version three, `kind: "directory-xattr"`, +exact image/platform/Bun/libc/helper and kernel-ABI pins, and a required private +carrier-journal token. Their version-three generation intent requires the carrier +tag, artifact pins and journal token even while Expected. Older strict owners +reject the new required fields; absence of a journal, artifact or tag cannot +downgrade to USTAR. The xattr helper wire format remains version one. The Linux adapter fixes the root at `/hack-storage-witness`. It checks Linux arm64 or x64 open constants and fixed architecture-specific glibc paths, uses @@ -150,13 +151,34 @@ carrier IDs or invocation IDs refuse. These predicates validate an injectable contract; they are not proof of actual engine identity or cleanup without the qualified transport. -Durable carrier intent, exact image/program/ID labels, fixed mount correspondence, +The owner initializes a required private `carrier.json` in the anchored witness +slot before provisioning. Every invocation atomically publishes and synchronizes +a prospective intent before calling the captured transport. The transport must +publish the exact created carrier ID and birth through its one-use callback before +start. A response cannot substitute that pin or omit its publication. Only the +owner may publish idle after matching that pin with complete stopped and empty +cleanup observations. No marker values, helper output or commands enter the journal. +An exact canonical refused response with exit one may clear its proven absent +finite helper intent; it still rejects the witness and admits no workload or repair. +The journal changes independently of the generation receipt; the existing exact +material-binding comparison remains unchanged. + +An interrupted prospective or created intent, missing journal or changed anchor +blocks startup, run and normal down without another helper call. Explicit saved +recovery may stop saved engine resources, but remains incomplete with its pending +generation, witness reference and dependent claims retained. It cannot infer helper +absence from an unpublished ID, replay the invocation or reset the journal. Fresh +saved checks also precede both final receipt boundaries and material retirement. + +Exact image/program/ID labels, fixed mount correspondence, no image-declared anonymous volumes, dropped capabilities, no network/ports/socket, readonly root filesystem, finite CPU/memory/PID limits and absolute deadline/group reaping are mandatory before implementing the engine owner. Cancellation or an unknown create/start outcome keeps the anchor. Cleanup must prove the exact helper stopped before non-force removal and never remove data, witness, material or -journals. This source contains no Docker transport or helper cleanup implementation. +journals. This source contains no Docker transport, host deadline/group owner or +helper cleanup implementation. Journal bytes validate the owning port contract; +they do not replace actual stop and absence observations. ## Evidence limits and remaining gates @@ -166,7 +188,10 @@ one create request, no overwrite/repair requests, missing/wrong witness under unchanged metadata, root/credential drift, callback capture and uncertain fsync or close; required Expected before cold provisioning; one seed; exact stopped adoption; resumed missing/same-birth-empty/foreign-root refusals; artifact, -holder, context and cleanup drift; cancellation; and retained interrupted state. +holder, context and cleanup drift; cancellation; retained interrupted state; and +unknown enrolled-helper refusal through explicit saved recovery. These controls +also reject an omitted created callback or changed carrier birth despite a +synthetic successful response. They do not execute the Linux adapter or qualify kernel atomicity, permissions, xattr persistence, filesystem crash durability or Docker semantics. @@ -181,7 +206,9 @@ Count every helper process, container and query. The first straightforward proof needs metadata discovery, a matching-UID read and a fresh metadata observation after that read. Initial enrollment adds a discovery and write helper before its separate three-invocation read-only proof. Required pre/post/final proofs -therefore have a real startup cost. No performance improvement or production +therefore have a real startup cost. Each finite invocation adds three synchronized +private journal publications (intent, created pin and idle), without weakening the +existing main receipt comparison. No performance improvement or production activation is claimed, and no persistent helper is proposed. Primary contracts: Linux [fsetxattr](https://man7.org/linux/man-pages/man2/fsetxattr.2.html), diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 343ca58b..ed2c3fd8 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -632,6 +632,7 @@ async function savedCommand(opts: { }; if (options.operation === "down") { return await store.withMutation(async (mutation) => { + const recoveryState = await store.loadCurrent(); const saved = await savedRouteDocuments(store); const prepared = await prepareSavedStop({ store, @@ -652,7 +653,9 @@ async function savedCommand(opts: { .runEffect({ generation, operation: "down", - recoverPending: options.recover === true && pending !== null, + recoverPending: + options.recover === true && + (pending !== null || recoveryState.storageWitnessesPending), assertOwned, captureStorage: prepared.captureStorage, assertFresh: hooks?.assertFresh, @@ -778,7 +781,8 @@ async function savedCommand(opts: { data: { composeProject: generation.identity.composeProject, stopped: state.stopped, - pending: state.pending !== null, + pending: + state.pending !== null || state.storageWitnessesPending, beforeHooksPending: state.beforeHooksPending, hostHookPhase: state.hostHookPhase, services: observed.containers.map( @@ -800,6 +804,11 @@ async function savedCommand(opts: { "Native host hook completion remains uncertain; hook recovery is not supported in this slice.\n" ); } + if (state.storageWitnessesPending) { + process.stderr.write( + "Native storage continuity or helper completion remains uncertain; saved recovery retains its anchors. Values omitted.\n" + ); + } return await run([...composeArgs(generation), "ps"], { cwd: base.cwd, env: base.env, diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index d15dee94..aeccf37e 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -27,6 +27,7 @@ import { nativeComposeRetainedVolumesValid, } from "./native-compose-retained-storage.ts"; import { projectNativeComposeOneOff } from "./native-compose-run-projection.ts"; +import { nativeComposeStorageCarriersPending } from "./native-compose-storage-carrier-journal.ts"; import { createNativeComposeStorageWitnessReceiptProtocol } from "./native-compose-storage-witness-receipt.ts"; import { type NativeComposeStorageWitnessCompletionProof, @@ -423,6 +424,15 @@ function witnessPending(state: Receipt): boolean { state.storageWitnesses?.some((entry) => entry.state === "expected") ?? false ); } +async function witnessWorkPending(state: Receipt): Promise { + return ( + witnessPending(state) || + (await nativeComposeStorageCarriersPending({ + identity: state.identity, + states: state.storageWitnesses, + })) + ); +} type FileIdentity = { readonly dev: number; readonly ino: number }; type CheckoutAnchor = FileIdentity & { readonly projectDirectory: FileIdentity; @@ -972,7 +982,8 @@ function hookComplete(result: { function admitEffect( input: NativeComposeEffectOptions, state: Receipt, - mode: "prepare" | "saved" | undefined + mode: "prepare" | "saved" | undefined, + admission: { readonly carrierRecovery: boolean } ): void { if (!["up", "restart", "run", "down"].includes(input.operation)) { refuse(); @@ -1015,7 +1026,11 @@ function admitEffect( ) { throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); } - if (input.recoverPending && state.pending === null) { + if ( + input.recoverPending && + state.pending === null && + !admission.carrierRecovery + ) { refuse(); } } @@ -1521,7 +1536,7 @@ export async function openNativeComposeGenerationStore(opts: { await held.file.close(); } }; - const requireFinalPending = ( + const requireFinalPending = async ( latest: Receipt, pending: Receipt["pending"], operation: NativeComposeOperation, @@ -1531,7 +1546,7 @@ export async function openNativeComposeGenerationStore(opts: { JSON.stringify(latest.pending) !== JSON.stringify(pending) || ((operation !== "down" || hasDownHooks) && latest.beforeHooks !== null) || - witnessPending(latest) + (await witnessWorkPending(latest)) ) { refuse(); } @@ -1574,7 +1589,7 @@ export async function openNativeComposeGenerationStore(opts: { await rememberStorage(input); await assertWitnesses(); let latest = await receipt(); - requireFinalPending( + await requireFinalPending( latest, pending, input.operation, @@ -1593,7 +1608,7 @@ export async function openNativeComposeGenerationStore(opts: { await rememberStorage(input); await assertWitnesses(); latest = await receipt(); - requireFinalPending( + await requireFinalPending( latest, pending, input.operation, @@ -1632,7 +1647,7 @@ export async function openNativeComposeGenerationStore(opts: { storageWitnesses: state.storageWitnesses ? structuredClone(state.storageWitnesses) : null, - storageWitnessesPending: witnessPending(state), + storageWitnessesPending: await witnessWorkPending(state), }; Object.defineProperty(result, "retainedStorage", { enumerable: false }); Object.defineProperty(result, "storageWitnesses", { @@ -1682,6 +1697,16 @@ export async function openNativeComposeGenerationStore(opts: { refuse(); } }; + const matchesMutationReceipt = ( + read: Awaited> + ) => + mutationReceipt !== null && + sameFile(read.info, mutationReceipt) && + read.text === mutationReceipt.text; + const sameReceiptRead = ( + left: Awaited>, + right: Awaited> + ) => sameFile(left.info, right.info) && left.text === right.text; materialActions.set(materialAuthority, async (run) => { requireActive(); const work = run(); @@ -1763,11 +1788,7 @@ export async function openNativeComposeGenerationStore(opts: { requireActive(); await lease.assertHeld(); const read = await readPrivate(receiptPath, RECEIPT_LIMIT); - if ( - mutationReceipt === null || - !sameFile(read.info, mutationReceipt) || - read.text !== mutationReceipt.text - ) { + if (!matchesMutationReceipt(read)) { return refuse(); } const state = parseReceipt( @@ -1779,13 +1800,16 @@ export async function openNativeComposeGenerationStore(opts: { selection, state ); + if ( + selection.phase === "retire" && + (await witnessWorkPending(state)) + ) { + return refuse(); + } await lease.assertHeld(); const latest = await readPrivate(receiptPath, RECEIPT_LIMIT); requireActive(); - if ( - !sameFile(read.info, latest.info) || - read.text !== latest.text - ) { + if (!sameReceiptRead(read, latest)) { return refuse(); } requireMaterialEffectLive({ @@ -1937,7 +1961,7 @@ export async function openNativeComposeGenerationStore(opts: { ) => { if ( result.outcome !== "complete" || - witnessPending(await receipt()) + (await witnessWorkPending(await receipt())) ) { // Saved recovery may stop engine resources, but unresolved enrollment keeps its anchor. await rememberStorage(input); @@ -2251,13 +2275,45 @@ export async function openNativeComposeGenerationStore(opts: { }); return await runAction(async () => { requireActive(); + const admit = async (state: Receipt) => { + const unknown = await witnessWorkPending(state); + if ( + unknown && + !( + input.operation === "down" && + input.recoverPending === true + ) + ) { + throw new NativeComposeGenerationError( + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + } + // A read-only preflight can leave required helper intent before a + // main workload intent exists. Only exact saved down recovery may + // arm its stop; all generic empty-pending recovery still refuses. + const carrierRecovery = + unknown && + state.pending === null && + !witnessPending(state) && + input.operation === "down" && + input.recoverPending === true && + state.current?.generationId === + input.generation.generationId && + (state.storageWitnesses?.some( + (entry) => + entry.state === "enrolled" && + entry.reference.version === 3 + ) ?? + false); + admitEffect(input, state, opts.mode, { carrierRecovery }); + }; let state = await receipt(); - admitEffect(input, state, opts.mode); + await admit(state); await checkEffect(input); // Ownership can publish newly observed births. Never overwrite them // with the pre-observation receipt when arming the effect intent. state = await receipt(); - admitEffect(input, state, opts.mode); + await admit(state); const anchor = knownAnchor(input.generation); const pending = effectPending(state, input, anchor); await save({ ...state, pending }); diff --git a/src/lib/native-compose-storage-carrier-journal.ts b/src/lib/native-compose-storage-carrier-journal.ts new file mode 100644 index 00000000..a059b209 --- /dev/null +++ b/src/lib/native-compose-storage-carrier-journal.ts @@ -0,0 +1,345 @@ +import { createHash, randomBytes } from "node:crypto"; +import { rename } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "./guards.ts"; +import type { NativeComposeIdentity } from "./native-compose-generation.ts"; +import { + type HeldDirectory, + holdDirectory, + keys, + parsePrivateJson, + readPrivate, + recheckDirectories, + sameFile, + writeExclusive, +} from "./native-compose-private-state.ts"; +import { nativeComposeRetainedVolumesValid } from "./native-compose-retained-storage.ts"; +import type { NativeComposeStorageWitnessState } from "./native-compose-storage-witness-state.ts"; +import { + type NativeComposeStorageXattrInvocation, + nativeComposeStorageXattrArtifactValid, +} from "./native-compose-storage-witness-xattr-carrier.ts"; +import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; + +const LIMIT = 16 * 1024; +const TOKEN = /^[a-f0-9]{32}$/; +const HASH = /^[a-f0-9]{64}$/; +const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; +const RUNTIME = /^[a-z0-9][a-z0-9_-]{0,127}$/; +type Created = { readonly id: string; readonly createdAt: string }; +type Intent = { + readonly invocationId: string; + readonly engineId: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; + readonly volume: NativeComposeStorageXattrInvocation["target"]["volume"]; + readonly artifact: NativeComposeStorageXattrInvocation["artifact"]; + readonly scope: NativeComposeStorageXattrInvocation["scope"]; + readonly operation: NativeComposeStorageXattrInvocation["request"]["operation"]; + readonly readonly: boolean; + readonly uid: number; + readonly gid: number; + readonly created: Created | null; +}; +type Journal = { + readonly version: 1; + readonly token: string; + readonly revision: number; + readonly intent: Intent | null; +}; +type Bound = { + readonly directory: HeldDirectory; + readonly token: string; + readonly check: () => Promise; +}; +function created(value: unknown): Created { + if ( + !( + isRecord(value) && + keys(value, "createdAt,id") && + typeof value.id === "string" && + HASH.test(value.id) && + nativeComposeRetainedVolumesValid([ + { name: "carrier", storage: "carrier", createdAt: value.createdAt }, + ]) + ) + ) { + return refuse(); + } + return Object.freeze({ id: value.id, createdAt: String(value.createdAt) }); +} +function scopeValid(value: unknown): boolean { + return ( + isRecord(value) && + keys( + value, + "currentGenerationId,generationId,pendingGenerationId,pendingToken" + ) && + typeof value.generationId === "string" && + TOKEN.test(value.generationId) && + [ + value.currentGenerationId, + value.pendingGenerationId, + value.pendingToken, + ].every( + (entry) => + entry === null || (typeof entry === "string" && TOKEN.test(entry)) + ) + ); +} +function intentValid(value: unknown): value is Intent { + return ( + isRecord(value) && + keys( + value, + "artifact,created,engineId,gid,invocationId,operation,ownerToken,readonly,runtimeIdentity,scope,uid,volume" + ) && + typeof value.invocationId === "string" && + TOKEN.test(value.invocationId) && + typeof value.engineId === "string" && + ENGINE.test(value.engineId) && + typeof value.runtimeIdentity === "string" && + RUNTIME.test(value.runtimeIdentity) && + typeof value.ownerToken === "string" && + TOKEN.test(value.ownerToken) && + nativeComposeRetainedVolumesValid([value.volume]) && + nativeComposeStorageXattrArtifactValid(value.artifact) && + scopeValid(value.scope) && + ["root", "verify", "seed"].includes(String(value.operation)) && + value.readonly === (value.operation !== "seed") && + [value.uid, value.gid].every( + (entry) => + typeof entry === "number" && + Number.isInteger(entry) && + entry >= 0 && + entry <= 4_294_967_295 && + !Object.is(entry, -0) + ) && + (value.created === null || Boolean(created(value.created))) + ); +} +function decode(text: string, token: string): Journal { + const value = parsePrivateJson(text); + if ( + !( + isRecord(value) && + keys(value, "intent,revision,token,version") && + value.version === 1 && + value.token === token && + TOKEN.test(token) && + typeof value.revision === "number" && + Number.isSafeInteger(value.revision) && + value.revision >= 0 && + (value.intent === null || intentValid(value.intent)) + ) + ) { + return refuse(); + } + return Object.freeze({ + version: 1, + token, + revision: value.revision, + intent: value.intent === null ? null : value.intent, + }); +} +async function read(bound: Bound) { + const value = await readPrivate( + join(bound.directory.path, "carrier.json"), + LIMIT + ); + await recheckDirectories([bound.directory]); + return { ...value, state: decode(value.text, bound.token) }; +} +async function save( + bound: Bound, + prior: Awaited>, + intent: Intent | null +): Promise { + if (prior.state.revision >= Number.MAX_SAFE_INTEGER) { + return refuse(); + } + const text = JSON.stringify({ + version: 1, + token: bound.token, + revision: prior.state.revision + 1, + intent, + }); + decode(text, bound.token); + const path = join(bound.directory.path, "carrier.json"); + const temporary = join( + bound.directory.path, + `.carrier-${randomBytes(16).toString("hex")}` + ); + const written = await writeExclusive(temporary, text); + const latest = await read(bound); + if (!sameFile(latest.info, prior.info) || latest.text !== prior.text) { + return refuse(); + } + await bound.check(); + await rename(temporary, path); + await bound.directory.file.sync(); + const published = await read(bound); + if (!sameFile(published.info, written) || published.text !== text) { + return refuse(); + } + await bound.check(); +} +/** Required before any finite carrier effects. A missing or changed journal is never initialized on resume. */ +export async function initializeNativeComposeStorageCarrierJournal( + opts: Bound +): Promise { + const bound = Object.freeze({ + directory: Object.freeze({ ...opts.directory }), + token: opts.token, + check: opts.check, + }); + if (!TOKEN.test(bound.token)) { + return refuse(); + } + await writeExclusive( + join(bound.directory.path, "carrier.json"), + JSON.stringify({ + version: 1, + token: bound.token, + revision: 0, + intent: null, + }) + ); + await bound.directory.file.sync(); + await read(bound); + await bound.check(); +} +/** The transport receives only created(), never the owner's completion authority. */ +export async function beginNativeComposeStorageCarrierIntent( + opts: Bound & { + readonly input: Omit; + } +) { + const input = structuredClone(opts.input); + const bound = Object.freeze({ + directory: Object.freeze({ ...opts.directory }), + token: opts.token, + check: opts.check, + }); + const prior = await read(bound); + if (prior.state.intent !== null || input.target.volume === null) { + return refuse(); + } + const intent: Intent = Object.freeze({ + invocationId: input.invocationId, + engineId: input.target.engineId, + runtimeIdentity: input.target.runtimeIdentity, + ownerToken: input.target.ownerToken, + volume: input.target.volume, + artifact: input.artifact, + scope: input.scope, + operation: input.request.operation, + readonly: input.readonly, + uid: input.uid, + gid: input.gid, + created: null, + }); + await save(bound, prior, intent); + let recorded: Created | null = null; + let recording = false; + let completing = false; + const requireIntent = async () => { + const latest = await read(bound); + if ( + JSON.stringify(latest.state.intent) !== + JSON.stringify({ ...intent, created: recorded }) + ) { + return refuse(); + } + return latest; + }; + return Object.freeze({ + async recordCreated(value: Created): Promise { + // Consume before awaiting; copies, duplicate publication and concurrent callbacks refuse. + if (recording || recorded) { + return refuse(); + } + recording = true; + const selected = created(structuredClone(value)); + await bound.check(); + const latest = await requireIntent(); + await save(bound, latest, { ...intent, created: selected }); + recorded = selected; + }, + async complete(value: Created): Promise { + if (completing) { + return refuse(); + } + completing = true; + const selected = created(structuredClone(value)); + if (!recorded || JSON.stringify(recorded) !== JSON.stringify(selected)) { + return refuse(); + } + await bound.check(); + const latest = await requireIntent(); + await save(bound, latest, null); + }, + }); +} + +/** Saved-only check. Broken anchors and unknown intent block admission/retirement but permit explicit saved stop. */ +export async function nativeComposeStorageCarriersPending(opts: { + readonly identity: NativeComposeIdentity; + readonly states: readonly NativeComposeStorageWitnessState[] | undefined; +}): Promise { + for (const state of opts.states ?? []) { + if (state.state === "expected") { + return true; + } + if (state.reference.version !== 3) { + continue; + } + const held: HeldDirectory[] = []; + try { + const parent = join( + opts.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + opts.identity.instanceId + ); + held.push(await holdDirectory(parent, true)); + held.push(await holdDirectory(join(parent, "storage-witnesses"), true)); + held.push( + await holdDirectory( + join( + held[1]?.path ?? refuse(), + createHash("sha256").update(state.name).digest("hex") + ), + true + ) + ); + const root = held[1] ?? refuse(); + const directory = held[2] ?? refuse(); + if ( + !( + sameFile(root.info, state.reference.root) && + sameFile(directory.info, state.reference.directory) + ) + ) { + return true; + } + const current = await read({ + directory, + token: state.reference.carrierJournalToken, + check: async () => { + // Saved status has no effect authority; private anchors are checked below. + }, + }); + await recheckDirectories(held); + if (current.state.intent !== null) { + return true; + } + } catch { + return true; + } finally { + await Promise.allSettled(held.map((entry) => entry.file.close())); + } + } + return false; +} diff --git a/src/lib/native-compose-storage-witness-receipt.ts b/src/lib/native-compose-storage-witness-receipt.ts index 85ccd285..e766e9e9 100644 --- a/src/lib/native-compose-storage-witness-receipt.ts +++ b/src/lib/native-compose-storage-witness-receipt.ts @@ -152,7 +152,7 @@ export function createNativeComposeStorageWitnessReceiptProtocol< const { verifyNativeComposeStorageWitness } = await import( "./native-compose-storage-witness.ts" ); - if (entry.reference.version === 2) { + if (entry.reference.version === 3) { if (carrier.kind !== "directory-xattr") { return refuse(); } diff --git a/src/lib/native-compose-storage-witness-state.ts b/src/lib/native-compose-storage-witness-state.ts index b4b70097..6f18f550 100644 --- a/src/lib/native-compose-storage-witness-state.ts +++ b/src/lib/native-compose-storage-witness-state.ts @@ -27,9 +27,10 @@ export type NativeComposeStorageWitnessReference = ReferenceAnchors & ( | { readonly version: 1 } | { - readonly version: 2; + readonly version: 3; readonly kind: "directory-xattr"; readonly artifact: NativeComposeStorageXattrArtifact; + readonly carrierJournalToken: string; } ); /** Issued privately by the witness owner. An object copy cannot authorize receipt publication. */ @@ -51,6 +52,7 @@ export type NativeComposeStorageWitnessIntent = IntentBinding & | { readonly carrier: "directory-xattr"; readonly artifact: NativeComposeStorageXattrArtifact; + readonly carrierJournalToken: string; } ); /** Required v3 state: Expected survives every interruption; only this invocation can enroll. */ @@ -93,12 +95,14 @@ export function nativeComposeStorageWitnessReferenceValid( isRecord(value) && ((value.version === 1 && keys(value, "completion,directory,expectation,root,version,volume")) || - (value.version === 2 && + (value.version === 3 && keys( value, - "artifact,completion,directory,expectation,kind,root,version,volume" + "artifact,carrierJournalToken,completion,directory,expectation,kind,root,version,volume" ) && value.kind === "directory-xattr" && + typeof value.carrierJournalToken === "string" && + TOKEN.test(value.carrierJournalToken) && nativeComposeStorageXattrArtifactValid(value.artifact))) && nativeComposeRetainedVolumesValid([value.volume]) && directoryValid(value.root) && @@ -116,9 +120,11 @@ export function nativeComposeStorageWitnessIntentValid( (Object.hasOwn(value, "carrier") ? keys( value, - "admission,artifact,carrier,engineId,generationId,name,originalVolume,pendingToken,storage" + "admission,artifact,carrier,carrierJournalToken,engineId,generationId,name,originalVolume,pendingToken,storage" ) && value.carrier === "directory-xattr" && + typeof value.carrierJournalToken === "string" && + TOKEN.test(value.carrierJournalToken) && nativeComposeStorageXattrArtifactValid(value.artifact) : keys( value, @@ -161,7 +167,8 @@ function enrolledReferenceMatches( } const carrierMatches = intent.carrier === "directory-xattr" - ? reference.version === 2 && + ? reference.version === 3 && + reference.carrierJournalToken === intent.carrierJournalToken && JSON.stringify( captureNativeComposeStorageXattrArtifact(reference.artifact) ) === @@ -202,7 +209,9 @@ export function nativeComposeStorageWitnessStatesValid( "state", "storage", ...(entry.state === "expected" ? [] : ["reference"]), - ...(Object.hasOwn(entry, "carrier") ? ["artifact", "carrier"] : []), + ...(Object.hasOwn(entry, "carrier") + ? ["artifact", "carrier", "carrierJournalToken"] + : []), ] .sort() .join(",") diff --git a/src/lib/native-compose-storage-witness-xattr-carrier.ts b/src/lib/native-compose-storage-witness-xattr-carrier.ts index 9362f558..f964ad3f 100644 --- a/src/lib/native-compose-storage-witness-xattr-carrier.ts +++ b/src/lib/native-compose-storage-witness-xattr-carrier.ts @@ -90,6 +90,11 @@ export type NativeComposeStorageXattrTarget = { readonly holders: readonly Holder[]; }; export type NativeComposeStorageXattrInvocation = { + /** Durably pin the exact created carrier before starting it; invocation intent already exists. */ + readonly recordCreated: (value: { + readonly id: string; + readonly createdAt: string; + }) => Promise; readonly invocationId: string; readonly artifact: NativeComposeStorageXattrArtifact; readonly target: NativeComposeStorageXattrTarget; @@ -303,6 +308,7 @@ export function checkNativeComposeStorageXattrResult(opts: { }): { readonly response: NativeComposeStorageXattrResponse; readonly responseHash: string; + readonly created: { readonly id: string; readonly createdAt: string }; } { const { value, input } = opts; const captured = parts(opts.carrier); @@ -311,10 +317,10 @@ export function checkNativeComposeStorageXattrResult(opts: { isRecord(value) && keys( value, - "artifact,carrierId,containersAfterCleanup,engineId,exitCode,gid,invocationId,outcome,readonly,response,scope,stopped,target,uid" + "artifact,carrierCreatedAt,carrierId,containersAfterCleanup,engineId,exitCode,gid,invocationId,outcome,readonly,response,scope,stopped,target,uid" ) && value.outcome === "complete" && - value.exitCode === 0 && + (value.exitCode === 0 || value.exitCode === 1) && value.invocationId === input.invocationId && TOKEN.test(input.invocationId) && !captured.invocationIds.has(input.invocationId) && @@ -343,7 +349,14 @@ export function checkNativeComposeStorageXattrResult(opts: { value.stopped.id === value.carrierId && value.stopped.running === false && value.stopped.pid === 0 && - value.stopped.exitCode === 0 && + value.stopped.exitCode === value.exitCode && + nativeComposeRetainedVolumesValid([ + { + name: "carrier", + storage: "carrier", + createdAt: value.carrierCreatedAt, + }, + ]) && Array.isArray(value.containersAfterCleanup) && value.containersAfterCleanup.length === 0 && typeof value.response === "string" @@ -362,19 +375,17 @@ export function checkNativeComposeStorageXattrResult(opts: { return refuse(); } const response = parseNativeComposeStorageXattrResponse(value.response); - let outcome = "verified"; - if (input.request.operation === "root") { - outcome = "root"; - } else if (input.request.operation === "seed") { - outcome = "seeded"; - } - if (response.outcome !== outcome) { + if ((response.outcome === "refused") !== (value.exitCode === 1)) { return refuse(); } captured.invocationIds.add(input.invocationId); captured.carrierIds.add(value.carrierId); return Object.freeze({ response, + created: Object.freeze({ + id: value.carrierId, + createdAt: String(value.carrierCreatedAt), + }), responseHash: createHash("sha256").update(value.response).digest("hex"), }); } diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index 2535123e..a89149a8 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -26,6 +26,10 @@ import { type NativeComposeRetainedVolume, nativeComposeRetainedVolumesValid, } from "./native-compose-retained-storage.ts"; +import { + beginNativeComposeStorageCarrierIntent, + initializeNativeComposeStorageCarrierJournal, +} from "./native-compose-storage-carrier-journal.ts"; import { encodeNativeComposeStorageWitnessArchive, type NativeComposeStorageWitnessMarker, @@ -88,9 +92,10 @@ type ExpectationBinding = { readonly originalVolume: NativeComposeRetainedVolume | null; }; type XattrExpectation = ExpectationBinding & { - readonly version: 2; + readonly version: 3; readonly kind: "directory-xattr"; readonly artifact: NativeComposeStorageXattrArtifact; + readonly carrierJournalToken: string; readonly marker: NativeComposeStorageXattrMarker; }; type Expectation = ExpectationBinding & @@ -113,7 +118,7 @@ type Completion = CompletionBinding & ( | { readonly version: 1 } | { - readonly version: 2; + readonly version: 3; readonly kind: "directory-xattr"; readonly kernelProof: KernelProof; } @@ -220,11 +225,11 @@ function expectationRecordValid(value: unknown): value is Record< isRecord(value) && ((value.version === 1 && keys(value, "admission,binding,marker,originalVolume,version")) || - (value.version === 2 && + (value.version === 3 && value.kind === "directory-xattr" && keys( value, - "admission,artifact,binding,kind,marker,originalVolume,version" + "admission,artifact,binding,carrierJournalToken,kind,marker,originalVolume,version" ))) && bindingValid(value.binding) && isRecord(value.marker) && @@ -288,9 +293,14 @@ function expectation(text: string): Expectation { } return { ...common, - version: 2, + version: 3, kind: "directory-xattr", artifact: captureNativeComposeStorageXattrArtifact(value.artifact), + carrierJournalToken: + typeof value.carrierJournalToken === "string" && + TOKEN.test(value.carrierJournalToken) + ? value.carrierJournalToken + : refuse(), marker: { kind: marker.kind, version: marker.version, @@ -312,7 +322,7 @@ function completion(text: string): Completion { !( isRecord(value) && ((value.version === 1 && keys(value, "expectationHash,version,volume")) || - (value.version === 2 && + (value.version === 3 && value.kind === "directory-xattr" && keys(value, "expectationHash,kernelProof,kind,version,volume") && isRecord(value.kernelProof) && @@ -344,7 +354,7 @@ function completion(text: string): Completion { } return { ...common, - version: 2, + version: 3, kind: "directory-xattr", kernelProof: { root: { ...value.kernelProof.root }, @@ -451,6 +461,7 @@ async function invokeXattr(opts: { readonly volume: NativeComposeRetainedVolume; readonly stopped: boolean; readonly check: XattrCheck; + readonly journal: HeldDirectory; readonly request: Parameters< typeof decodeNativeComposeStorageXattrRequest >[0]; @@ -462,7 +473,7 @@ async function invokeXattr(opts: { } const current = await opts.check(); checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); - const input: NativeComposeStorageXattrInvocation = Object.freeze({ + const captured = Object.freeze({ invocationId: randomBytes(16).toString("hex"), artifact: opts.saved.artifact, target, @@ -477,6 +488,21 @@ async function invokeXattr(opts: { pendingToken: current.pendingToken, }), }); + const journal = await beginNativeComposeStorageCarrierIntent({ + directory: opts.journal, + token: opts.saved.carrierJournalToken, + check: opts.check, + input: captured, + }); + const beforeInvoke = await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + if (JSON.stringify(current) !== JSON.stringify(beforeInvoke)) { + return refuse(); + } + const input: NativeComposeStorageXattrInvocation = Object.freeze({ + ...captured, + recordCreated: journal.recordCreated, + }); const value = await nativeComposeStorageXattrCarrierPorts( opts.carrier ).invoke(input); @@ -495,10 +521,18 @@ async function invokeXattr(opts: { if (JSON.stringify(after) !== JSON.stringify(target)) { return refuse(); } + await journal.complete(result.created); + await opts.check(); + checkNativeComposeStorageXattrCarrierLifetime(opts.carrier); + const expectedOutcome = ( + { root: "root", seed: "seeded", verify: "verified" } as const + )[request.operation]; + if (result.response.outcome !== expectedOutcome) { + return refuse(); + } if ( request.operation !== "root" && - (result.response.outcome === "refused" || - !sameNativeComposeStorageXattrRoot(result.response.root, request.root)) + !sameNativeComposeStorageXattrRoot(result.response.root, request.root) ) { return refuse(); } @@ -510,6 +544,7 @@ async function observeXattr(opts: { readonly volume: NativeComposeRetainedVolume; readonly stopped: boolean; readonly check: XattrCheck; + readonly journal: HeldDirectory; }): Promise<{ readonly volume: NativeComposeRetainedVolume; readonly kernelProof: KernelProof; @@ -563,6 +598,7 @@ async function seedXattr(opts: { readonly carrier: NativeComposeStorageXattrCarrier; readonly saved: XattrExpectation; readonly check: XattrCheck; + readonly journal: HeldDirectory; }): Promise<{ readonly volume: NativeComposeRetainedVolume; readonly kernelProof: KernelProof; @@ -725,13 +761,14 @@ async function observeEnrollment(opts: { readonly archive: Uint8Array; }>; readonly check: XattrCheck; + readonly journal: HeldDirectory; }): Promise { const { saved, carrier, seed, observe, check } = opts; - if (saved.version === 2) { + if (saved.version === 3) { if (seed || observe || !carrier) { return refuse(); } - return await seedXattr({ carrier, saved, check }); + return await seedXattr({ carrier, saved, check, journal: opts.journal }); } if (!(seed && observe) || carrier) { return refuse(); @@ -772,7 +809,8 @@ function expectationMatchesReference(opts: { return true; } return ( - reference.version === 2 && + reference.version === 3 && + saved.carrierJournalToken === reference.carrierJournalToken && JSON.stringify(saved.artifact) === JSON.stringify( captureNativeComposeStorageXattrArtifact(reference.artifact) @@ -835,9 +873,10 @@ export async function prepareNativeComposeStorageWitness(opts: { const record: Expectation = artifact ? { ...common, - version: 2, + version: 3, kind: "directory-xattr", artifact, + carrierJournalToken: randomBytes(16).toString("hex"), marker: createNativeComposeStorageXattrMarker(), } : { @@ -854,7 +893,7 @@ export async function prepareNativeComposeStorageWitness(opts: { return refuse(); } await input.assertAdmission(); - if (input.xattrCarrier && record.version === 2) { + if (input.xattrCarrier && record.version === 3) { const target = await inspectXattr({ carrier: input.xattrCarrier, saved: record, @@ -886,8 +925,13 @@ export async function prepareNativeComposeStorageWitness(opts: { authority: input.authority, generation: input.generation, intent: - record.version === 2 - ? { ...intent, carrier: record.kind, artifact: record.artifact } + record.version === 3 + ? { + ...intent, + carrier: record.kind, + artifact: record.artifact, + carrierJournalToken: record.carrierJournalToken, + } : intent, }); const held = await directories({ @@ -910,6 +954,28 @@ export async function prepareNativeComposeStorageWitness(opts: { if (!matchesAdmission(binding, latest)) { return refuse(); } + if (record.version === 3) { + await initializeNativeComposeStorageCarrierJournal({ + directory: last(held), + token: record.carrierJournalToken, + check: async () => { + await checkedExpectation(held, anchor({ info, text })); + const live = await assertNativeComposeMaterialAuthority({ + authority: input.authority, + generation: input.generation, + phase: "effect", + }); + if (!matchesAdmission(binding, live)) { + return refuse(); + } + if (input.xattrCarrier) { + checkNativeComposeStorageXattrCarrierLifetime( + input.xattrCarrier + ); + } + }, + }); + } if (input.xattrCarrier) { checkNativeComposeStorageXattrCarrierLifetime(input.xattrCarrier); } @@ -991,6 +1057,7 @@ export async function enrollNativeComposeStorageWitness(opts: { seed, observe, check: live, + journal: last(held), }); const volume = parseVolume(observed.volume); if ( @@ -1006,7 +1073,7 @@ export async function enrollNativeComposeStorageWitness(opts: { saved.version === 1 ? { version: 1, expectationHash: selected.anchor.hash, volume } : { - version: 2, + version: 3, kind: "directory-xattr", expectationHash: selected.anchor.hash, volume, @@ -1042,9 +1109,10 @@ export async function enrollNativeComposeStorageWitness(opts: { ? { ...anchors, version: 1 as const } : { ...anchors, - version: 2 as const, + version: 3 as const, kind: "directory-xattr" as const, artifact: saved.artifact, + carrierJournalToken: saved.carrierJournalToken, } ); const proof = Object.freeze({}); @@ -1103,7 +1171,7 @@ export async function verifyNativeComposeStorageWitness(opts: { return refuse(); } if ( - reference.version === 2 && + reference.version === 3 && xattrCarrier && JSON.stringify( captureNativeComposeStorageXattrArtifact(reference.artifact) @@ -1172,6 +1240,7 @@ export async function verifyNativeComposeStorageWitness(opts: { saved, volume: reference.volume, stopped: false, + journal: last(held), check: async () => { await checkedExpectation(held, reference.expectation); await readCompletion(); diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index baad898f..fb31907e 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -63,11 +63,11 @@ fail the runner. | Model action/state | Intended foundation mapping and separate implementation obligation | | --- | --- | | `ReserveIntent` / `intent` | `prepareNativeComposeStorageWitness` first calls `armNativeComposeStorageWitnessIntent` to publish a required v3 `Expected` entry under the existing generation mutation, then exclusively creates and synchronizes the private per-volume slot before writing the expectation. An empty or absent interrupted slot retains the receipt intent and cannot be recreated. The model's slot intent abstracts that refusal; it does not model the enclosing generation receipt. | -| `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` validates active pending-generation authority, exclusively publishes/syncs an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then returns an opaque live capability. Its explicit `prepareNativeComposeStorageXattrWitness` path additionally requires captured artifact pins and a distinct version-two directory-xattr expectation; the v3 intent requires the carrier tag and artifact before slot creation. A failed publication must not grant a capability. | +| `PublishExpected` / `expected` | `prepareNativeComposeStorageWitness` validates active pending-generation authority, exclusively publishes/syncs an immutable random 32-byte expectation and exact engine/instance/owner/physical-volume/logical-volume/generation/pending binding, then returns an opaque live capability. Its explicit `prepareNativeComposeStorageXattrWitness` path additionally requires captured artifact pins and a distinct version-three directory-xattr expectation; the v3 intent requires the carrier tag, artifact and private journal token before slot creation. A failed publication must not grant a capability. | | `ArmSeed` / `PublishMarker` | `enrollNativeComposeStorageWitness` consumes the original capability before awaiting its single seed callback. The explicit xattr path instead permits one captured cold-provision callback and one seed invocation, then separate read-only proof. `runNativeComposeStorageXattrHelper` requests `XATTR_CREATE` once through syscall ports, without retry/repair. Actual cold newness, non-creating current-root transport, child ownership/reaping, Linux ABI and kernel durability remain unqualified. `CreateVolume` is an abstract boundary, not evidence that this source creates Docker volumes. | | `ObserveEnrollment` / `CommitEnrollment` | The owner verifies the distinct archive or xattr result, exclusively publishes/syncs/re-reads the completion file, and rechecks owner/expectation anchors. The xattr path binds exact artifact/context/volume/root and canonical kernel response hash; fresh root discovery, token read and post-read root discovery are separate injectable invocations. It issues a private one-use proof bound to the exact live authority and generation; `publishNativeComposeStorageWitnessEnrollment` consumes only that issued proof and obtains its reference and fresh verifier from the owner. The receipt protocol verifies again before attaching `Enrolled` and rechecks live authority after the final filesystem await before publication. The model's current exact witness predicate is stronger than finite observations across real engine awaits; that remains a separate transport obligation. Its completion is the enrollment record, not a generation's ready receipt. | -| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh observation and exact archive or xattr value, then re-reads and rechecks ownership without writing. The xattr path never provisions or seeds during verification and brackets each fresh invocation with exact daemon/birth/current-Mountpoint/holder and authority checks. The receipt protocol requires read-only proof before/after startup/run and at both completion receipt boundaries. The CLI refuses witness-bearing startup/run/exec; actual down/up activation remains unqualified. | -| `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. Explicit saved `down --recover` can stop engine resources but returns incomplete with `Expected` and pending generation retained, skipping dependent ownership retirement. An explicit read-only reconciliation interface requires its own source review and tests; neither this model nor the foundation implements it. | +| `Down` / `BeginResume` / `ReadWitness` | `verifyNativeComposeStorageWitness` validates the saved reference, anchored files/directories, fresh observation and exact archive or xattr value, then re-reads and rechecks ownership without writing storage. Xattr verification publishes/synchronizes private finite helper intent while keeping the volume read-only; it never provisions, seeds or reenrolls, and brackets each fresh invocation with exact daemon/birth/current-Mountpoint/holder and authority checks. The receipt protocol requires read-only storage proof before/after startup/run and at both completion receipt boundaries. The CLI refuses witness-bearing startup/run/exec; actual down/up activation remains unqualified. | +| `Crash` / `Recover` / `RecoveryDisposition` | Persisted `Expected` without confirmed completion retains its original bytes and refuses inspection-to-enrollment promotion. Reopened records cannot recover an original seed capability. Required finite xattr carrier intent also survives unknown outcomes after enrollment; saved checks block replay and retirement, including unpublished helper IDs. Explicit saved `down --recover` can stop engine resources but returns incomplete with unresolved intent and pending generation retained, skipping dependent ownership retirement. An explicit read-only reconciliation interface requires its own source review and tests; neither this model nor the foundation implements it. The model does not represent finite helper intent/publication/cleanup. | | `EmptyReplacement` / `WrongMarker` / `ForeignMetadata` | Owner/codec controls distinguish absent/wrong bytes, fresh foreign binding and same-metadata empty replacement. Xattr owner controls also refuse an old pinned-root result and same-birth root exchange during final proof. `encodeNativeComposeStorageWitnessArchive` and the tagged xattr codec are distinct; byte decoding, namespace transport and read-only engine behavior are outside this model. A copied matching witness with otherwise identical metadata is outside the empty-replacement guarantee. | | `DriftAuthority` | Existing opaque generation authority must reject changed pending selection or revoked lease before seed/completion, and recheck after asynchronous proofs. The model summarizes those identities by one version; it cannot qualify real lease/token/inode checks. | | `ReplaceExpectation` / `ReenrollReadOnly` | Immutable anchored records must reject replacement and read-only reopen must not append enrollment, even with matching marker bytes. Filesystem substitution and exact write counts require separate source regressions. | diff --git a/tests/native-compose-storage-witness-command.test.ts b/tests/native-compose-storage-witness-command.test.ts index e32a75c8..e6882331 100644 --- a/tests/native-compose-storage-witness-command.test.ts +++ b/tests/native-compose-storage-witness-command.test.ts @@ -4,8 +4,14 @@ import { openNativeComposeGenerationStore } from "../src/lib/native-compose-gene import { nativeComposeRetainedVolumesValid } from "../src/lib/native-compose-retained-storage.ts"; import { enrollNativeComposeStorageWitness, + enrollNativeComposeStorageXattrWitness, prepareNativeComposeStorageWitness, + prepareNativeComposeStorageXattrWitness, + verifyNativeComposeStorageXattrWitness, } from "../src/lib/native-compose-storage-witness.ts"; +import { captureNativeComposeStorageXattrCarrier } from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; +import { encodeNativeComposeStorageXattrResponse } from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; +import { runNativeComposeStorageXattrHelper } from "../src/lib/native-compose-storage-witness-xattr-helper.ts"; import { fixture, invoke } from "./helpers/native-compose-command.ts"; async function prepared(enroll: boolean) { @@ -134,3 +140,191 @@ test("source-unavailable explicit saved recovery stops engine but retains Expect expect(current.retainedStorage).toHaveLength(1); expect(JSON.stringify(current)).not.toContain(".hack-storage-"); }, 30_000); + +test("source CLI reports and explicitly stops saved resources for unknown enrolled preflight helper with no main pending", async () => { + const root = await fixture("", false, { + noHooks: true, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); + expect((await invoke(root)).code).toBe(0); + expect((await invoke(root, ["down", "--json"])).code).toBe(0); + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + mode: "prepare", + }); + let generationId = ""; + let witnesses: Awaited< + ReturnType + >["storageWitnesses"] = null; + const marker = new Map(); + let unknown = false; + let helperId = 1; + try { + const current = await store.loadCurrent(); + const generation = current.generation; + const volume = current.retainedStorage?.[0]; + if (!(generation && volume)) { + throw new Error("Missing saved synthetic volume"); + } + generationId = generation.generationId; + const carrier = captureNativeComposeStorageXattrCarrier({ + artifact: { + version: 1, + imageId: `sha256:${"a".repeat(64)}`, + platform: "linux/arm64", + bunVersion: "1.4.2", + bunHash: "b".repeat(64), + libcHash: "c".repeat(64), + helperHash: "e".repeat(64), + kernelAbi: 1, + }, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + ports: { + inspect: async () => ({ + engineId: "d".repeat(64), + runtimeIdentity: store.identity.composeProject, + ownerToken: store.identity.ownerToken, + name: volume.name, + storage: volume.storage, + volume, + mountpoint: `/var/lib/docker/volumes/${volume.name}/_data`, + driver: "local", + options: {}, + holders: [], + }), + invoke: async (input) => { + if (unknown) { + throw new Error( + "Synthetic create outcome unknown; no actual helper or Docker" + ); + } + const id = (helperId++).toString(16).padStart(64, "0"); + await input.recordCreated({ id, createdAt: volume.createdAt }); + const rootFacts = { device: "1", inode: "42", uid: 0, gid: 0 }; + const response = runNativeComposeStorageXattrHelper({ + request: input.request, + kernel: { + effectiveUid: () => input.uid, + effectiveGid: () => input.gid, + probeRoot: () => rootFacts, + openRoot: () => 42, + statRoot: () => rootFacts, + createXattr: (_, name, bytes) => { + if (input.readonly || marker.has(name)) { + throw new Error("EEXIST/EROFS"); + } + marker.set(name, new Uint8Array(bytes)); + }, + syncRoot: () => {}, + readXattr: (_, name) => marker.get(name) ?? new Uint8Array(), + closeRoot: () => {}, + }, + }); + const exitCode = response.outcome === "refused" ? 1 : 0; + return { + artifact: input.artifact, + carrierId: id, + carrierCreatedAt: volume.createdAt, + engineId: input.target.engineId, + invocationId: input.invocationId, + readonly: input.readonly, + uid: input.uid, + gid: input.gid, + target: input.target, + scope: input.scope, + response: encodeNativeComposeStorageXattrResponse(response), + outcome: "complete", + exitCode, + stopped: { id, running: false, pid: 0, exitCode }, + containersAfterCleanup: [], + }; + }, + }, + }); + await store.withMutation(async (mutation) => { + expect( + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + const enrollment = await prepareNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId: "d".repeat(64), + volume: { name: volume.name, storage: volume.storage }, + admission: "explicit-adoption", + originalVolume: volume, + assertAdmission: async () => {}, + carrier, + }); + await enrollNativeComposeStorageXattrWitness({ enrollment }); + return { value: 0, outcome: "complete" }; + }, + }) + ).toEqual({ value: 0, outcome: "complete" }); + }); + const enrolled = (await store.loadCurrent()).storageWitnesses?.[0]; + if ( + !enrolled || + enrolled.state !== "enrolled" || + enrolled.reference.version !== 3 + ) { + throw new Error("Missing issued xattr enrollment"); + } + unknown = true; + await store.withMutation(async (mutation) => { + await expect( + verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId: "d".repeat(64), + reference: enrolled.reference, + carrier, + }) + ).rejects.toThrow("values omitted"); + }); + const uncertain = await store.loadCurrent(); + expect(uncertain.pending).toBeNull(); + expect(uncertain.storageWitnessesPending).toBe(true); + witnesses = uncertain.storageWitnesses; + } finally { + await store.close(); + } + const ps = await invoke(root, ["ps", "--json"]); + expect(ps.code).toBe(0); + expect(JSON.parse(ps.stdout)).toMatchObject({ + ok: true, + data: { pending: true }, + }); + expect(ps.stdout).not.toContain("carrierJournalToken"); + expect(ps.stdout).not.toContain("user.hack.storage."); + const requests = await Bun.file(join(root, "requests")).text(); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + await Bun.write( + join(root, ".hack/hack.project.json"), + "malformed authored source" + ); + await Bun.write( + join(root, ".hack/hack.env.default.yaml"), + "malformed private env" + ); + expect((await invoke(root, ["down", "--json"])).code).toBe(1); + expect(await Bun.file(join(root, "requests")).text()).toBe(requests); + const recovered = await invoke(root, ["down", "--recover", "--json"]); + expect(recovered.code).toBe(1); + expect(JSON.parse(recovered.stdout)).toMatchObject({ + ok: false, + error: { code: "E_COMPOSE_FAILED" }, + }); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + const retained = await saved(root); + expect(retained.pending?.generationId).toBe(generationId); + expect(retained.storageWitnesses).toEqual(witnesses); + expect(retained.storageWitnessesPending).toBe(true); + expect(retained.retainedStorage).toHaveLength(1); +}, 30_000); diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index a8bd62bc..f25c23c3 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -1,13 +1,23 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + assertNativeComposeMaterialAuthority, type NativeComposeGeneration, type NativeComposeGenerationStore, type NativeComposeMutation, openNativeComposeGenerationStore, } from "../src/lib/native-compose-generation.ts"; +import * as carrierJournal from "../src/lib/native-compose-storage-carrier-journal.ts"; import { enrollNativeComposeStorageXattrWitness, type NativeComposeStorageWitnessEnrollment, @@ -121,6 +131,9 @@ function fake(store: NativeComposeGenerationStore) { | "readonly" | "uid" | "replay" + | "no-created" + | "changed-created" + | "created-uncertain" | null, generation: null as NativeComposeGeneration | null, afterInvoke: null as @@ -166,6 +179,21 @@ function fake(store: NativeComposeGenerationStore) { if (state.failure === input.request.operation) { throw new Error("private child uncertainty"); } + const carrierId = (state.failure === "replay" ? 1 : nextId++) + .toString(16) + .padStart(64, "0"); + expect((await store.loadCurrent()).storageWitnessesPending).toBe(true); + if (state.failure !== "no-created") { + await input.recordCreated({ + id: carrierId, + createdAt: volume.createdAt, + }); + } + if (state.failure === "created-uncertain") { + throw new Error( + "private start uncertainty after exact create publication" + ); + } const selectedRoot = state.failure === "pinned-root" && input.request.operation === "verify" ? { ...state.root, inode: "41" } @@ -189,9 +217,6 @@ function fake(store: NativeComposeGenerationStore) { closeRoot: () => {}, }, }); - const carrierId = (state.failure === "replay" ? 1 : nextId++) - .toString(16) - .padStart(64, "0"); state.afterInvoke?.(input); return { artifact: @@ -199,6 +224,10 @@ function fake(store: NativeComposeGenerationStore) { ? { ...artifact, helperHash: "f".repeat(64) } : input.artifact, carrierId, + carrierCreatedAt: + state.failure === "changed-created" + ? "2026-10-08T12:01:00Z" + : volume.createdAt, engineId, invocationId: input.invocationId, readonly: @@ -213,7 +242,12 @@ function fake(store: NativeComposeGenerationStore) { response: encodeNativeComposeStorageXattrResponse(response), outcome: "complete", exitCode: response.outcome === "refused" ? 1 : 0, - stopped: { id: carrierId, running: false, pid: 0, exitCode: 0 }, + stopped: { + id: carrierId, + running: false, + pid: 0, + exitCode: response.outcome === "refused" ? 1 : 0, + }, containersAfterCleanup: state.failure === "cleanup" ? [carrierId] : [], }; }, @@ -266,7 +300,7 @@ async function active() { test("durable tagged Expected precedes one cold provision/seed and fresh proof before workload", async () => { const { store, transport, reference } = await active(); expect(reference).toMatchObject({ - version: 2, + version: 3, kind: "directory-xattr", artifact, volume, @@ -296,7 +330,7 @@ test("durable tagged Expected precedes one cold provision/seed and fresh proof b test("required xattr artifact/kind cannot be removed or relabeled as a legacy USTAR reference", async () => { const { store, reference } = await active(); expect(nativeComposeStorageWitnessReferenceValid(reference)).toBe(true); - if (reference.version !== 2) { + if (reference.version !== 3) { throw new Error("Missing xattr reference"); } const { artifact: ignoredArtifact, ...missingArtifact } = reference; @@ -312,6 +346,12 @@ test("required xattr artifact/kind cannot be removed or relabeled as a legacy US expect( nativeComposeStorageWitnessReferenceValid({ ...reference, version: 1 }) ).toBe(false); + expect( + nativeComposeStorageWitnessReferenceValid({ ...reference, version: 2 }) + ).toBe(false); + const { carrierJournalToken: ignoredJournal, ...missingJournal } = reference; + expect(nativeComposeStorageWitnessReferenceValid(missingJournal)).toBe(false); + expect(ignoredJournal).toMatch(/^[a-f0-9]{32}$/); const states = (await store.loadCurrent()).storageWitnesses; expect(nativeComposeStorageWitnessStatesValid(states)).toBe(true); const tagged = states?.[0]; @@ -582,6 +622,238 @@ test("same-birth root replacement while awaiting the final reader cannot publish ); }); +test("known finite refused marker read clears only its proven absent helper intent and never repairs the witness", async () => { + const { store, transport, generation, reference } = await active(); + transport.attributes.clear(); + transport.calls.length = 0; + let workload = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toThrow("values omitted"); + }); + expect(workload).toBe(0); + expect(transport.attributes.size).toBe(0); + expect(transport.calls).not.toContain("seed"); + expect(transport.calls).not.toContain("provision"); + const failed = await store.loadCurrent(); + expect(failed.storageWitnessesPending).toBe(false); + expect(failed.pending).toBeNull(); + expect(failed.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + expect( + await mutation.runEffect({ + generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => ({ value: 0, outcome: "complete" }), + }) + ).toEqual({ value: 0, outcome: "complete" }); + }); + const stopped = await store.loadCurrent(); + expect(stopped.stopped).toBe(true); + expect(stopped.pending).toBeNull(); + expect(stopped.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); +}); + +test.each([ + "no-created", + "changed-created", + "created-uncertain", + "cleanup", +] as const)("unknown enrolled helper %s blocks replay and saved retirement without changing the main material receipt", async (failure) => { + const { store, transport, generation, reference } = await active(); + transport.state.failure = failure; + transport.calls.length = 0; + let workload = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "run", + assertFresh: async () => {}, + assertOwned: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toThrow("values omitted"); + }); + expect(workload).toBe(0); + const unknown = await store.loadCurrent(); + expect(unknown.generation?.generationId).toBe(generation.generationId); + expect(unknown.pending).toBeNull(); + expect(unknown.storageWitnessesPending).toBe(true); + expect(unknown.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); + const observed = [...transport.calls]; + await store.withMutation(async (mutation) => { + if (failure === "no-created") { + const foreignGeneration = await publish(mutation); + await expect( + mutation.runEffect({ + generation: foreignGeneration, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + } + for (const operation of ["up", "down"] as const) { + await expect( + mutation.runEffect({ + generation, + operation, + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + } + const stopped = await mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + beforeComplete: async () => { + throw new Error("Unknown helper cannot retire claims"); + }, + }); + expect(stopped).toEqual({ value: 0, outcome: "uncertain" }); + }); + expect(workload).toBe(1); + expect(transport.calls).toEqual(observed); + const retained = await store.loadCurrent(); + expect(retained.pending?.generationId).toBe(generation.generationId); + expect(retained.storageWitnessesPending).toBe(true); + expect(retained.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); +}); + +test("retirement rereads the original receipt after its final carrier-journal await", async () => { + const { store, generation } = await active(); + const original = carrierJournal.nativeComposeStorageCarriersPending; + let pause = false; + let enter = () => {}; + let release = () => {}; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const resumed = new Promise((resolve) => { + release = resolve; + }); + const probe = spyOn( + carrierJournal, + "nativeComposeStorageCarriersPending" + ).mockImplementation(async (opts) => { + if (pause) { + pause = false; + enter(); + await resumed; + } + return await original(opts); + }); + try { + await store.withMutation(async (mutation) => { + expect( + await mutation.runEffect({ + generation, + operation: "down", + assertOwned: async () => {}, + effect: async () => { + pause = true; + const retirement = assertNativeComposeMaterialAuthority({ + authority: mutation.materialAuthority, + generation, + phase: "retire", + }); + await entered; + // Adversarial substitution only in this isolated synthetic store: same + // complete bytes, different inode while the final journal probe awaits. + const path = join( + store.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + store.identity.instanceId, + "receipt.json" + ); + const bytes = await readFile(path); + await writeFile(`${path}.replacement`, bytes, { + mode: 0o600, + flag: "wx", + }); + await rename(`${path}.replacement`, path); + release(); + await expect(retirement).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_STATE", + }); + return { value: 0, outcome: "uncertain" }; + }, + }) + ).toEqual({ value: 0, outcome: "uncertain" }); + }); + } finally { + release(); + probe.mockRestore(); + } +}); + test("captured artifacts/ports cannot be substituted and cancelled lifetime stops before engine callbacks", async () => { const store = await fixture(); const transport = fake(store); From 00bff58fc6cad0af8fca1668697673e14482a093 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 16:33:34 -0400 Subject: [PATCH 13/23] fix: request close-on-exec for storage witness root --- src/lib/native-compose-storage-witness-xattr-linux.ts | 6 +++++- tests/native-compose-storage-witness-xattr.test.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/native-compose-storage-witness-xattr-linux.ts b/src/lib/native-compose-storage-witness-xattr-linux.ts index bf7816c1..c5a7ddca 100644 --- a/src/lib/native-compose-storage-witness-xattr-linux.ts +++ b/src/lib/native-compose-storage-witness-xattr-linux.ts @@ -22,6 +22,7 @@ export const NATIVE_STORAGE_XATTR_LINUX_ABI = Object.freeze({ version: 1, root: "/hack-storage-witness", xattrCreate: 1, + openCloseExec: 1 << 19, fGetFd: 1, fdCloseExec: 1, pointerBits: 64, @@ -139,7 +140,10 @@ export async function createNativeComposeStorageXattrLinuxKernel(): Promise<{ active(); const fd = openSync( NATIVE_STORAGE_XATTR_LINUX_ABI.root, - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + NATIVE_STORAGE_XATTR_LINUX_ABI.openCloseExec ); held.add(fd); const flags = fcntl(fd, NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd, 0); diff --git a/tests/native-compose-storage-witness-xattr.test.ts b/tests/native-compose-storage-witness-xattr.test.ts index 3a878f56..aa0501fd 100644 --- a/tests/native-compose-storage-witness-xattr.test.ts +++ b/tests/native-compose-storage-witness-xattr.test.ts @@ -101,6 +101,10 @@ function refused(value: unknown) { describe("distinct bounded directory-xattr codec", () => { test("Linux open flags retain the separate arm64 and x64 UAPI contracts without loading libc", () => { + // Linux open does not set FD_CLOEXEC unless the initial flags request it. + expect(NATIVE_STORAGE_XATTR_LINUX_ABI.openCloseExec).toBe(524_288); + expect(NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd).toBe(1); + expect(NATIVE_STORAGE_XATTR_LINUX_ABI.fdCloseExec).toBe(1); expect(NATIVE_STORAGE_XATTR_LINUX_ABI.arm64).toEqual({ libc: "/lib/aarch64-linux-gnu/libc.so.6", directory: 16_384, From 67c590282a1dbe9de84265158661bc180dea0eb0 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:14:35 -0400 Subject: [PATCH 14/23] feat: prepare finite Docker storage witness transport --- .../native-compose-storage-witness.md | 5 +- .../native-compose-storage-xattr-carrier.md | 21 +- .../generate-native-storage-witness-helper.ts | 38 ++ ...compose-storage-witness-docker-artifact.ts | 21 + ...ompose-storage-witness-docker-inventory.ts | 110 +++++ ...e-compose-storage-witness-docker-policy.ts | 78 ++++ .../native-compose-storage-witness-docker.ts | 375 ++++++++++++++++++ ...e-compose-storage-witness-helper-bundle.ts | 2 + ...ive-compose-storage-witness-docker.test.ts | 156 ++++++++ 9 files changed, 801 insertions(+), 5 deletions(-) create mode 100644 scripts/generate-native-storage-witness-helper.ts create mode 100644 src/lib/native-compose-storage-witness-docker-artifact.ts create mode 100644 src/lib/native-compose-storage-witness-docker-inventory.ts create mode 100644 src/lib/native-compose-storage-witness-docker-policy.ts create mode 100644 src/lib/native-compose-storage-witness-docker.ts create mode 100644 src/lib/native-compose-storage-witness-helper-bundle.ts create mode 100644 tests/native-compose-storage-witness-docker.test.ts diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 83a4a536..eeeeb911 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -81,7 +81,8 @@ The separate [directory-xattr owner and helper source](native-compose-storage-xa uses a create-only token without a PostgreSQL data-root directory entry. Required version-three expectation/reference records and tagged artifact/journal-bound intent keep it distinct from USTAR under the same version-three receipt owner. It adds -injectable cold provisioning and fresh read-only proof ports; no Docker transport -or CLI activation is implemented. Required finite carrier intent survives unknown +injectable cold provisioning and fresh read-only proof ports. The candidate Docker +port remains unactivated pending its persistent-volume qualification; no CLI caller +enrolls storage yet. Required finite carrier intent survives unknown helper outcomes even after enrollment; saved recovery stops resources without retiring that anchor. Its offline controls do not qualify a carrier. diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 18089898..11d972f3 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -176,9 +176,24 @@ readonly root filesystem, finite CPU/memory/PID limits and absolute deadline/gro reaping are mandatory before implementing the engine owner. Cancellation or an unknown create/start outcome keeps the anchor. Cleanup must prove the exact helper stopped before non-force removal and never remove data, witness, material or -journals. This source contains no Docker transport, host deadline/group owner or -helper cleanup implementation. Journal bytes validate the owning port contract; -they do not replace actual stop and absence observations. +journals. The candidate Docker port now uses the shared bounded query and child +owners, captures canonical stdin privately, and records the created helper before +start. It inspects all selected-volume holders, binds the fresh canonical local +Mountpoint with `bind-recursive=disabled`, and never uses a named-volume helper +mount or requests bind source creation. Known completion requires an exact stopped +helper, non-force removal and fresh empty helper inventory. Unknown disposition +retains the private journal and input. Journal bytes do not replace these actual +stop and absence observations. The CLI does not call this unactivated port. + +The first declared dependency is the fixed Linux arm64 Bun 1.4.2 image +`sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61`. +The packaged helper pins its bytes and the qualified Bun/libc bytes separately. +Dependency/platform checks precede volume effects; every helper uses `--pull never`. +There is no image download, emulation fallback or platform substitution. Linux +amd64 and uncached first installation remain outside this qualification. +Maintain the packaged source with +`bun scripts/generate-native-storage-witness-helper.ts --check` or `--write`; +the generator refuses a changed artifact pin rather than approving new bytes. ## Evidence limits and remaining gates diff --git a/scripts/generate-native-storage-witness-helper.ts b/scripts/generate-native-storage-witness-helper.ts new file mode 100644 index 00000000..b4bea64e --- /dev/null +++ b/scripts/generate-native-storage-witness-helper.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NATIVE_STORAGE_DOCKER_ARTIFACT } from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; +import { NATIVE_STORAGE_WITNESS_HELPER } from "../src/lib/native-compose-storage-witness-helper-bundle.ts"; +import { run } from "../src/lib/shell.ts"; + +/** Explicit maintenance build only; never execute the helper or update its approved + * artifact pin automatically. Changed bytes require separate runtime qualification. */ +const root = await mkdtemp(join(tmpdir(), "hack-storage-helper-build-")); +let group = 0, settled = false, started = false; +try { + if (Bun.version !== "1.4.2" || !["--check", "--write"].includes(process.argv[2] ?? "")) throw new Error("Pinned helper generation refused"); + const output = join(root, "helper.mjs"); + started = true; + const exit = await run([process.execPath, "build", "scripts/native-storage-witness-helper.ts", "--target=bun", "--outfile", output], + { stdin: "ignore", timeoutMs: 60_000, stdout: "ignore", stderr: "ignore", + onSpawn: async (event) => { group = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; } }); + const deadline = Date.now() + 3000; + while (group > 1 && Date.now() <= deadline) { + try { process.kill(-group, 0); } catch (error: unknown) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") { settled = true; break; } + throw new Error("Helper build disposition uncertain"); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!settled) throw new Error("Helper build disposition uncertain"); + if (exit !== 0) throw new Error("Pinned helper build refused"); + const bytes = await readFile(output); + if (bytes.length > 128 * 1024 || createHash("sha256").update(bytes).digest("hex") !== NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash) throw new Error("Helper artifact changed; separate qualification required"); + const path = "src/lib/native-compose-storage-witness-helper-bundle.ts"; + const source = `// Generated from scripts/native-storage-witness-helper.ts. Do not edit the payload.\nexport const NATIVE_STORAGE_WITNESS_HELPER = ${JSON.stringify(bytes.toString("utf8"))};\n`; + if (process.argv[2] === "--write") await writeFile(path, source); + else if (NATIVE_STORAGE_WITNESS_HELPER !== bytes.toString("utf8")) throw new Error("Generated helper source differs"); +} finally { + if (settled || !started) await rm(root, { recursive: true, force: true }); +} diff --git a/src/lib/native-compose-storage-witness-docker-artifact.ts b/src/lib/native-compose-storage-witness-docker-artifact.ts new file mode 100644 index 00000000..19770ece --- /dev/null +++ b/src/lib/native-compose-storage-witness-docker-artifact.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { NATIVE_STORAGE_WITNESS_HELPER } from "./native-compose-storage-witness-helper-bundle.ts"; +import type { NativeComposeStorageXattrArtifact } from "./native-compose-storage-witness-xattr-carrier.ts"; +import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; + +/** Explicit cached dependency; the owner never pulls or builds this image. Other + * platforms require their own artifact/ABI/volume qualification before admission. */ +export const NATIVE_STORAGE_DOCKER_ARTIFACT: NativeComposeStorageXattrArtifact = Object.freeze({ + version: 1, + imageId: "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + platform: "linux/arm64", + bunVersion: "1.4.2", + bunHash: "616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1", + libcHash: "9a325944115b36e74077786b29531963d96cd55cd6c82f11750a352cdb58fab0", + helperHash: "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", + kernelAbi: 1, +}); +export function nativeComposeStorageDockerHelper(): string { + if (createHash("sha256").update(NATIVE_STORAGE_WITNESS_HELPER).digest("hex") !== NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash) return refuse(); + return NATIVE_STORAGE_WITNESS_HELPER; +} diff --git a/src/lib/native-compose-storage-witness-docker-inventory.ts b/src/lib/native-compose-storage-witness-docker-inventory.ts new file mode 100644 index 00000000..a7651acc --- /dev/null +++ b/src/lib/native-compose-storage-witness-docker-inventory.ts @@ -0,0 +1,110 @@ +import { isRecord } from "./guards.ts"; +import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { nativeComposeVolumeCreatedAt } from "./native-compose-retained-storage.ts"; +import { + checkNativeComposeStorageXattrTarget, + type NativeComposeStorageXattrTarget, +} from "./native-compose-storage-witness-xattr-carrier.ts"; +import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; + +const ID = /^[a-f0-9]{64}$/; +const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; +const PREFIX = "io.hack.native-config"; +const VOLUME = `{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver":{{json .Driver}},"options":{{json .Options}},"mountpoint":{{json .Mountpoint}},"project":{{json (index .Labels "com.docker.compose.project")}},"instance":{{json (index .Labels "${PREFIX}.instance")}},"owner":{{json (index .Labels "${PREFIX}.owner")}},"version":{{json (index .Labels "${PREFIX}.version")}},"storage":{{json (index .Labels "${PREFIX}.storage")}},"provision":{{json (index .Labels "${PREFIX}.storage-provision")}}}`; +const HOLDER = `{"id":{{json .Id}},"createdAt":{{json .Created}},"mounts":{{json .Mounts}},"running":{{json .State.Running}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"instance":{{json (index .Config.Labels "${PREFIX}.instance")}},"owner":{{json (index .Config.Labels "${PREFIX}.owner")}},"version":{{json (index .Config.Labels "${PREFIX}.version")}},"generation":{{json (index .Config.Labels "${PREFIX}.generation")}},"carrier":{{json (index .Config.Labels "io.hack.storage-witness.carrier")}},"carrierOwner":{{json (index .Config.Labels "io.hack.storage-witness.owner")}},"carrierGeneration":{{json (index .Config.Labels "io.hack.storage-witness.generation")}}}`; +type Probe = ReturnType; +type Selection = { readonly name: string; readonly storage: string }; + +function rows(text: string): Record[] { + if (text.trim() === "") return []; + return text.trim().split("\n").map((line) => { + const value: unknown = JSON.parse(line); + return isRecord(value) ? value : refuse(); + }); +} +function strings(text: string, pattern: RegExp): string[] { + const result = text.trim() === "" ? [] : text.trim().split("\n").map((line) => { + const value: unknown = JSON.parse(line); + return typeof value === "string" && pattern.test(value) ? value : refuse(); + }); + if (new Set(result).size !== result.length) return refuse(); + return result.sort(); +} +async function volumes(probe: Probe): Promise { + return strings(await probe(["volume", "ls", "--format", "{{json .Name}}"]), NAME); +} +async function containers(probe: Probe): Promise { + return strings(await probe(["container", "ls", "-a", "--no-trunc", "--format", "{{json .ID}}"]), ID); +} +export async function inspectNativeComposeStorageDockerVolume(opts: { + readonly probe: Probe; + readonly selection: Selection; + readonly current: NativeComposeMaterialBinding; +}): Promise> { + const { probe, selection, current } = opts; + if (!NAME.test(selection.name)) return refuse(); + const values = rows(await probe(["volume", "inspect", "--format", VOLUME, selection.name])); + if (values.length !== 1) return refuse(); + const value = values[0] ?? refuse(); + if (!(value.name === selection.name && nativeComposeVolumeCreatedAt(value.createdAt) && + value.project === current.identity.composeProject && value.instance === current.identity.composeProject && + value.owner === current.identity.ownerToken && value.version === "1" && value.storage === selection.storage && + value.driver === "local" && value.mountpoint === `/var/lib/docker/volumes/${selection.name}/_data` && + (value.options === null || (isRecord(value.options) && Object.keys(value.options).length === 0)))) return refuse(); + return value; +} + +/** Complete holder reads include foreign containers. The two inventories and selected + * volume reread are observed fences; they do not claim an atomic daemon transaction. */ +export async function observeNativeComposeStorageDockerTarget(opts: { + readonly probe: Probe; + readonly selection: Selection; + readonly current: NativeComposeMaterialBinding; + readonly engineId: string; + readonly stopped: boolean; + /** Only the separately inspected exact created helper may be excluded. */ + readonly carrier?: { readonly id: string; readonly createdAt: string; readonly invocationId: string }; +}): Promise { + const { probe, selection, current, engineId, stopped } = opts; + if (!NAME.test(selection.name)) return refuse(); + if (JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId) return refuse(); + const beforeVolumes = await volumes(probe); + const volume = beforeVolumes.includes(selection.name) ? await inspectNativeComposeStorageDockerVolume(opts) : null; + const beforeContainers = await containers(probe); + const holders: Record[] = []; + for (let offset = 0; offset < beforeContainers.length; offset += 128) { + const batch = beforeContainers.slice(offset, offset + 128); + const values = rows(await probe(["container", "inspect", "--format", HOLDER, ...batch])); + if (values.length !== batch.length || new Set(values.map((value) => value.id)).size !== batch.length) return refuse(); + for (const value of values) { + if (!(typeof value.id === "string" && batch.includes(value.id) && Array.isArray(value.mounts))) return refuse(); + let selected = false; + for (const mount of value.mounts) { + if (!(isRecord(mount) && typeof mount.Type === "string" && typeof mount.Source === "string" && typeof mount.Destination === "string" && typeof mount.RW === "boolean")) return refuse(); + if ((mount.Type === "volume" && mount.Name === selection.name) || mount.Source === `/var/lib/docker/volumes/${selection.name}/_data`) selected = true; + } + if (!selected) continue; + if (opts.carrier && value.id === opts.carrier.id) { + if (!(value.createdAt === opts.carrier.createdAt && value.carrier === opts.carrier.invocationId && + value.carrierOwner === current.identity.ownerToken && value.carrierGeneration === current.generationId && value.running === false)) return refuse(); + continue; + } + if (!(value.project === current.identity.composeProject && value.instance === current.identity.composeProject && + value.owner === current.identity.ownerToken && value.version === "1")) return refuse(); + holders.push({ id: value.id, runtimeIdentity: value.project, ownerToken: value.owner, generationId: value.generation, running: value.running }); + } + } + const repeated = volume ? await inspectNativeComposeStorageDockerVolume(opts) : null; + if (JSON.stringify(repeated) !== JSON.stringify(volume) || + JSON.stringify(await volumes(probe)) !== JSON.stringify(beforeVolumes) || + JSON.stringify(await containers(probe)) !== JSON.stringify(beforeContainers) || + JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId) return refuse(); + return checkNativeComposeStorageXattrTarget({ + current, engineId, selection, stopped, + value: { engineId, runtimeIdentity: current.identity.composeProject, ownerToken: current.identity.ownerToken, + ...selection, volume: volume ? { ...selection, createdAt: volume.createdAt } : null, + mountpoint: volume?.mountpoint ?? null, driver: volume ? "local" : null, + options: volume ? {} : null, holders }, + }); +} diff --git a/src/lib/native-compose-storage-witness-docker-policy.ts b/src/lib/native-compose-storage-witness-docker-policy.ts new file mode 100644 index 00000000..bf26635f --- /dev/null +++ b/src/lib/native-compose-storage-witness-docker-policy.ts @@ -0,0 +1,78 @@ +import { isRecord } from "./guards.ts"; +import { nativeComposeVolumeCreatedAt } from "./native-compose-retained-storage.ts"; +import { NATIVE_STORAGE_DOCKER_ARTIFACT } from "./native-compose-storage-witness-docker-artifact.ts"; +import type { NativeComposeStorageXattrInvocation } from "./native-compose-storage-witness-xattr-carrier.ts"; +import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; + +const ID = /^[a-f0-9]{64}$/; +export const NATIVE_STORAGE_CARRIER_LABEL = "io.hack.storage-witness.carrier"; +export const NATIVE_STORAGE_CARRIER_FORMAT = '{"id":{{json .Id}},"createdAt":{{json .Created}},"image":{{json .Image}},"configImage":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"openStdin":{{json .Config.OpenStdin}},"tty":{{json .Config.Tty}},"labels":{{json .Config.Labels}},"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"state":{{json .State}},"execIds":{{json .ExecIDs}}}'; +export type NativeComposeStorageDockerCarrier = Record & { + readonly id: string; + readonly createdAt: string; + readonly state: Record; +}; +export function checkNativeComposeStorageDockerCarrier(opts: { + readonly value: unknown; + readonly input: NativeComposeStorageXattrInvocation; + readonly program: string; + readonly imageIds: readonly string[]; +}): NativeComposeStorageDockerCarrier { + const { value, input, program, imageIds } = opts; + if (!(isRecord(value) && typeof value.id === "string" && ID.test(value.id) && nativeComposeVolumeCreatedAt(value.createdAt) && + isRecord(value.host) && isRecord(value.state) && isRecord(value.labels) && Array.isArray(value.mounts) && + value.configImage === input.artifact.imageId && typeof value.image === "string" && imageIds.includes(value.image) && + value.user === `${input.uid}:${input.gid}` && value.openStdin === true && value.tty === false && + JSON.stringify(value.entrypoint) === '["/usr/local/bin/bun"]' && JSON.stringify(value.cmd) === '["--no-env-file","/hack-storage-witness-helper.mjs"]' && + value.labels[NATIVE_STORAGE_CARRIER_LABEL] === input.invocationId && value.labels["io.hack.storage-witness.owner"] === input.target.ownerToken && + value.labels["io.hack.storage-witness.generation"] === input.scope.generationId && value.labels["io.hack.storage-witness.helper"] === input.artifact.helperHash && + !Object.hasOwn(value.labels, "com.docker.compose.project") && + Object.hasOwn(value, "execIds") && (value.execIds === null || (Array.isArray(value.execIds) && value.execIds.length === 0)))) return refuse(); + const host = value.host; + if (!(host.Privileged === false && host.ReadonlyRootfs === true && host.NetworkMode === "none" && host.Memory === 268_435_456 && + host.NanoCpus === 1_000_000_000 && host.PidsLimit === 32 && JSON.stringify(host.CapDrop) === '["ALL"]' && + (host.CapAdd === null || (Array.isArray(host.CapAdd) && host.CapAdd.length === 0)) && + JSON.stringify(host.SecurityOpt) === '["no-new-privileges:true"]' && + isRecord(host.LogConfig) && host.LogConfig.Type === "none" && isRecord(host.LogConfig.Config) && Object.keys(host.LogConfig.Config).length === 0 && + isRecord(host.RestartPolicy) && host.RestartPolicy.Name === "no" && + (host.Binds === null || (Array.isArray(host.Binds) && host.Binds.length === 0)) && + (host.Tmpfs === null || (isRecord(host.Tmpfs) && Object.keys(host.Tmpfs).length === 0)) && + (host.Devices === null || (Array.isArray(host.Devices) && host.Devices.length === 0)) && + (host.DeviceRequests === null || (Array.isArray(host.DeviceRequests) && host.DeviceRequests.length === 0)) && + isRecord(host.PortBindings) && Object.keys(host.PortBindings).length === 0 && + Array.isArray(host.Mounts) && host.Mounts.length === 2 && value.mounts.length === 2)) return refuse(); + for (const [source, target, readonly] of [[program, "/hack-storage-witness-helper.mjs", true], [input.target.mountpoint, "/hack-storage-witness", input.readonly]] as const) { + const configured = host.Mounts.filter((mount) => isRecord(mount) && mount.Target === target); + const physical = value.mounts.filter((mount) => isRecord(mount) && mount.Destination === target); + if (!(typeof source === "string" && configured.length === 1 && physical.length === 1 && + isRecord(configured[0]) && configured[0].Type === "bind" && configured[0].Source === source && configured[0].ReadOnly === readonly && + isRecord(configured[0].BindOptions) && configured[0].BindOptions.NonRecursive === true && configured[0].BindOptions.Propagation === "rprivate" && configured[0].BindOptions.CreateMountpoint !== true && + isRecord(physical[0]) && physical[0].Type === "bind" && physical[0].Source === source && physical[0].RW === !readonly && physical[0].Propagation === "rprivate")) return refuse(); + } + return value as NativeComposeStorageDockerCarrier; +} +/** Preserve every static field and complete mount row. Only the two observed closed + * optional defaults are normalized; no value, duplicate or projected field is dropped. */ +export function nativeComposeStorageDockerCarrierPolicy(value: NativeComposeStorageDockerCarrier): string { + if (!(isRecord(value.host) && Array.isArray(value.mounts) && Object.hasOwn(value.host, "OomKillDisable") && + (value.host.OomKillDisable === null || value.host.OomKillDisable === false) && Object.hasOwn(value, "execIds") && + (value.execIds === null || (Array.isArray(value.execIds) && value.execIds.length === 0)))) return refuse(); + const { state: _, ...fixed } = value; + const mounts = [...value.mounts].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + return JSON.stringify({ ...fixed, host: { ...value.host, OomKillDisable: false }, execIds: [], mounts }); +} +export function nativeComposeStorageDockerCreateArgs(opts: { + readonly input: NativeComposeStorageXattrInvocation; + readonly program: string; +}): readonly string[] { + const { input, program } = opts; + if (input.artifact.imageId !== NATIVE_STORAGE_DOCKER_ARTIFACT.imageId || !input.target.mountpoint) return refuse(); + return ["create", "--interactive", "--name", `hack-storage-witness-${input.invocationId}`, "--pull", "never", "--platform", input.artifact.platform, + "--user", `${input.uid}:${input.gid}`, "--read-only", "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", + "--memory", "256m", "--cpus", "1", "--pids-limit", "32", "--restart", "no", "--log-driver", "none", + "--mount", `type=bind,src=${program},dst=/hack-storage-witness-helper.mjs,readonly,bind-recursive=disabled,bind-propagation=rprivate`, + "--mount", `type=bind,src=${input.target.mountpoint},dst=/hack-storage-witness${input.readonly ? ",readonly" : ""},bind-recursive=disabled,bind-propagation=rprivate`, + "--label", `${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, "--label", `io.hack.storage-witness.owner=${input.target.ownerToken}`, + "--label", `io.hack.storage-witness.generation=${input.scope.generationId}`, "--label", `io.hack.storage-witness.helper=${input.artifact.helperHash}`, + "--entrypoint", "/usr/local/bin/bun", input.artifact.imageId, "--no-env-file", "/hack-storage-witness-helper.mjs"]; +} diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts new file mode 100644 index 00000000..79776003 --- /dev/null +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -0,0 +1,375 @@ +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, readdir, rmdir, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "./guards.ts"; +import { + assertNativeComposeMaterialAuthority, + type NativeComposeGenerationStore, + type NativeComposeMaterialAuthority, + type NativeComposeMaterialBinding, +} from "./native-compose-generation.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { + type HeldDirectory, + holdDirectory, + privateDirectory, + readPrivate, + recheckDirectories, + sameFile, + writeExclusive, +} from "./native-compose-private-state.ts"; +import { + NATIVE_STORAGE_DOCKER_ARTIFACT as artifact, + nativeComposeStorageDockerHelper, +} from "./native-compose-storage-witness-docker-artifact.ts"; +import { + inspectNativeComposeStorageDockerVolume, + observeNativeComposeStorageDockerTarget, +} from "./native-compose-storage-witness-docker-inventory.ts"; +import { + checkNativeComposeStorageDockerCarrier, + NATIVE_STORAGE_CARRIER_FORMAT, + NATIVE_STORAGE_CARRIER_LABEL, + type NativeComposeStorageDockerCarrier, + nativeComposeStorageDockerCarrierPolicy, + nativeComposeStorageDockerCreateArgs, +} from "./native-compose-storage-witness-docker-policy.ts"; +import { + captureNativeComposeStorageXattrCarrier, + type NativeComposeStorageXattrCarrier, + type NativeComposeStorageXattrInvocation, +} from "./native-compose-storage-witness-xattr-carrier.ts"; +import { + encodeNativeComposeStorageXattrRequest, + parseNativeComposeStorageXattrResponse, + refuseNativeComposeStorageXattr as refuse, +} from "./native-compose-storage-witness-xattr-codec.ts"; +import { findExecutableInPath, run, type RunExitEvent } from "./shell.ts"; + +const ID = /^[a-f0-9]{64}$/; +const IMAGE_IDS = [artifact.imageId, + "sha256:5c51cee225076d3c7db2150683141476298062489de4660f2d1729e522641f91", + "sha256:1cb8f81099813a0ec61f99b69348f6594e188cf1689c20a0a53a5ddd23b37708"] as const; +const CAPTURE_LIMIT = 4096; +type Selection = { readonly name: string; readonly storage: string }; +type Context = { + readonly authority: NativeComposeMaterialAuthority; + readonly store: NativeComposeGenerationStore; + readonly engineId: string; + readonly signal: AbortSignal; + readonly deadline: number; +}; +type Files = { + readonly held: readonly HeldDirectory[]; + readonly path: string; + readonly program: string; + readonly input: string; + readonly programInfo: Awaited>; + readonly inputInfo: Awaited>; + readonly requestText: string; + readonly captures: { readonly path: string; readonly info: Awaited> }[]; +}; +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} +function lifetime(context: Context): void { + if (context.signal.aborted || Date.now() >= context.deadline) refuse(); +} +async function current(context: Context, selection?: Selection, effect = false): Promise { + const saved = await context.store.loadCurrent(); + const generation = await context.store.loadPending() ?? saved.generation ?? refuse(); + if (selection) { + const document = await context.store.readGenerationDocument(generation); + const declared = isRecord(document.volumes) && Object.hasOwn(document.volumes, selection.storage) && + isRecord(document.volumes[selection.storage]) && document.volumes[selection.storage].name === selection.name; + const retained = saved.storageWitnesses?.some((entry) => entry.name === selection.name && entry.storage === selection.storage) ?? false; + if (!(declared || (!effect && retained))) return refuse(); + } + return await assertNativeComposeMaterialAuthority({ authority: context.authority, generation, phase: effect ? "effect" : "inspect" }); +} +function engine(probe: ReturnType, context: Context): Promise { + return probe(["info", "--format", "{{json .ID}}"]).then((text) => { + if (JSON.parse(text) !== context.engineId) refuse(); + }); +} +async function image(probe: ReturnType): Promise { + const value: unknown = JSON.parse(await probe(["image", "inspect", "--format", '{"id":{{json .Id}},"os":{{json .Os}},"arch":{{json .Architecture}},"volumes":{{json (index .Config "Volumes")}}', artifact.imageId])); + if (!(isRecord(value) && value.id === artifact.imageId && value.os === "linux" && value.arch === "arm64" && value.volumes === null)) return refuse(); +} +async function invocationFiles(context: Context, input: NativeComposeStorageXattrInvocation): Promise { + const held: HeldDirectory[] = []; + try { + const owner = await holdDirectory(join(context.store.identity.checkoutRoot, ".hack", ".internal", "native-compose", context.store.identity.instanceId), true); + held.push(owner); + const root = await privateDirectory(join(owner.path, "storage-carriers")); + held.push(root); + await owner.file.sync(); + const path = join(root.path, input.invocationId); + await mkdir(path, { mode: 0o700 }); + await root.file.sync(); + const directory = await holdDirectory(path, true); + held.push(directory); + const program = join(path, "helper.mjs"), request = join(path, "request.json"); + const programInfo = await writeExclusive(program, nativeComposeStorageDockerHelper()); + const requestText = encodeNativeComposeStorageXattrRequest(input.request); + const inputInfo = await writeExclusive(request, requestText); + // read-only guest code, private owned parent. This leaf is not executable on the host. + const file = await open(program, constants.O_RDONLY | constants.O_NOFOLLOW); + try { await file.chmod(0o444); await file.sync(); } finally { await file.close(); } + await directory.file.sync(); + await recheckDirectories(held); + return { held, path, program, input: request, programInfo, inputInfo, requestText, captures: [] }; + } catch { + await Promise.allSettled(held.map((directory) => directory.file.close())); + return refuse(); + } +} +async function retireInvocationFiles(files: Files): Promise { + await checkFiles(files); + const owned = [{ path: files.program, info: files.programInfo }, { path: files.input, info: files.inputInfo }, ...files.captures]; + const names = owned.map((file) => file.path.slice(files.path.length + 1)).sort(); + if (!same((await readdir(files.path)).sort(), names)) return refuse(); + for (const file of owned) { + await recheckDirectories(files.held); + const named = await lstat(file.path); + if (!(named.isFile() && named.nlink === 1 && named.uid === process.getuid?.() && sameFile(named, file.info))) return refuse(); + await unlink(file.path); + } + await files.held.at(-1)?.file.sync(); + // The known helper and every owned host group are absent before this retirement. + await rmdir(files.path); + await files.held.at(-2)?.file.sync(); +} +async function checkFiles(files: Files): Promise { + await recheckDirectories(files.held); + for (const [path, original, text] of [[files.program, files.programInfo, nativeComposeStorageDockerHelper()], [files.input, files.inputInfo, files.requestText]] as const) { + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await file.stat(); + if (!(before.isFile() && before.nlink === 1 && before.uid === process.getuid?.() && sameFile(before, original) && before.size === Buffer.byteLength(text) && + (before.mode & 0o777) === (path === files.program ? 0o444 : 0o600))) return refuse(); + const bytes = await file.readFile(); + const after = await file.stat(), named = await lstat(path); + if (!(sameFile(after, before) && sameFile(named, before) && bytes.toString("utf8") === text && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs)) return refuse(); + } finally { await file.close(); } + } +} +async function groupAbsent(pid: number): Promise { + if (!(Number.isInteger(pid) && pid > 1)) return refuse(); + const deadline = Date.now() + 3000; + while (true) { + try { process.kill(-pid, 0); } catch (error: unknown) { + if (isRecord(error) && error.code === "ESRCH") return; + return refuse(); + } + if (Date.now() >= deadline) return refuse(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} +/** No callback can reject before the shared owner awaits exit. A rejected owner or + * unproven group retains the journal; daemon effects are never inferred from exit. */ +async function command(opts: { + readonly context: Context; + readonly files: Files; + readonly args: readonly string[]; + readonly beforeSpawn: () => void; + readonly assertAdmitted: () => Promise; + readonly timeoutMs: number; + readonly cleanup?: boolean; +}): Promise<{ readonly exitCode: number; readonly stdout: string }> { + const capture = randomBytes(16).toString("hex"); + const stdout = join(opts.files.path, `${capture}.stdout`), stderr = join(opts.files.path, `${capture}.stderr`); + const out = await writeExclusive(stdout, ""), err = await writeExclusive(stderr, ""); + opts.files.captures.push({ path: stdout, info: out }, { path: stderr, info: err }); + await opts.files.held.at(-1)?.file.sync(); + await opts.assertAdmitted(); + const timeoutMs = opts.cleanup ? opts.timeoutMs : Math.min(opts.timeoutMs, opts.context.deadline - Date.now()); + if (!(timeoutMs > 0)) return refuse(); + let pid = 0, exit: RunExitEvent | null = null; + // No awaited setup in run(): this noninteractive pipe uses AbortSignal rather + // than the terminal handoff. The async authority read immediately precedes it. + const code = await run(["/bin/sh", "-c", 'ulimit -f 8; input=$1; out=$2; err=$3; shift 3; exec "$@" < "$input" > "$out" 2> "$err"', "storage-witness-command", opts.files.input, stdout, stderr, ...opts.args], { + stdin: "ignore", stdout: "ignore", stderr: "ignore", timeoutMs, + signal: opts.cleanup ? undefined : opts.context.signal, + beforeSpawn: opts.beforeSpawn, + onSpawn: async (event) => { pid = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; }, + onExit: async (event) => { exit = event; }, + }); + await groupAbsent(pid); + if (!(exit && !(exit as RunExitEvent).timedOut && !(exit as RunExitEvent).cancelled)) return refuse(); + const output = await readPrivate(stdout, CAPTURE_LIMIT), errors = await readPrivate(stderr, CAPTURE_LIMIT); + if (!(sameFile(output.info, out) && sameFile(errors.info, err))) return refuse(); + return { exitCode: code, stdout: output.text }; +} +function matchesScope(input: NativeComposeStorageXattrInvocation, binding: NativeComposeMaterialBinding): boolean { + return input.scope.generationId === binding.generationId && input.scope.currentGenerationId === binding.currentGenerationId && + input.scope.pendingGenerationId === binding.pendingGenerationId && input.scope.pendingToken === binding.pendingToken && + input.target.runtimeIdentity === binding.identity.composeProject && input.target.ownerToken === binding.identity.ownerToken; +} + +/** SOURCE-only transport until its persistent-volume/PGDATA qualification and CLI + * admission are complete. No module initialization executes Docker or the helper. */ +export async function createNativeComposeDockerStorageXattrCarrier(opts: Context): Promise { + const context: Context = Object.freeze({ ...opts }); + const docker = findExecutableInPath("docker") ?? refuse(); + const selectors = ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"].map((key) => [key, process.env[key]] as const); + const tool = () => { + if (findExecutableInPath("docker") !== docker || selectors.some(([key, value]) => process.env[key] !== value)) refuse(); + }; + const probe = (cleanup = false) => { + tool(); + if (!cleanup) lifetime(context); + return createNativeComposeProbe({ signal: cleanup ? undefined : context.signal, timeoutMs: cleanup ? 10_000 : Math.min(15_000, context.deadline - Date.now()) }); + }; + // Resolve the declared dependency before Expected, volume creation or any helper + // footprint. This first platform remains explicit; emulation is not a fallback. + try { + const prerequisite = probe(); + const daemon: unknown = JSON.parse(await prerequisite(["info", "--format", '{"id":{{json .ID}},"os":{{json .OSType}},"arch":{{json .Architecture}}}'])); + if (!(isRecord(daemon) && daemon.id === context.engineId && daemon.os === "linux" && (daemon.arch === "arm64" || daemon.arch === "aarch64"))) refuse(); + await image(prerequisite); + nativeComposeStorageDockerHelper(); + lifetime(context); + } catch { + throw new Error("Native storage witnesses require a Linux arm64 daemon and the fixed cached Bun 1.4.2 helper image; no image is pulled automatically."); + } + const inspect = async (selection: Selection) => { + const before = await current(context, selection); + const target = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection, engineId: context.engineId, stopped: false }); + const after = await current(context, selection); + if (!same(before, after)) return refuse(); + lifetime(context); + return target; + }; + const provision = async (selection: Selection & { readonly engineId: string; readonly runtimeIdentity: string; readonly ownerToken: string }) => { + const before = await current(context, selection, true); + if (!(selection.engineId === context.engineId && selection.runtimeIdentity === before.identity.composeProject && selection.ownerToken === before.identity.ownerToken)) return refuse(); + const absent = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection, engineId: context.engineId, stopped: true }); + if (absent.volume !== null) return refuse(); + await image(probe()); + const nonce = randomBytes(16).toString("hex"); + const directory = await holdDirectory(join(context.store.identity.checkoutRoot, ".hack", ".internal", "native-compose", context.store.identity.instanceId), true); + const held: HeldDirectory[] = [directory]; + let files: Files | null = null; + try { + const root = await privateDirectory(join(directory.path, "storage-provisions")); held.push(root); + const path = join(root.path, nonce); await mkdir(path, { mode: 0o700 }); await root.file.sync(); + const leaf = await holdDirectory(path, true); held.push(leaf); + const request = join(path, "request.json"), program = join(path, "intent.json"); + const inputInfo = await writeExclusive(request, ""); + const programInfo = await writeExclusive(program, JSON.stringify({ ...selection, nonce })); await leaf.file.sync(); + files = { held, path, input: request, program, programInfo, inputInfo, requestText: "", captures: [] }; + const read = probe(); await engine(read, context); + const finalAbsent = await observeNativeComposeStorageDockerTarget({ probe: read, current: before, selection, engineId: context.engineId, stopped: true }); + if (finalAbsent.volume !== null) return refuse(); + tool(); await recheckDirectories(held); + const latest = await current(context, selection, true); + if (!same(before, latest)) return refuse(); + const result = await command({ context, files, + args: [docker, "volume", "create", "--driver", "local", "--label", `com.docker.compose.project=${selection.runtimeIdentity}`, + "--label", `com.docker.compose.volume=${selection.storage}`, "--label", "io.hack.native-config.version=1", + "--label", `io.hack.native-config.instance=${selection.runtimeIdentity}`, "--label", `io.hack.native-config.owner=${selection.ownerToken}`, + "--label", `io.hack.native-config.storage=${selection.storage}`, "--label", `io.hack.native-config.storage-provision=${nonce}`, selection.name], + timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, + assertAdmitted: async () => { await engine(probe(), context); tool(); if (!same(before, await current(context, selection, true))) refuse(); } }); + if (!(result.exitCode === 0 && result.stdout.trim() === selection.name)) return refuse(); + const created = await inspectNativeComposeStorageDockerVolume({ probe: probe(), current: latest, selection }); + if (created.provision !== nonce) return refuse(); + const after = await current(context, selection, true); + if (!same(before, after)) return refuse(); + } finally { await Promise.allSettled(held.map((entry) => entry.file.close())); } + }; + const invoke = async (input: NativeComposeStorageXattrInvocation) => { + const before = await current(context, input.target, !input.readonly); + if (!(matchesScope(input, before) && same(input.artifact, artifact))) return refuse(); + const target = await inspect(input.target); + if (!same(target, input.target)) return refuse(); + const files = await invocationFiles(context, input); + let known: { readonly id: string; readonly createdAt: string; readonly policy: string } | null = null; + let completed = false; + try { + const read = probe(); await engine(read, context); await image(read); await checkFiles(files); tool(); + const admitted = await current(context, input.target, !input.readonly); + if (!same(before, admitted)) return refuse(); + const created = await command({ context, files, args: [docker, ...nativeComposeStorageDockerCreateArgs({ input, program: files.program })], + timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, + assertAdmitted: async () => { await engine(probe(), context); tool(); if (!same(before, await current(context, input.target, !input.readonly))) refuse(); } }); + const id = created.stdout.trim(); + if (!(created.exitCode === 0 && ID.test(id))) return refuse(); + const inspectCarrier = async (cleanup = false): Promise => { + const observed = probe(cleanup); await engine(observed, context); + const value: unknown = JSON.parse(await observed(["container", "inspect", "--format", NATIVE_STORAGE_CARRIER_FORMAT, id])); + return checkNativeComposeStorageDockerCarrier({ value, input, program: files.program, imageIds: IMAGE_IDS }); + }; + const carrier = await inspectCarrier(); + known = { id, createdAt: carrier.createdAt, policy: nativeComposeStorageDockerCarrierPolicy(carrier) }; + const owned = known; + if (!(carrier.state.Running === false && carrier.state.Pid === 0 && carrier.state.Status === "created")) return refuse(); + await input.recordCreated({ id, createdAt: known.createdAt }); + await checkFiles(files); await image(probe()); + const volume = await inspectNativeComposeStorageDockerVolume({ probe: probe(), current: before, selection: input.target }); + if (!(input.target.volume && volume.createdAt === input.target.volume.createdAt && volume.mountpoint === input.target.mountpoint)) return refuse(); + const holders = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection: input.target, engineId: context.engineId, + stopped: !input.readonly, carrier: { ...known, invocationId: input.invocationId } }); + if (!same(holders, input.target)) return refuse(); + const ready = await inspectCarrier(); + if (!(ready.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(ready) === known.policy && ready.state.Running === false && ready.state.Pid === 0 && ready.state.Status === "created")) return refuse(); + tool(); + const final = await current(context, input.target, !input.readonly); + if (!same(before, final)) return refuse(); + const result = await command({ context, files, args: [docker, "start", "-ai", id], timeoutMs: 15_000, + beforeSpawn: () => { tool(); lifetime(context); }, + assertAdmitted: async () => { + const selected = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection: input.target, engineId: context.engineId, + stopped: !input.readonly, carrier: { ...owned, invocationId: input.invocationId } }); + if (!same(selected, input.target)) refuse(); + await checkFiles(files); + const ready = await inspectCarrier(); + if (!(ready.createdAt === owned.createdAt && nativeComposeStorageDockerCarrierPolicy(ready) === owned.policy && ready.state.Running === false && ready.state.Pid === 0 && ready.state.Status === "created")) refuse(); + await engine(probe(), context); tool(); if (!same(before, await current(context, input.target, !input.readonly))) refuse(); + } }); + if (result.exitCode !== 0 && result.exitCode !== 1) return refuse(); + const response = parseNativeComposeStorageXattrResponse(result.stdout); + if ((response.outcome === "refused") !== (result.exitCode === 1)) return refuse(); + const stopped = await inspectCarrier(true); + if (!(stopped.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(stopped) === known.policy && + stopped.state.Running === false && stopped.state.Pid === 0 && stopped.state.Status === "exited" && stopped.state.ExitCode === result.exitCode && + stopped.state.Paused === false && stopped.state.Restarting === false && stopped.state.OOMKilled === false && stopped.state.Dead === false && stopped.state.Error === "")) return refuse(); + const repeated = await inspectCarrier(true); + if (!(repeated.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(repeated) === known.policy && same(repeated.state, stopped.state))) return refuse(); + await checkFiles(files); tool(); + const cleanupAuthority = await current(context, input.target); + if (!same(before, cleanupAuthority)) return refuse(); + const removed = await command({ context, files, cleanup: true, args: [docker, "rm", id], timeoutMs: 10_000, beforeSpawn: tool, + assertAdmitted: async () => { + const finalCarrier = await inspectCarrier(true); + if (!(finalCarrier.createdAt === owned.createdAt && nativeComposeStorageDockerCarrierPolicy(finalCarrier) === owned.policy && same(finalCarrier.state, stopped.state))) refuse(); + await engine(probe(true), context); + tool(); if (!same(before, await current(context, input.target))) refuse(); + } }); + if (removed.exitCode !== 0) return refuse(); + const absent = probe(true); await engine(absent, context); + const inventory = (await absent(["container", "ls", "-a", "--no-trunc", "--format", "{{json .ID}}"])).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as unknown); + if (inventory.some((value) => typeof value !== "string" || !ID.test(value)) || inventory.includes(id) || new Set(inventory).size !== inventory.length) return refuse(); + const selected = (await absent(["container", "ls", "-a", "--no-trunc", "--filter", `label=${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, "--format", "{{json .ID}}"])).trim(); + if (selected !== "") return refuse(); + const after = await inspect(input.target); + if (!same(after, input.target)) return refuse(); + await retireInvocationFiles(files); + completed = true; + return { artifact, carrierId: id, carrierCreatedAt: known.createdAt, containersAfterCleanup: [], engineId: context.engineId, + invocationId: input.invocationId, readonly: input.readonly, uid: input.uid, gid: input.gid, scope: input.scope, + outcome: "complete", exitCode: result.exitCode, response: result.stdout, target: after, + stopped: { id, running: false, pid: 0, exitCode: result.exitCode } }; + } finally { + // Failure never starts a competing stop/remove after unknown child disposition. + // The original prospective/created journal and private input remain authoritative. + await Promise.allSettled(files.held.map((entry) => entry.file.close())); + if (!completed && known) { + // Exact retained ID/birth are already in the existing private finite journal. + } + } + }; + return captureNativeComposeStorageXattrCarrier({ artifact, ports: { inspect, provision, invoke }, signal: context.signal, deadline: context.deadline }); +} diff --git a/src/lib/native-compose-storage-witness-helper-bundle.ts b/src/lib/native-compose-storage-witness-helper-bundle.ts new file mode 100644 index 00000000..826a57d6 --- /dev/null +++ b/src/lib/native-compose-storage-witness-helper-bundle.ts @@ -0,0 +1,2 @@ +// Generated from scripts/native-storage-witness-helper.ts. Do not edit the payload. +export const NATIVE_STORAGE_WITNESS_HELPER = "// @bun\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nimport { randomBytes, timingSafeEqual } from \"crypto\";\n\n// src/lib/guards.ts\nfunction isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nvar NATIVE_STORAGE_XATTR_KIND = \"directory-xattr\";\nvar NATIVE_STORAGE_XATTR_VERSION = 1;\nvar NATIVE_STORAGE_XATTR_BYTES = 32;\nvar NATIVE_STORAGE_XATTR_INPUT_LIMIT = 4096;\nvar NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar VALUE = /^[a-f0-9]{64}$/;\nvar DECIMAL = /^(0|[1-9][0-9]{0,19})$/;\nvar UINT64_MAX = 18446744073709551615n;\nvar UINT32_MAX = 4294967295;\nfunction refuseNativeComposeStorageXattr() {\n throw new Error(\"Native storage xattr proof refused; values omitted.\");\n}\nfunction keys(value, expected) {\n return Object.keys(value).sort().join(\",\") === expected;\n}\nfunction uint32(value) {\n return typeof value === \"number\" && Number.isInteger(value) && !Object.is(value, -0) && value >= 0 && value <= UINT32_MAX;\n}\nfunction uint64(value) {\n return typeof value === \"string\" && DECIMAL.test(value) && BigInt(value) <= UINT64_MAX;\n}\nfunction nativeComposeStorageXattrRootValid(value) {\n return isRecord(value) && keys(value, \"device,gid,inode,uid\") && uint64(value.device) && uint64(value.inode) && value.inode !== \"0\" && uint32(value.uid) && uint32(value.gid);\n}\nfunction sameNativeComposeStorageXattrRoot(left, right) {\n return left.device === right.device && left.inode === right.inode && left.uid === right.uid && left.gid === right.gid;\n}\nfunction markerValid(value) {\n return value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && typeof value.name === \"string\" && NAME.test(value.name) && typeof value.valueHex === \"string\" && VALUE.test(value.valueHex);\n}\nfunction decodeNativeComposeStorageXattrRequest(value) {\n if (!isRecord(value)) {\n return refuseNativeComposeStorageXattr();\n }\n if (keys(value, \"kind,operation,version\") && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && value.operation === \"root\") {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: \"root\"\n });\n }\n if (!(keys(value, \"kind,name,operation,root,valueHex,version\") && markerValid(value) && (value.operation === \"seed\" || value.operation === \"verify\") && nativeComposeStorageXattrRootValid(value.root))) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: value.operation,\n name: value.name,\n valueHex: value.valueHex,\n root: Object.freeze({ ...value.root })\n });\n}\nfunction decodeNativeComposeStorageXattrResponse(value) {\n if (!(isRecord(value) && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION)) {\n return refuseNativeComposeStorageXattr();\n }\n if (value.outcome === \"refused\" && keys(value, \"kind,outcome,version\")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n });\n }\n if (!nativeComposeStorageXattrRootValid(value.root)) {\n return refuseNativeComposeStorageXattr();\n }\n const root = Object.freeze({ ...value.root });\n if ((value.outcome === \"root\" || value.outcome === \"seeded\") && keys(value, \"kind,outcome,root,version\")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: value.outcome,\n root\n });\n }\n if (value.outcome === \"verified\" && keys(value, \"kind,outcome,root,valueHex,version\") && typeof value.valueHex === \"string\" && VALUE.test(value.valueHex)) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"verified\",\n root,\n valueHex: value.valueHex\n });\n }\n return refuseNativeComposeStorageXattr();\n}\nfunction encodeNativeComposeStorageXattrResponse(value) {\n return `${JSON.stringify(decodeNativeComposeStorageXattrResponse(value))}\n`;\n}\nfunction parse(text, limit, decode) {\n if (Buffer.byteLength(text) > limit) {\n return refuseNativeComposeStorageXattr();\n }\n try {\n const value = decode(JSON.parse(text));\n if (text !== `${JSON.stringify(value)}\n`) {\n return refuseNativeComposeStorageXattr();\n }\n return value;\n } catch {\n return refuseNativeComposeStorageXattr();\n }\n}\nfunction parseNativeComposeStorageXattrRequest(text) {\n return parse(text, NATIVE_STORAGE_XATTR_INPUT_LIMIT, decodeNativeComposeStorageXattrRequest);\n}\nfunction nativeComposeStorageXattrValueMatches(actual, expectedHex) {\n return actual.byteLength === NATIVE_STORAGE_XATTR_BYTES && VALUE.test(expectedHex) && timingSafeEqual(actual, Buffer.from(expectedHex, \"hex\"));\n}\n\n// src/lib/native-compose-storage-witness-xattr-helper.ts\nfunction root(value) {\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({ ...value });\n}\nfunction requireRoot(actual, expected) {\n if (!sameNativeComposeStorageXattrRoot(root(actual), expected)) {\n refuseNativeComposeStorageXattr();\n }\n}\nfunction runNativeComposeStorageXattrHelper(opts) {\n let fd = null;\n let capturedClose = null;\n try {\n const request = decodeNativeComposeStorageXattrRequest(opts.request);\n const {\n effectiveUid,\n effectiveGid,\n probeRoot,\n openRoot,\n statRoot,\n createXattr,\n syncRoot,\n readXattr,\n closeRoot\n } = opts.kernel;\n capturedClose = closeRoot;\n if (request.operation === \"root\") {\n const before = root(probeRoot());\n requireRoot(probeRoot(), before);\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"root\",\n root: before\n };\n }\n if (effectiveUid() !== request.root.uid || effectiveGid() !== request.root.gid) {\n return refuseNativeComposeStorageXattr();\n }\n fd = openRoot();\n if (!Number.isInteger(fd) || fd < 0) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n let response;\n if (request.operation === \"seed\") {\n createXattr(fd, request.name, Buffer.from(request.valueHex, \"hex\"));\n syncRoot(fd);\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"seeded\",\n root: request.root\n };\n } else {\n const first = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(first, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n const latest = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(latest, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"verified\",\n root: request.root,\n valueHex: Buffer.from(latest).toString(\"hex\")\n };\n }\n const selected = fd;\n fd = null;\n closeRoot(selected);\n return response;\n } catch {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n };\n } finally {\n if (fd !== null && Number.isInteger(fd) && fd >= 0 && capturedClose) {\n try {\n capturedClose(fd);\n } catch {}\n }\n }\n}\n\n// src/lib/native-compose-storage-witness-xattr-linux.ts\nimport {\n closeSync,\n constants,\n fstatSync,\n fsyncSync,\n lstatSync,\n openSync\n} from \"fs\";\nvar XATTR_NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar NATIVE_STORAGE_XATTR_LINUX_ABI = Object.freeze({\n version: 1,\n root: \"/hack-storage-witness\",\n xattrCreate: 1,\n openCloseExec: 1 << 19,\n fGetFd: 1,\n fdCloseExec: 1,\n pointerBits: 64,\n sizeTBits: 64,\n signedSizeBits: 64,\n arm64: Object.freeze({\n libc: \"/lib/aarch64-linux-gnu/libc.so.6\",\n directory: 1 << 14,\n noFollow: 1 << 15\n }),\n x64: Object.freeze({\n libc: \"/lib/x86_64-linux-gnu/libc.so.6\",\n directory: 1 << 16,\n noFollow: 1 << 17\n })\n});\nfunction directory(info) {\n if (!info.isDirectory() || info.isSymbolicLink()) {\n return refuseNativeComposeStorageXattr();\n }\n const value = {\n device: info.dev.toString(),\n inode: info.ino.toString(),\n uid: Number(info.uid),\n gid: Number(info.gid)\n };\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze(value);\n}\nfunction libc() {\n let abi = null;\n if (process.arch === \"arm64\") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.arm64;\n } else if (process.arch === \"x64\") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.x64;\n }\n if (process.platform !== \"linux\" || !abi || constants.O_DIRECTORY !== abi.directory || constants.O_NOFOLLOW !== abi.noFollow || constants.O_RDONLY !== 0 || !process.geteuid || !process.getegid || !process.getuid || !process.getgid || process.getuid() !== process.geteuid() || process.getgid() !== process.getegid()) {\n return refuseNativeComposeStorageXattr();\n }\n return abi.libc;\n}\nasync function createNativeComposeStorageXattrLinuxKernel() {\n const libraryPath = libc();\n const { dlopen, FFIType, ptr } = await import(\"bun:ffi\");\n const library = dlopen(libraryPath, {\n fsetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.i32],\n returns: FFIType.i32\n },\n fgetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64],\n returns: FFIType.i64\n },\n fcntl: {\n args: [FFIType.i32, FFIType.i32, FFIType.i32],\n returns: FFIType.i32\n }\n });\n const { fsetxattr, fgetxattr, fcntl } = library.symbols;\n const held = new Set;\n let closed = false;\n const active = () => {\n if (closed) {\n refuseNativeComposeStorageXattr();\n }\n };\n const fdOwned = (fd) => {\n active();\n if (!held.has(fd)) {\n refuseNativeComposeStorageXattr();\n }\n };\n const nameBytes = (name) => {\n if (!XATTR_NAME.test(name)) {\n return refuseNativeComposeStorageXattr();\n }\n return Buffer.from(`${name}\\x00`, \"ascii\");\n };\n const kernel = Object.freeze({\n effectiveUid: () => process.geteuid?.() ?? refuseNativeComposeStorageXattr(),\n effectiveGid: () => process.getegid?.() ?? refuseNativeComposeStorageXattr(),\n probeRoot() {\n active();\n return directory(lstatSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, {\n bigint: true\n }));\n },\n openRoot() {\n active();\n const fd = openSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | NATIVE_STORAGE_XATTR_LINUX_ABI.openCloseExec);\n held.add(fd);\n const flags = fcntl(fd, NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd, 0);\n if (flags < 0 || (flags & NATIVE_STORAGE_XATTR_LINUX_ABI.fdCloseExec) === 0) {\n held.delete(fd);\n closeSync(fd);\n return refuseNativeComposeStorageXattr();\n }\n return fd;\n },\n statRoot(fd) {\n fdOwned(fd);\n return directory(fstatSync(fd, { bigint: true }));\n },\n createXattr(fd, name, value) {\n fdOwned(fd);\n if (value.byteLength !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const bytes = Buffer.from(value);\n if (fsetxattr(fd, ptr(selectedName), ptr(bytes), BigInt(bytes.length), NATIVE_STORAGE_XATTR_LINUX_ABI.xattrCreate) !== 0) {\n return refuseNativeComposeStorageXattr();\n }\n },\n syncRoot(fd) {\n fdOwned(fd);\n fsyncSync(fd);\n },\n readXattr(fd, name, bytes) {\n fdOwned(fd);\n if (bytes !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const value = Buffer.alloc(bytes);\n if (fgetxattr(fd, ptr(selectedName), ptr(value), BigInt(bytes)) !== BigInt(bytes)) {\n return refuseNativeComposeStorageXattr();\n }\n return new Uint8Array(value);\n },\n closeRoot(fd) {\n fdOwned(fd);\n held.delete(fd);\n closeSync(fd);\n }\n });\n return Object.freeze({\n kernel,\n close() {\n active();\n closed = true;\n try {\n for (const fd of held) {\n closeSync(fd);\n }\n } finally {\n held.clear();\n library.close();\n }\n }\n });\n}\n\n// scripts/native-storage-witness-helper.ts\nasync function input() {\n const reader = Bun.stdin.stream().getReader();\n const chunks = [];\n let length = 0;\n try {\n while (true) {\n const next = await reader.read();\n if (next.done) {\n break;\n }\n length += next.value.byteLength;\n if (length > NATIVE_STORAGE_XATTR_INPUT_LIMIT) {\n await reader.cancel();\n return refuseNativeComposeStorageXattr();\n }\n chunks.push(next.value);\n }\n } finally {\n reader.releaseLock();\n }\n return new TextDecoder(\"utf-8\", { fatal: true }).decode(Buffer.concat(chunks));\n}\nfunction refused() {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n };\n}\nasync function nativeStorageWitnessHelperMain() {\n let response = refused();\n let close = null;\n try {\n const request = parseNativeComposeStorageXattrRequest(await input());\n const selected = await createNativeComposeStorageXattrLinuxKernel();\n close = selected.close;\n response = runNativeComposeStorageXattrHelper({\n request,\n kernel: selected.kernel\n });\n } catch {\n response = refused();\n } finally {\n if (close) {\n try {\n close();\n } catch {\n response = refused();\n }\n }\n }\n try {\n await Bun.write(Bun.stdout, encodeNativeComposeStorageXattrResponse(response));\n } catch {\n return 1;\n }\n return response.outcome === \"refused\" ? 1 : 0;\n}\nif (import.meta.main) {\n process.exitCode = await nativeStorageWitnessHelperMain();\n}\nexport {\n nativeStorageWitnessHelperMain\n};\n"; diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts new file mode 100644 index 00000000..0c5c4786 --- /dev/null +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertNativeComposeMaterialAuthority, + type NativeComposeGenerationStore, + type NativeComposeMaterialBinding, + openNativeComposeGenerationStore, +} from "../src/lib/native-compose-generation.ts"; +import { + NATIVE_STORAGE_DOCKER_ARTIFACT, + nativeComposeStorageDockerHelper, +} from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; +import { observeNativeComposeStorageDockerTarget } from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; +import { + checkNativeComposeStorageDockerCarrier, + NATIVE_STORAGE_CARRIER_LABEL, + nativeComposeStorageDockerCarrierPolicy, + nativeComposeStorageDockerCreateArgs, +} from "../src/lib/native-compose-storage-witness-docker-policy.ts"; +import type { NativeComposeStorageXattrInvocation } from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; + +const roots: string[] = [], stores: NativeComposeGenerationStore[] = []; +const engineId = "engine-fixture"; +const selection = { name: "owned_data", storage: "data" }; +const createdAt = "2026-10-08T12:00:00Z"; +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.close())); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function withBinding(run: (value: NativeComposeMaterialBinding) => Promise) { + const root = await realpath(await mkdtemp(join(tmpdir(), "native-storage-docker-"))); + roots.push(root); + await mkdir(join(root, ".hack")); + await Bun.write(join(root, ".hack/hack.project.json"), JSON.stringify({ schema_version: 1, name: "storage" })); + const store = await openNativeComposeGenerationStore({ projectRoot: root, instance: null }); + stores.push(store); + await store.withMutation(async (mutation) => { + const reservation = mutation.reserveGeneration(); + const labels = { "io.hack.native-config.version": "1", "io.hack.native-config.owner": store.identity.ownerToken, + "io.hack.native-config.instance": store.identity.composeProject }; + const generation = await mutation.publish({ reservation, profiles: [], inputRevision: "a".repeat(64), assertFresh: async () => {}, composeJson: JSON.stringify({ + name: store.identity.composeProject, + services: { app: { image: "synthetic:1", labels: { ...labels, "io.hack.native-config.generation": reservation.generationId, "io.hack.native-config.workload": "service" } } }, + volumes: { data: { name: selection.name, labels: { ...labels, "io.hack.native-config.storage": selection.storage } } }, + }) }); + await mutation.runEffect({ generation, operation: "up", assertOwned: async () => {}, + effect: async () => { + await run(await assertNativeComposeMaterialAuthority({ authority: mutation.materialAuthority, generation, phase: "effect" })); + return { outcome: "complete", value: 0 }; + } }); + }); +} +function inventory(binding: NativeComposeMaterialBinding) { + const state = { present: true, drift: false, foreign: false, running: false, mounts: true, version: "1", volumeReads: 0 }; + const id = "c".repeat(64); + const mutableCalls: string[][] = []; + const probe = async (args: readonly string[]): Promise => { + mutableCalls.push([...args]); + if (args[0] === "info") return JSON.stringify(engineId); + if (args[0] === "volume" && args[1] === "ls") return state.present ? JSON.stringify(selection.name) : ""; + if (args[0] === "volume" && args[1] === "inspect") { + state.volumeReads++; + return JSON.stringify({ ...selection, createdAt: state.drift && state.volumeReads > 1 ? "2026-10-08T13:00:00Z" : createdAt, + driver: "local", options: null, mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, + project: binding.identity.composeProject, instance: binding.identity.composeProject, owner: binding.identity.ownerToken, version: state.version, provision: null }); + } + if (args[0] === "container" && args[1] === "ls") return state.mounts ? JSON.stringify(id) : ""; + if (args[0] === "container" && args[1] === "inspect") return JSON.stringify({ id, createdAt, + mounts: [{ Type: "volume", Name: selection.name, Source: `/var/lib/docker/volumes/${selection.name}/_data`, Destination: "/data", RW: true }], + running: state.running, project: binding.identity.composeProject, instance: binding.identity.composeProject, + owner: state.foreign ? "f".repeat(32) : binding.identity.ownerToken, version: state.version, generation: binding.generationId, + carrier: null, carrierOwner: null, carrierGeneration: null }); + throw new Error("Unexpected synthetic Docker request"); + }; + return { state, probe, calls: mutableCalls }; +} +test("full holder inventory admits stopped and exact owned running readers and preserves strict sparse reads", async () => { + await withBinding(async (current) => { + const fake = inventory(current); + const target = await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe }); + expect(target.volume).toEqual({ ...selection, createdAt }); + expect(target.holders).toHaveLength(1); + fake.state.running = true; + expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: false, probe: fake.probe })).holders[0]?.running).toBe(true); + expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm") && !args.join(" ").includes("Config.Env"))).toBe(true); + }); +}); +test.each(["foreign", "running", "drift", "version"] as const)("selected holder or volume %s refuses before any effects", async (failure) => { + await withBinding(async (current) => { + const fake = inventory(current); + if (failure === "version") fake.state.version = "2"; else fake.state[failure] = true; + await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow("values omitted"); + expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm"))).toBe(true); + }); +}); +test("genuinely absent cold storage needs an empty complete holder selection", async () => { + await withBinding(async (current) => { + const fake = inventory(current); fake.state.present = false; fake.state.mounts = false; + expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).volume).toBeNull(); + fake.state.mounts = true; + await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow(); + }); +}); +function input(): NativeComposeStorageXattrInvocation { + return { recordCreated: async () => {}, invocationId: "a".repeat(32), artifact: NATIVE_STORAGE_DOCKER_ARTIFACT, + target: { ...selection, engineId, runtimeIdentity: "owned", ownerToken: "b".repeat(32), volume: { ...selection, createdAt }, + mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, driver: "local", options: {}, holders: [] }, + readonly: true, uid: 70, gid: 70, request: { kind: "directory-xattr", version: 1, operation: "root" }, + scope: { generationId: "d".repeat(32), currentGenerationId: "d".repeat(32), pendingGenerationId: null, pendingToken: null } }; +} +function carrier(input: NativeComposeStorageXattrInvocation) { + const program = "/private-owned/helper.mjs"; + const mounts = [[program, "/hack-storage-witness-helper.mjs"], [input.target.mountpoint, "/hack-storage-witness"]].map(([source, target]) => ({ + Type: "bind", Source: source, Destination: target, RW: false, Propagation: "rprivate" })); + const value = { id: "e".repeat(64), createdAt, image: input.artifact.imageId, configImage: input.artifact.imageId, + user: "70:70", entrypoint: ["/usr/local/bin/bun"], cmd: ["--no-env-file", "/hack-storage-witness-helper.mjs"], openStdin: true, tty: false, + labels: { [NATIVE_STORAGE_CARRIER_LABEL]: input.invocationId, "io.hack.storage-witness.owner": input.target.ownerToken, + "io.hack.storage-witness.generation": input.scope.generationId, "io.hack.storage-witness.helper": input.artifact.helperHash }, + host: { Privileged: false, ReadonlyRootfs: true, NetworkMode: "none", Memory: 268_435_456, NanoCpus: 1_000_000_000, PidsLimit: 32, + CapDrop: ["ALL"], CapAdd: null, SecurityOpt: ["no-new-privileges:true"], LogConfig: { Type: "none", Config: {} }, RestartPolicy: { Name: "no" }, + Binds: null, Tmpfs: null, Devices: [], DeviceRequests: null, PortBindings: {}, OomKillDisable: false as false | null | true, + Mounts: mounts.map((mount) => ({ Type: mount.Type, Source: mount.Source, Target: mount.Destination, ReadOnly: true, + BindOptions: { NonRecursive: true, Propagation: "rprivate", CreateMountpoint: false } })) }, + mounts, state: {}, execIds: null as null | string[] }; + return { program, value }; +} +test("helper args require explicit cached dependency/noncreating RO binds and contain no witness token", () => { + const selected = input(), fake = carrier(selected); + expect(nativeComposeStorageDockerHelper().length).toBeGreaterThan(1000); + const args = nativeComposeStorageDockerCreateArgs({ input: selected, program: fake.program }); + expect(args).toContain("never"); expect(args).toContain("none"); + expect(args.filter((value) => value.startsWith("type=bind,")).every((value) => value.includes("readonly,bind-recursive=disabled"))).toBe(true); + expect(args.some((value) => value.includes("bind-create-src") || value.includes("type=volume"))).toBe(false); + checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] }); +}); +test.each(["empty", "subset", "duplicate", "writable", "creating", "logging"] as const)("physical/configured projection %s cannot qualify a helper", (failure) => { + const selected = input(), fake = carrier(selected); + if (failure === "empty") fake.value.mounts = []; + if (failure === "subset") fake.value.mounts.pop(); + if (failure === "duplicate") fake.value.mounts[1] = fake.value.mounts[0]!; + if (failure === "writable") fake.value.mounts[1]!.RW = true; + if (failure === "creating") fake.value.host.Mounts[1]!.BindOptions.CreateMountpoint = true; + if (failure === "logging") fake.value.host.LogConfig.Type = "json-file"; + expect(() => checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] })).toThrow("values omitted"); +}); +test("policy comparison preserves all physical fields/duplicates and closes only observed optional defaults", () => { + const selected = input(), fake = carrier(selected); + const checked = () => checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] }); + const before = nativeComposeStorageDockerCarrierPolicy(checked()); + fake.value.mounts.reverse(); fake.value.host.OomKillDisable = null; fake.value.execIds = []; + expect(nativeComposeStorageDockerCarrierPolicy(checked())).toBe(before); + fake.value.host.OomKillDisable = true; + expect(() => nativeComposeStorageDockerCarrierPolicy(checked())).toThrow(); +}); From 7218d873ab48a7b7c7e907014f83f7eab1ee38e8 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:27:47 -0400 Subject: [PATCH 15/23] fix: close storage carrier holder and descriptor boundaries --- .../native-compose-storage-xattr-carrier.md | 12 ++- ...ompose-storage-witness-docker-inventory.ts | 12 ++- ...ative-compose-storage-witness-docker-io.ts | 74 +++++++++++++++ .../native-compose-storage-witness-docker.ts | 58 ++++++------ src/lib/shell.ts | 19 +++- ...-compose-storage-witness-docker-io.test.ts | 92 +++++++++++++++++++ ...ive-compose-storage-witness-docker.test.ts | 27 +++++- tests/shell-private-io.test.ts | 26 ++++++ 8 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 src/lib/native-compose-storage-witness-docker-io.ts create mode 100644 tests/native-compose-storage-witness-docker-io.test.ts create mode 100644 tests/shell-private-io.test.ts diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 11d972f3..1b1774f3 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -3,8 +3,8 @@ This is an unactivated Linux helper source, injectable owner protocol and offline controls. The [witness foundation](native-compose-storage-witness.md) accepts its distinct xattr expectation/reference format under the same opaque generation -authority. There is no Docker implementation of the carrier ports, and the CLI -does not enroll volume witnesses. Ordinary CLI instances retain the metadata +authority. A candidate Docker implementation of the carrier ports is unactivated; +the CLI does not enroll volume witnesses. Ordinary CLI instances retain the metadata guard's same-metadata replacement limitation. The proposed directory xattr avoids adding a directory entry to PostgreSQL's @@ -185,6 +185,14 @@ helper, non-force removal and fresh empty helper inventory. Unknown disposition retains the private journal and input. Journal bytes do not replace these actual stop and absence observations. The CLI does not call this unactivated port. +Holder selection includes normalized ancestor and descendant bind paths; similarly +named sibling roots stay independent. Command stdin and captures use held no-follow +descriptors, checked after the final admission await and held until owned group +absence. A fixed shell file-size quota bounds each capture to at most 8192 bytes +on the admitted shell platforms; acceptance is at most 4096 bytes per stream. +Unknown child disposition retains descriptors and the pending journal rather than +starting competing cleanup. No child redirects through a reopened named path. + The first declared dependency is the fixed Linux arm64 Bun 1.4.2 image `sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61`. The packaged helper pins its bytes and the qualified Bun/libc bytes separately. diff --git a/src/lib/native-compose-storage-witness-docker-inventory.ts b/src/lib/native-compose-storage-witness-docker-inventory.ts index a7651acc..3ef53c02 100644 --- a/src/lib/native-compose-storage-witness-docker-inventory.ts +++ b/src/lib/native-compose-storage-witness-docker-inventory.ts @@ -1,4 +1,5 @@ import { isRecord } from "./guards.ts"; +import { posix } from "node:path"; import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; import { createNativeComposeProbe } from "./native-compose-ownership.ts"; import { nativeComposeVolumeCreatedAt } from "./native-compose-retained-storage.ts"; @@ -15,6 +16,14 @@ const VOLUME = `{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver": const HOLDER = `{"id":{{json .Id}},"createdAt":{{json .Created}},"mounts":{{json .Mounts}},"running":{{json .State.Running}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"instance":{{json (index .Config.Labels "${PREFIX}.instance")}},"owner":{{json (index .Config.Labels "${PREFIX}.owner")}},"version":{{json (index .Config.Labels "${PREFIX}.version")}},"generation":{{json (index .Config.Labels "${PREFIX}.generation")}},"carrier":{{json (index .Config.Labels "io.hack.storage-witness.carrier")}},"carrierOwner":{{json (index .Config.Labels "io.hack.storage-witness.owner")}},"carrierGeneration":{{json (index .Config.Labels "io.hack.storage-witness.generation")}}}`; type Probe = ReturnType; type Selection = { readonly name: string; readonly storage: string }; +/** A parent or child bind exposes the selected root too. Normalize complete paths, + * then compare separator boundaries so a similarly named sibling stays independent. */ +export function nativeComposeStorageDockerPathsOverlap(left: string, right: string): boolean { + if (!(posix.isAbsolute(left) && posix.isAbsolute(right))) return false; + const a = posix.normalize(left).replace(/\/+$/, "") || "/"; + const b = posix.normalize(right).replace(/\/+$/, "") || "/"; + return a === b || a === "/" || b === "/" || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); +} function rows(text: string): Record[] { if (text.trim() === "") return []; @@ -82,7 +91,8 @@ export async function observeNativeComposeStorageDockerTarget(opts: { let selected = false; for (const mount of value.mounts) { if (!(isRecord(mount) && typeof mount.Type === "string" && typeof mount.Source === "string" && typeof mount.Destination === "string" && typeof mount.RW === "boolean")) return refuse(); - if ((mount.Type === "volume" && mount.Name === selection.name) || mount.Source === `/var/lib/docker/volumes/${selection.name}/_data`) selected = true; + if ((mount.Type === "volume" && mount.Name === selection.name) || + nativeComposeStorageDockerPathsOverlap(mount.Source, `/var/lib/docker/volumes/${selection.name}/_data`)) selected = true; } if (!selected) continue; if (opts.carrier && value.id === opts.carrier.id) { diff --git a/src/lib/native-compose-storage-witness-docker-io.ts b/src/lib/native-compose-storage-witness-docker-io.ts new file mode 100644 index 00000000..eac2c6cf --- /dev/null +++ b/src/lib/native-compose-storage-witness-docker-io.ts @@ -0,0 +1,74 @@ +import { constants, fstatSync, lstatSync, readSync, type Stats } from "node:fs"; +import { type FileHandle, open } from "node:fs/promises"; +import { + type HeldDirectory, + sameFile, +} from "./native-compose-private-state.ts"; +import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; + +type Leaf = { readonly path: string; readonly info: Stats }; +type HeldLeaf = Leaf & { readonly file: FileHandle }; +// Unknown child disposition cannot authorize closing its inherited descriptors. +// Retention is not a reaper or recovery authority; the private journal stays pending. +const unsettledDescriptors = new Set(); + +function checkLeaf(leaf: HeldLeaf, limit: number): Stats { + try { + const info = fstatSync(leaf.file.fd), named = lstatSync(leaf.path); + if (!(info.isFile() && named.isFile() && sameFile(info, leaf.info) && sameFile(info, named) && + info.nlink === 1 && named.nlink === 1 && info.uid === process.getuid?.() && named.uid === info.uid && + (info.mode & 0o777) === 0o600 && (named.mode & 0o777) === 0o600 && info.size <= limit)) return refuse(); + return info; + } catch { return refuse(); } +} +function readLeaf(leaf: HeldLeaf, limit: number): string { + const before = checkLeaf(leaf, limit), bytes = Buffer.alloc(before.size + 1); + const count = readSync(leaf.file.fd, bytes, 0, bytes.length, 0); + const after = checkLeaf(leaf, limit); + if (!(count === before.size && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs)) return refuse(); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count)); +} + +/** Holds exact no-follow leaves through shared run() and owned group settlement. + * Child writes are separately quota-limited by the fixed shell wrapper; reads + * accept at most limit bytes. No named path is reopened or truncated by the child. */ +export async function holdNativeComposeStorageDockerIo(opts: { + readonly directories: readonly HeldDirectory[]; + readonly input: Leaf & { readonly text: string }; + readonly stdout: Leaf; + readonly stderr: Leaf; + readonly limit: number; +}) { + const directories = [...opts.directories]; + const input = { ...opts.input }, stdout = { ...opts.stdout }, stderr = { ...opts.stderr }; + const limit = opts.limit; + if (!(Number.isSafeInteger(limit) && limit > 0 && limit <= 4096 && Buffer.byteLength(input.text) <= limit)) return refuse(); + const held: HeldLeaf[] = []; + try { + for (const [leaf, flags] of [[input, constants.O_RDONLY], [stdout, constants.O_RDWR], [stderr, constants.O_RDWR]] as const) { + const file = await open(leaf.path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK); + held.push({ ...leaf, file }); + } + const source = held[0] ?? refuse(), out = held[1] ?? refuse(), err = held[2] ?? refuse(); + const assertFresh = () => { try { + for (const directory of directories) { + const info = fstatSync(directory.file.fd), named = lstatSync(directory.path); + if (!(info.isDirectory() && named.isDirectory() && sameFile(info, directory.info) && sameFile(info, named) && + info.uid === process.getuid?.() && named.uid === info.uid && (info.mode & 0o022) === 0 && + (!directory.private || (info.mode & 0o777) === 0o700))) return refuse(); + } + if (readLeaf(source, limit) !== input.text || checkLeaf(out, limit).size !== 0 || checkLeaf(err, limit).size !== 0) return refuse(); + } catch { return refuse(); } }; + assertFresh(); + return { + descriptors: { stdin: source.file.fd, stdout: out.file.fd, stderr: err.file.fd }, + assertFresh, + read: () => { try { return { stdout: readLeaf(out, limit), stderr: readLeaf(err, limit) }; } catch { return refuse(); } }, + close: async () => { await Promise.all(held.map((leaf) => leaf.file.close())); }, + retainUnsettled: () => { unsettledDescriptors.add(held); }, + }; + } catch { + await Promise.allSettled(held.map((leaf) => leaf.file.close())); + return refuse(); + } +} diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index 79776003..3f41d8aa 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -14,7 +14,6 @@ import { type HeldDirectory, holdDirectory, privateDirectory, - readPrivate, recheckDirectories, sameFile, writeExclusive, @@ -27,6 +26,7 @@ import { inspectNativeComposeStorageDockerVolume, observeNativeComposeStorageDockerTarget, } from "./native-compose-storage-witness-docker-inventory.ts"; +import { holdNativeComposeStorageDockerIo } from "./native-compose-storage-witness-docker-io.ts"; import { checkNativeComposeStorageDockerCarrier, NATIVE_STORAGE_CARRIER_FORMAT, @@ -169,38 +169,44 @@ async function groupAbsent(pid: number): Promise { } /** No callback can reject before the shared owner awaits exit. A rejected owner or * unproven group retains the journal; daemon effects are never inferred from exit. */ -async function command(opts: { - readonly context: Context; - readonly files: Files; +export async function runNativeComposeStorageDockerCommand(supplied: { + readonly context: Pick; + readonly files: Pick; readonly args: readonly string[]; readonly beforeSpawn: () => void; readonly assertAdmitted: () => Promise; readonly timeoutMs: number; readonly cleanup?: boolean; }): Promise<{ readonly exitCode: number; readonly stdout: string }> { + const opts = { ...supplied, context: { ...supplied.context }, args: [...supplied.args] }; const capture = randomBytes(16).toString("hex"); const stdout = join(opts.files.path, `${capture}.stdout`), stderr = join(opts.files.path, `${capture}.stderr`); const out = await writeExclusive(stdout, ""), err = await writeExclusive(stderr, ""); opts.files.captures.push({ path: stdout, info: out }, { path: stderr, info: err }); await opts.files.held.at(-1)?.file.sync(); - await opts.assertAdmitted(); - const timeoutMs = opts.cleanup ? opts.timeoutMs : Math.min(opts.timeoutMs, opts.context.deadline - Date.now()); - if (!(timeoutMs > 0)) return refuse(); - let pid = 0, exit: RunExitEvent | null = null; - // No awaited setup in run(): this noninteractive pipe uses AbortSignal rather - // than the terminal handoff. The async authority read immediately precedes it. - const code = await run(["/bin/sh", "-c", 'ulimit -f 8; input=$1; out=$2; err=$3; shift 3; exec "$@" < "$input" > "$out" 2> "$err"', "storage-witness-command", opts.files.input, stdout, stderr, ...opts.args], { - stdin: "ignore", stdout: "ignore", stderr: "ignore", timeoutMs, - signal: opts.cleanup ? undefined : opts.context.signal, - beforeSpawn: opts.beforeSpawn, - onSpawn: async (event) => { pid = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; }, - onExit: async (event) => { exit = event; }, - }); - await groupAbsent(pid); - if (!(exit && !(exit as RunExitEvent).timedOut && !(exit as RunExitEvent).cancelled)) return refuse(); - const output = await readPrivate(stdout, CAPTURE_LIMIT), errors = await readPrivate(stderr, CAPTURE_LIMIT); - if (!(sameFile(output.info, out) && sameFile(errors.info, err))) return refuse(); - return { exitCode: code, stdout: output.text }; + const io = await holdNativeComposeStorageDockerIo({ directories: opts.files.held, + input: { path: opts.files.input, info: opts.files.inputInfo, text: opts.files.requestText }, + stdout: { path: stdout, info: out }, stderr: { path: stderr, info: err }, limit: CAPTURE_LIMIT }); + let admitted = false, settled = false, pid = 0, exit: RunExitEvent | null = null; + try { + await opts.assertAdmitted(); + const timeoutMs = opts.cleanup ? opts.timeoutMs : Math.min(opts.timeoutMs, opts.context.deadline - Date.now()); + if (!(timeoutMs > 0)) return refuse(); + // Fixed FSIZE quota bounds both inherited capture files, including daemon + // stderr. Acceptance remains 4096 bytes; no child reopens a named file. + const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec "$@"', "storage-witness-command", ...opts.args], { + privateIo: io.descriptors, timeoutMs, signal: opts.cleanup ? undefined : opts.context.signal, + beforeSpawn: () => { io.assertFresh(); opts.beforeSpawn(); admitted = true; }, + onSpawn: async (event) => { pid = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; }, + onExit: async (event) => { exit = event; }, + }); + await groupAbsent(pid); + settled = true; + if (!(exit && !(exit as RunExitEvent).timedOut && !(exit as RunExitEvent).cancelled)) return refuse(); + return { exitCode: code, stdout: io.read().stdout }; + } finally { + if (!admitted || settled) await io.close(); else io.retainUnsettled(); + } } function matchesScope(input: NativeComposeStorageXattrInvocation, binding: NativeComposeMaterialBinding): boolean { return input.scope.generationId === binding.generationId && input.scope.currentGenerationId === binding.currentGenerationId && @@ -266,7 +272,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context tool(); await recheckDirectories(held); const latest = await current(context, selection, true); if (!same(before, latest)) return refuse(); - const result = await command({ context, files, + const result = await runNativeComposeStorageDockerCommand({ context, files, args: [docker, "volume", "create", "--driver", "local", "--label", `com.docker.compose.project=${selection.runtimeIdentity}`, "--label", `com.docker.compose.volume=${selection.storage}`, "--label", "io.hack.native-config.version=1", "--label", `io.hack.native-config.instance=${selection.runtimeIdentity}`, "--label", `io.hack.native-config.owner=${selection.ownerToken}`, @@ -292,7 +298,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context const read = probe(); await engine(read, context); await image(read); await checkFiles(files); tool(); const admitted = await current(context, input.target, !input.readonly); if (!same(before, admitted)) return refuse(); - const created = await command({ context, files, args: [docker, ...nativeComposeStorageDockerCreateArgs({ input, program: files.program })], + const created = await runNativeComposeStorageDockerCommand({ context, files, args: [docker, ...nativeComposeStorageDockerCreateArgs({ input, program: files.program })], timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, assertAdmitted: async () => { await engine(probe(), context); tool(); if (!same(before, await current(context, input.target, !input.readonly))) refuse(); } }); const id = created.stdout.trim(); @@ -318,7 +324,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context tool(); const final = await current(context, input.target, !input.readonly); if (!same(before, final)) return refuse(); - const result = await command({ context, files, args: [docker, "start", "-ai", id], timeoutMs: 15_000, + const result = await runNativeComposeStorageDockerCommand({ context, files, args: [docker, "start", "-ai", id], timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, assertAdmitted: async () => { const selected = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection: input.target, engineId: context.engineId, @@ -341,7 +347,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context await checkFiles(files); tool(); const cleanupAuthority = await current(context, input.target); if (!same(before, cleanupAuthority)) return refuse(); - const removed = await command({ context, files, cleanup: true, args: [docker, "rm", id], timeoutMs: 10_000, beforeSpawn: tool, + const removed = await runNativeComposeStorageDockerCommand({ context, files, cleanup: true, args: [docker, "rm", id], timeoutMs: 10_000, beforeSpawn: tool, assertAdmitted: async () => { const finalCarrier = await inspectCarrier(true); if (!(finalCarrier.createdAt === owned.createdAt && nativeComposeStorageDockerCarrierPolicy(finalCarrier) === owned.policy && same(finalCarrier.state, stopped.state))) refuse(); diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 0243eee3..83f1b5c5 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -86,6 +86,13 @@ export interface RunOptions { */ readonly stdout?: "inherit" | "stderr" | "ignore"; readonly stderr?: "inherit" | "ignore"; + /** Non-TTY owner-supplied descriptors. The caller holds them through settlement; + * run captures numbers synchronously and never reopens names or closes the FDs. */ + readonly privateIo?: { + readonly stdin: number; + readonly stdout: number; + readonly stderr: number; + }; readonly timeoutMs?: number; /** Forward cancellation to an owned command process group, preserving TTY input. */ readonly forwardSignals?: boolean; @@ -121,8 +128,14 @@ export async function run( ...opts, env: opts.env ? { ...opts.env } : undefined, unsetEnvKeys: opts.unsetEnvKeys ? [...opts.unsetEnvKeys] : undefined, + privateIo: opts.privateIo ? { ...opts.privateIo } : undefined, }; const command = [...cmd]; + if (options.privateIo && (options.forwardSignals === true || + !(typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) || + ![options.privateIo.stdin, options.privateIo.stdout, options.privateIo.stderr].every((fd) => Number.isInteger(fd) && fd >= 0 && fd <= 2_147_483_647))) { + throw new Error("Private subprocess descriptors require a non-TTY owned invocation."); + } const signal = options.signal; if (signal?.aborted) { return 143; @@ -158,9 +171,9 @@ export async function run( const proc = Bun.spawn(command, { cwd: options.cwd, env: buildSpawnEnv(options.env, options.unsetEnvKeys), - stdin: options.stdin ?? "inherit", - stdout: options.stdout === "stderr" ? 2 : (options.stdout ?? "inherit"), - stderr: options.stderr ?? "inherit", + stdin: options.privateIo?.stdin ?? options.stdin ?? "inherit", + stdout: options.privateIo?.stdout ?? (options.stdout === "stderr" ? 2 : (options.stdout ?? "inherit")), + stderr: options.privateIo?.stderr ?? options.stderr ?? "inherit", detached: ownsProcessGroup, }); const observeCpu = beginNativeCpuChild( diff --git a/tests/native-compose-storage-witness-docker-io.test.ts b/tests/native-compose-storage-witness-docker-io.test.ts new file mode 100644 index 00000000..ddaa0c28 --- /dev/null +++ b/tests/native-compose-storage-witness-docker-io.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test"; +import { fstatSync } from "node:fs"; +import { link, mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { holdDirectory, writeExclusive } from "../src/lib/native-compose-private-state.ts"; +import { holdNativeComposeStorageDockerIo } from "../src/lib/native-compose-storage-witness-docker-io.ts"; +import { runNativeComposeStorageDockerCommand } from "../src/lib/native-compose-storage-witness-docker.ts"; +import { run } from "../src/lib/shell.ts"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), "native-storage-held-io-"))); + roots.push(root); + const directory = await holdDirectory(root, true); + const input = join(root, "input"), stdout = join(root, "stdout"), stderr = join(root, "stderr"); + const text = "synthetic private input"; + const inputInfo = await writeExclusive(input, text), outInfo = await writeExclusive(stdout, ""), errInfo = await writeExclusive(stderr, ""); + const io = await holdNativeComposeStorageDockerIo({ directories: [directory], input: { path: input, info: inputInfo, text }, + stdout: { path: stdout, info: outInfo }, stderr: { path: stderr, info: errInfo }, limit: 4096 }); + return { root, directory, input, inputInfo, stdout, stderr, text, io }; +} +test("held IO passes exact stdin and preserves caller descriptors through completion", async () => { + const f = await fixture(); + try { + let exited = false; + const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec cat'], { privateIo: f.io.descriptors, timeoutMs: 3000, + beforeSpawn: f.io.assertFresh, + onExit: async () => { exited = true; expect(fstatSync(f.io.descriptors.stdout).isFile()).toBe(true); } }); + expect(code).toBe(0); expect(exited).toBe(true); + expect(f.io.read()).toEqual({ stdout: f.text, stderr: "" }); + expect(fstatSync(f.io.descriptors.stdin).isFile()).toBe(true); + } finally { await f.io.close(); await f.directory.file.close(); } +}); +test.each(["input-replace", "input-symlink", "stdout-replace", "stderr-replace", "input-hardlink", "input-change", "stdout-hardlink", "stdout-symlink"] as const)( + "last admission await %s refuses before spawn and preserves the ungranted file", async (failure) => { + const f = await fixture(); + const foreign = join(f.root, "ungranted"); + await writeFile(foreign, "preserved foreign bytes", { mode: 0o600 }); + let admitted = false; + const files = { held: [f.directory], path: f.root, input: f.input, inputInfo: f.inputInfo, requestText: f.text, + captures: [] as { path: string; info: typeof f.inputInfo }[] }; + try { + await expect(runNativeComposeStorageDockerCommand({ files, context: { signal: new AbortController().signal, deadline: Date.now() + 3000 }, + args: ["/bin/cat"], timeoutMs: 3000, beforeSpawn: () => { admitted = true; }, assertAdmitted: async () => { + const selected = failure.startsWith("input") ? f.input : files.captures[failure.startsWith("stdout") ? 0 : 1]?.path ?? ""; + if (failure.endsWith("replace")) { + await rename(selected, `${selected}.original`); await link(foreign, selected); + } else if (failure.endsWith("symlink")) { + await rename(selected, `${selected}.original`); await symlink(foreign, selected); + } else if (failure.endsWith("hardlink")) await link(selected, `${selected}.alias`); + else await writeFile(selected, "substituted input"); + } })).rejects.toThrow("values omitted"); + expect(admitted).toBe(false); + expect(await readFile(foreign, "utf8")).toBe("preserved foreign bytes"); + } finally { await f.io.close(); await f.directory.file.close(); } + }); +test("cancellation at the final admission leaves captures empty and spawns no child", async () => { + const f = await fixture(), controller = new AbortController(); + let spawned = false; + try { + const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec cat'], { privateIo: f.io.descriptors, timeoutMs: 3000, + signal: controller.signal, beforeSpawn: () => { f.io.assertFresh(); controller.abort(); }, + onSpawn: async () => { spawned = true; } }); + expect(code).toBe(143); expect(spawned).toBe(false); + expect(f.io.read()).toEqual({ stdout: "", stderr: "" }); + } finally { await f.io.close(); await f.directory.file.close(); } +}); +test("actual transport refuses an abort during its last admission await without child effects", async () => { + const f = await fixture(), controller = new AbortController(); + const files = { held: [f.directory], path: f.root, input: f.input, inputInfo: f.inputInfo, requestText: f.text, + captures: [] as { path: string; info: typeof f.inputInfo }[] }; + let admitted = false; + try { + await expect(runNativeComposeStorageDockerCommand({ files, context: { signal: controller.signal, deadline: Date.now() + 3000 }, + args: ["/bin/cat"], timeoutMs: 3000, beforeSpawn: () => { admitted = true; }, + assertAdmitted: async () => { await Promise.resolve(); controller.abort(); } })).rejects.toThrow("values omitted"); + expect(admitted).toBe(false); + for (const capture of files.captures) expect(await readFile(capture.path, "utf8")).toBe(""); + } finally { await f.io.close(); await f.directory.file.close(); } +}); +test("fixed wrapper quota bounds capture growth and oversized output cannot be accepted", async () => { + const f = await fixture(); + try { + const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec /bin/sh -c "while :; do printf 0123456789; done"'], { + privateIo: f.io.descriptors, timeoutMs: 3000, beforeSpawn: f.io.assertFresh }); + expect(code).not.toBe(0); expect(code).not.toBe(124); + expect(fstatSync(f.io.descriptors.stdout).size).toBeLessThanOrEqual(8192); + if (fstatSync(f.io.descriptors.stdout).size > 4096) expect(() => f.io.read()).toThrow("values omitted"); + } finally { await f.io.close(); await f.directory.file.close(); } +}); diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 0c5c4786..884290ff 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -12,7 +12,7 @@ import { NATIVE_STORAGE_DOCKER_ARTIFACT, nativeComposeStorageDockerHelper, } from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; -import { observeNativeComposeStorageDockerTarget } from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; +import { nativeComposeStorageDockerPathsOverlap, observeNativeComposeStorageDockerTarget } from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; import { checkNativeComposeStorageDockerCarrier, NATIVE_STORAGE_CARRIER_LABEL, @@ -53,7 +53,8 @@ async function withBinding(run: (value: NativeComposeMaterialBinding) => Promise }); } function inventory(binding: NativeComposeMaterialBinding) { - const state = { present: true, drift: false, foreign: false, running: false, mounts: true, version: "1", volumeReads: 0 }; + const state = { present: true, drift: false, foreign: false, running: false, mounts: true, version: "1", volumeReads: 0, + mountType: "volume", mountName: selection.name, mountSource: `/var/lib/docker/volumes/${selection.name}/_data` }; const id = "c".repeat(64); const mutableCalls: string[][] = []; const probe = async (args: readonly string[]): Promise => { @@ -68,7 +69,7 @@ function inventory(binding: NativeComposeMaterialBinding) { } if (args[0] === "container" && args[1] === "ls") return state.mounts ? JSON.stringify(id) : ""; if (args[0] === "container" && args[1] === "inspect") return JSON.stringify({ id, createdAt, - mounts: [{ Type: "volume", Name: selection.name, Source: `/var/lib/docker/volumes/${selection.name}/_data`, Destination: "/data", RW: true }], + mounts: [{ Type: state.mountType, Name: state.mountName, Source: state.mountSource, Destination: "/data", RW: true }], running: state.running, project: binding.identity.composeProject, instance: binding.identity.composeProject, owner: state.foreign ? "f".repeat(32) : binding.identity.ownerToken, version: state.version, generation: binding.generationId, carrier: null, carrierOwner: null, carrierGeneration: null }); @@ -103,6 +104,26 @@ test("genuinely absent cold storage needs an empty complete holder selection", a await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow(); }); }); +test.each([ + "/var/lib/docker/volumes", "/var/lib/docker/volumes/owned_data/", "/var/lib/docker/volumes/owned_data/_data/", + "/var/lib/docker/volumes/owned_data/_data/child", "/var/lib/docker/volumes/other/../owned_data/_data", +] as const)("foreign overlapping bind %s refuses rather than disappearing from holder selection", async (source) => { + await withBinding(async (current) => { + const fake = inventory(current); + fake.state.foreign = true; fake.state.mountType = "bind"; fake.state.mountName = ""; fake.state.mountSource = source; + await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow("values omitted"); + expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm"))).toBe(true); + }); +}); +test("complete holder inventory keeps a similarly named foreign sibling independent", async () => { + await withBinding(async (current) => { + const fake = inventory(current); + fake.state.foreign = true; fake.state.mountType = "bind"; fake.state.mountName = ""; + fake.state.mountSource = "/var/lib/docker/volumes/owned_data_other/_data"; + expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).holders).toEqual([]); + }); + expect(nativeComposeStorageDockerPathsOverlap("relative/path", "/var/lib/docker/volumes/owned_data/_data")).toBe(false); +}); function input(): NativeComposeStorageXattrInvocation { return { recordCreated: async () => {}, invocationId: "a".repeat(32), artifact: NATIVE_STORAGE_DOCKER_ARTIFACT, target: { ...selection, engineId, runtimeIdentity: "owned", ownerToken: "b".repeat(32), volume: { ...selection, createdAt }, diff --git a/tests/shell-private-io.test.ts b/tests/shell-private-io.test.ts new file mode 100644 index 00000000..694dabdb --- /dev/null +++ b/tests/shell-private-io.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { constants, fstatSync } from "node:fs"; +import { mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "../src/lib/shell.ts"; + +test("private descriptor capture precedes spawn admission and run does not close caller FDs", async () => { + const root = await mkdtemp(join(tmpdir(), "shell-held-io-")); + const inputPath = join(root, "input"), outPath = join(root, "output"), errPath = join(root, "error"), otherPath = join(root, "other"); + await Promise.all([writeFile(inputPath, "captured input"), writeFile(outPath, ""), writeFile(errPath, ""), writeFile(otherPath, "")]); + const input = await open(inputPath, constants.O_RDONLY), output = await open(outPath, constants.O_RDWR), error = await open(errPath, constants.O_RDWR), other = await open(otherPath, constants.O_RDWR); + const privateIo = { stdin: input.fd, stdout: output.fd, stderr: error.fd }; + try { + expect(await run(["/bin/cat"], { privateIo, timeoutMs: 3000, beforeSpawn: () => { privateIo.stdout = other.fd; } })).toBe(0); + expect(await readFile(outPath, "utf8")).toBe("captured input"); + expect(await readFile(otherPath, "utf8")).toBe(""); + expect(fstatSync(input.fd).isFile()).toBe(true); expect(fstatSync(output.fd).isFile()).toBe(true); + } finally { await Promise.all([input.close(), output.close(), error.close(), other.close()]); await rm(root, { recursive: true, force: true }); } +}); +test.each(["negative", "fractional", "unbounded", "tty"] as const)("private IO %s refuses before admission", async (failure) => { + let admitted = false; + await expect(run(["/bin/cat"], { privateIo: { stdin: failure === "negative" ? -1 : failure === "fractional" ? 1.5 : 0, stdout: 1, stderr: 2 }, + timeoutMs: failure === "unbounded" ? undefined : 3000, forwardSignals: failure === "tty", beforeSpawn: () => { admitted = true; } })).rejects.toThrow("Private subprocess descriptors"); + expect(admitted).toBe(false); +}); From ea46a6f2a2b66355318227d1945110626cfef262 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:39:00 -0400 Subject: [PATCH 16/23] fix: create bounded empty storage command leaves --- .../native-compose-storage-witness-docker-io.ts | 11 +++++++++++ src/lib/native-compose-storage-witness-docker.ts | 6 +++--- ...ative-compose-storage-witness-docker-io.test.ts | 14 ++++++++++++-- .../native-compose-storage-witness-docker.test.ts | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/lib/native-compose-storage-witness-docker-io.ts b/src/lib/native-compose-storage-witness-docker-io.ts index eac2c6cf..9da9502c 100644 --- a/src/lib/native-compose-storage-witness-docker-io.ts +++ b/src/lib/native-compose-storage-witness-docker-io.ts @@ -29,6 +29,17 @@ function readLeaf(leaf: HeldLeaf, limit: number): string { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count)); } +/** Empty command leaves are not receipts: the shared receipt writer deliberately + * requires nonempty bytes. This separate create-only leaf keeps that invariant. */ +export async function createNativeComposeStorageDockerEmptyLeaf(path: string): Promise { + const file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { + await file.sync(); + const info = await file.stat(); + return checkLeaf({ path, info, file }, 0); + } catch { return refuse(); } finally { await file.close(); } +} + /** Holds exact no-follow leaves through shared run() and owned group settlement. * Child writes are separately quota-limited by the fixed shell wrapper; reads * accept at most limit bytes. No named path is reopened or truncated by the child. */ diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index 3f41d8aa..c1c805d1 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -26,7 +26,7 @@ import { inspectNativeComposeStorageDockerVolume, observeNativeComposeStorageDockerTarget, } from "./native-compose-storage-witness-docker-inventory.ts"; -import { holdNativeComposeStorageDockerIo } from "./native-compose-storage-witness-docker-io.ts"; +import { createNativeComposeStorageDockerEmptyLeaf, holdNativeComposeStorageDockerIo } from "./native-compose-storage-witness-docker-io.ts"; import { checkNativeComposeStorageDockerCarrier, NATIVE_STORAGE_CARRIER_FORMAT, @@ -181,7 +181,7 @@ export async function runNativeComposeStorageDockerCommand(supplied: { const opts = { ...supplied, context: { ...supplied.context }, args: [...supplied.args] }; const capture = randomBytes(16).toString("hex"); const stdout = join(opts.files.path, `${capture}.stdout`), stderr = join(opts.files.path, `${capture}.stderr`); - const out = await writeExclusive(stdout, ""), err = await writeExclusive(stderr, ""); + const out = await createNativeComposeStorageDockerEmptyLeaf(stdout), err = await createNativeComposeStorageDockerEmptyLeaf(stderr); opts.files.captures.push({ path: stdout, info: out }, { path: stderr, info: err }); await opts.files.held.at(-1)?.file.sync(); const io = await holdNativeComposeStorageDockerIo({ directories: opts.files.held, @@ -263,7 +263,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context const path = join(root.path, nonce); await mkdir(path, { mode: 0o700 }); await root.file.sync(); const leaf = await holdDirectory(path, true); held.push(leaf); const request = join(path, "request.json"), program = join(path, "intent.json"); - const inputInfo = await writeExclusive(request, ""); + const inputInfo = await createNativeComposeStorageDockerEmptyLeaf(request); const programInfo = await writeExclusive(program, JSON.stringify({ ...selection, nonce })); await leaf.file.sync(); files = { held, path, input: request, program, programInfo, inputInfo, requestText: "", captures: [] }; const read = probe(); await engine(read, context); diff --git a/tests/native-compose-storage-witness-docker-io.test.ts b/tests/native-compose-storage-witness-docker-io.test.ts index ddaa0c28..6bf9f698 100644 --- a/tests/native-compose-storage-witness-docker-io.test.ts +++ b/tests/native-compose-storage-witness-docker-io.test.ts @@ -4,7 +4,7 @@ import { link, mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } fro import { tmpdir } from "node:os"; import { join } from "node:path"; import { holdDirectory, writeExclusive } from "../src/lib/native-compose-private-state.ts"; -import { holdNativeComposeStorageDockerIo } from "../src/lib/native-compose-storage-witness-docker-io.ts"; +import { createNativeComposeStorageDockerEmptyLeaf, holdNativeComposeStorageDockerIo } from "../src/lib/native-compose-storage-witness-docker-io.ts"; import { runNativeComposeStorageDockerCommand } from "../src/lib/native-compose-storage-witness-docker.ts"; import { run } from "../src/lib/shell.ts"; @@ -16,7 +16,7 @@ async function fixture() { const directory = await holdDirectory(root, true); const input = join(root, "input"), stdout = join(root, "stdout"), stderr = join(root, "stderr"); const text = "synthetic private input"; - const inputInfo = await writeExclusive(input, text), outInfo = await writeExclusive(stdout, ""), errInfo = await writeExclusive(stderr, ""); + const inputInfo = await writeExclusive(input, text), outInfo = await createNativeComposeStorageDockerEmptyLeaf(stdout), errInfo = await createNativeComposeStorageDockerEmptyLeaf(stderr); const io = await holdNativeComposeStorageDockerIo({ directories: [directory], input: { path: input, info: inputInfo, text }, stdout: { path: stdout, info: outInfo }, stderr: { path: stderr, info: errInfo }, limit: 4096 }); return { root, directory, input, inputInfo, stdout, stderr, text, io }; @@ -33,6 +33,16 @@ test("held IO passes exact stdin and preserves caller descriptors through comple expect(fstatSync(f.io.descriptors.stdin).isFile()).toBe(true); } finally { await f.io.close(); await f.directory.file.close(); } }); +test("separate empty command leaf never overwrites existing bytes or follows a symlink", async () => { + const f = await fixture(); + try { + await expect(createNativeComposeStorageDockerEmptyLeaf(f.input)).rejects.toThrow(); + const alias = join(f.root, "alias"); await symlink(f.input, alias); + await expect(createNativeComposeStorageDockerEmptyLeaf(alias)).rejects.toThrow(); + expect(await readFile(f.input, "utf8")).toBe(f.text); + await expect(writeExclusive(join(f.root, "receipt-empty"), "")).rejects.toThrow("values omitted"); + } finally { await f.io.close(); await f.directory.file.close(); } +}); test.each(["input-replace", "input-symlink", "stdout-replace", "stderr-replace", "input-hardlink", "input-change", "stdout-hardlink", "stdout-symlink"] as const)( "last admission await %s refuses before spawn and preserves the ungranted file", async (failure) => { const f = await fixture(); diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 884290ff..57dea246 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -45,7 +45,7 @@ async function withBinding(run: (value: NativeComposeMaterialBinding) => Promise services: { app: { image: "synthetic:1", labels: { ...labels, "io.hack.native-config.generation": reservation.generationId, "io.hack.native-config.workload": "service" } } }, volumes: { data: { name: selection.name, labels: { ...labels, "io.hack.native-config.storage": selection.storage } } }, }) }); - await mutation.runEffect({ generation, operation: "up", assertOwned: async () => {}, + await mutation.runEffect({ generation, operation: "up", assertOwned: async () => {}, assertFresh: async () => {}, effect: async () => { await run(await assertNativeComposeMaterialAuthority({ authority: mutation.materialAuthority, generation, phase: "effect" })); return { outcome: "complete", value: 0 }; From 44e071b15d1d774e88975419d7569c1e318c57be Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 17:46:12 -0400 Subject: [PATCH 17/23] chore: qualify storage carrier source and formatting --- .../generate-native-storage-witness-helper.ts | 79 +- ...compose-storage-witness-docker-artifact.ts | 31 +- ...ompose-storage-witness-docker-inventory.ts | 248 ++++- ...ative-compose-storage-witness-docker-io.ts | 154 ++- ...e-compose-storage-witness-docker-policy.ts | 226 ++++- .../native-compose-storage-witness-docker.ts | 903 +++++++++++++++--- ...e-compose-storage-witness-helper-bundle.ts | 4 +- src/lib/shell.ts | 25 +- ...-compose-storage-witness-docker-io.test.ts | 262 +++-- ...ive-compose-storage-witness-docker.test.ts | 502 ++++++++-- tests/shell-private-io.test.ts | 63 +- 11 files changed, 2035 insertions(+), 462 deletions(-) diff --git a/scripts/generate-native-storage-witness-helper.ts b/scripts/generate-native-storage-witness-helper.ts index b4bea64e..da464da1 100644 --- a/scripts/generate-native-storage-witness-helper.ts +++ b/scripts/generate-native-storage-witness-helper.ts @@ -9,30 +9,81 @@ import { run } from "../src/lib/shell.ts"; /** Explicit maintenance build only; never execute the helper or update its approved * artifact pin automatically. Changed bytes require separate runtime qualification. */ const root = await mkdtemp(join(tmpdir(), "hack-storage-helper-build-")); -let group = 0, settled = false, started = false; +let group = 0, + settled = false, + started = false; try { - if (Bun.version !== "1.4.2" || !["--check", "--write"].includes(process.argv[2] ?? "")) throw new Error("Pinned helper generation refused"); + if ( + Bun.version !== "1.4.2" || + !["--check", "--write"].includes(process.argv[2] ?? "") + ) { + throw new Error("Pinned helper generation refused"); + } const output = join(root, "helper.mjs"); started = true; - const exit = await run([process.execPath, "build", "scripts/native-storage-witness-helper.ts", "--target=bun", "--outfile", output], - { stdin: "ignore", timeoutMs: 60_000, stdout: "ignore", stderr: "ignore", - onSpawn: async (event) => { group = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; } }); + const exit = await run( + [ + process.execPath, + "build", + "scripts/native-storage-witness-helper.ts", + "--target=bun", + "--outfile", + output, + ], + { + stdin: "ignore", + timeoutMs: 60_000, + stdout: "ignore", + stderr: "ignore", + onSpawn: (event) => { + group = event.ownsProcessGroup + ? (event.processGroupId ?? event.pid) + : 0; + return Promise.resolve(); + }, + } + ); const deadline = Date.now() + 3000; while (group > 1 && Date.now() <= deadline) { - try { process.kill(-group, 0); } catch (error: unknown) { - if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") { settled = true; break; } + try { + process.kill(-group, 0); + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) { + settled = true; + break; + } throw new Error("Helper build disposition uncertain"); } await new Promise((resolve) => setTimeout(resolve, 20)); } - if (!settled) throw new Error("Helper build disposition uncertain"); - if (exit !== 0) throw new Error("Pinned helper build refused"); + if (!settled) { + throw new Error("Helper build disposition uncertain"); + } + if (exit !== 0) { + throw new Error("Pinned helper build refused"); + } const bytes = await readFile(output); - if (bytes.length > 128 * 1024 || createHash("sha256").update(bytes).digest("hex") !== NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash) throw new Error("Helper artifact changed; separate qualification required"); + if ( + bytes.length > 128 * 1024 || + createHash("sha256").update(bytes).digest("hex") !== + NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash + ) { + throw new Error("Helper artifact changed; separate qualification required"); + } const path = "src/lib/native-compose-storage-witness-helper-bundle.ts"; - const source = `// Generated from scripts/native-storage-witness-helper.ts. Do not edit the payload.\nexport const NATIVE_STORAGE_WITNESS_HELPER = ${JSON.stringify(bytes.toString("utf8"))};\n`; - if (process.argv[2] === "--write") await writeFile(path, source); - else if (NATIVE_STORAGE_WITNESS_HELPER !== bytes.toString("utf8")) throw new Error("Generated helper source differs"); + const source = `// Generated from scripts/native-storage-witness-helper.ts. Do not edit the payload.\nexport const NATIVE_STORAGE_WITNESS_HELPER =\n // biome-ignore lint/suspicious/noTemplateCurlyInString: Preserve qualified helper bytes without host interpolation.\n ${JSON.stringify(bytes.toString("utf8"))};\n`; + if (process.argv[2] === "--write") { + await writeFile(path, source); + } else if (NATIVE_STORAGE_WITNESS_HELPER !== bytes.toString("utf8")) { + throw new Error("Generated helper source differs"); + } } finally { - if (settled || !started) await rm(root, { recursive: true, force: true }); + if (settled || !started) { + await rm(root, { recursive: true, force: true }); + } } diff --git a/src/lib/native-compose-storage-witness-docker-artifact.ts b/src/lib/native-compose-storage-witness-docker-artifact.ts index 19770ece..4e6418b2 100644 --- a/src/lib/native-compose-storage-witness-docker-artifact.ts +++ b/src/lib/native-compose-storage-witness-docker-artifact.ts @@ -5,17 +5,26 @@ import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-stor /** Explicit cached dependency; the owner never pulls or builds this image. Other * platforms require their own artifact/ABI/volume qualification before admission. */ -export const NATIVE_STORAGE_DOCKER_ARTIFACT: NativeComposeStorageXattrArtifact = Object.freeze({ - version: 1, - imageId: "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", - platform: "linux/arm64", - bunVersion: "1.4.2", - bunHash: "616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1", - libcHash: "9a325944115b36e74077786b29531963d96cd55cd6c82f11750a352cdb58fab0", - helperHash: "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", - kernelAbi: 1, -}); +export const NATIVE_STORAGE_DOCKER_ARTIFACT: NativeComposeStorageXattrArtifact = + Object.freeze({ + version: 1, + imageId: + "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + platform: "linux/arm64", + bunVersion: "1.4.2", + bunHash: "616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1", + libcHash: + "9a325944115b36e74077786b29531963d96cd55cd6c82f11750a352cdb58fab0", + helperHash: + "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", + kernelAbi: 1, + }); export function nativeComposeStorageDockerHelper(): string { - if (createHash("sha256").update(NATIVE_STORAGE_WITNESS_HELPER).digest("hex") !== NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash) return refuse(); + if ( + createHash("sha256").update(NATIVE_STORAGE_WITNESS_HELPER).digest("hex") !== + NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash + ) { + return refuse(); + } return NATIVE_STORAGE_WITNESS_HELPER; } diff --git a/src/lib/native-compose-storage-witness-docker-inventory.ts b/src/lib/native-compose-storage-witness-docker-inventory.ts index 3ef53c02..14b4a089 100644 --- a/src/lib/native-compose-storage-witness-docker-inventory.ts +++ b/src/lib/native-compose-storage-witness-docker-inventory.ts @@ -1,7 +1,7 @@ -import { isRecord } from "./guards.ts"; import { posix } from "node:path"; +import { isRecord } from "./guards.ts"; import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; -import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import type { createNativeComposeProbe } from "./native-compose-ownership.ts"; import { nativeComposeVolumeCreatedAt } from "./native-compose-retained-storage.ts"; import { checkNativeComposeStorageXattrTarget, @@ -12,39 +12,79 @@ import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-stor const ID = /^[a-f0-9]{64}$/; const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; const PREFIX = "io.hack.native-config"; +const TRAILING_SEPARATOR = /\/+$/; const VOLUME = `{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver":{{json .Driver}},"options":{{json .Options}},"mountpoint":{{json .Mountpoint}},"project":{{json (index .Labels "com.docker.compose.project")}},"instance":{{json (index .Labels "${PREFIX}.instance")}},"owner":{{json (index .Labels "${PREFIX}.owner")}},"version":{{json (index .Labels "${PREFIX}.version")}},"storage":{{json (index .Labels "${PREFIX}.storage")}},"provision":{{json (index .Labels "${PREFIX}.storage-provision")}}}`; const HOLDER = `{"id":{{json .Id}},"createdAt":{{json .Created}},"mounts":{{json .Mounts}},"running":{{json .State.Running}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"instance":{{json (index .Config.Labels "${PREFIX}.instance")}},"owner":{{json (index .Config.Labels "${PREFIX}.owner")}},"version":{{json (index .Config.Labels "${PREFIX}.version")}},"generation":{{json (index .Config.Labels "${PREFIX}.generation")}},"carrier":{{json (index .Config.Labels "io.hack.storage-witness.carrier")}},"carrierOwner":{{json (index .Config.Labels "io.hack.storage-witness.owner")}},"carrierGeneration":{{json (index .Config.Labels "io.hack.storage-witness.generation")}}}`; type Probe = ReturnType; type Selection = { readonly name: string; readonly storage: string }; /** A parent or child bind exposes the selected root too. Normalize complete paths, * then compare separator boundaries so a similarly named sibling stays independent. */ -export function nativeComposeStorageDockerPathsOverlap(left: string, right: string): boolean { - if (!(posix.isAbsolute(left) && posix.isAbsolute(right))) return false; - const a = posix.normalize(left).replace(/\/+$/, "") || "/"; - const b = posix.normalize(right).replace(/\/+$/, "") || "/"; - return a === b || a === "/" || b === "/" || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); +export function nativeComposeStorageDockerPathsOverlap( + left: string, + right: string +): boolean { + if (!(posix.isAbsolute(left) && posix.isAbsolute(right))) { + return false; + } + const a = posix.normalize(left).replace(TRAILING_SEPARATOR, "") || "/"; + const b = posix.normalize(right).replace(TRAILING_SEPARATOR, "") || "/"; + return ( + a === b || + a === "/" || + b === "/" || + a.startsWith(`${b}/`) || + b.startsWith(`${a}/`) + ); } function rows(text: string): Record[] { - if (text.trim() === "") return []; - return text.trim().split("\n").map((line) => { - const value: unknown = JSON.parse(line); - return isRecord(value) ? value : refuse(); - }); + if (text.trim() === "") { + return []; + } + return text + .trim() + .split("\n") + .map((line) => { + const value: unknown = JSON.parse(line); + return isRecord(value) ? value : refuse(); + }); } function strings(text: string, pattern: RegExp): string[] { - const result = text.trim() === "" ? [] : text.trim().split("\n").map((line) => { - const value: unknown = JSON.parse(line); - return typeof value === "string" && pattern.test(value) ? value : refuse(); - }); - if (new Set(result).size !== result.length) return refuse(); + const result = + text.trim() === "" + ? [] + : text + .trim() + .split("\n") + .map((line) => { + const value: unknown = JSON.parse(line); + return typeof value === "string" && pattern.test(value) + ? value + : refuse(); + }); + if (new Set(result).size !== result.length) { + return refuse(); + } return result.sort(); } async function volumes(probe: Probe): Promise { - return strings(await probe(["volume", "ls", "--format", "{{json .Name}}"]), NAME); + return strings( + await probe(["volume", "ls", "--format", "{{json .Name}}"]), + NAME + ); } async function containers(probe: Probe): Promise { - return strings(await probe(["container", "ls", "-a", "--no-trunc", "--format", "{{json .ID}}"]), ID); + return strings( + await probe([ + "container", + "ls", + "-a", + "--no-trunc", + "--format", + "{{json .ID}}", + ]), + ID + ); } export async function inspectNativeComposeStorageDockerVolume(opts: { readonly probe: Probe; @@ -52,15 +92,33 @@ export async function inspectNativeComposeStorageDockerVolume(opts: { readonly current: NativeComposeMaterialBinding; }): Promise> { const { probe, selection, current } = opts; - if (!NAME.test(selection.name)) return refuse(); - const values = rows(await probe(["volume", "inspect", "--format", VOLUME, selection.name])); - if (values.length !== 1) return refuse(); + if (!NAME.test(selection.name)) { + return refuse(); + } + const values = rows( + await probe(["volume", "inspect", "--format", VOLUME, selection.name]) + ); + if (values.length !== 1) { + return refuse(); + } const value = values[0] ?? refuse(); - if (!(value.name === selection.name && nativeComposeVolumeCreatedAt(value.createdAt) && - value.project === current.identity.composeProject && value.instance === current.identity.composeProject && - value.owner === current.identity.ownerToken && value.version === "1" && value.storage === selection.storage && - value.driver === "local" && value.mountpoint === `/var/lib/docker/volumes/${selection.name}/_data` && - (value.options === null || (isRecord(value.options) && Object.keys(value.options).length === 0)))) return refuse(); + if ( + !( + value.name === selection.name && + nativeComposeVolumeCreatedAt(value.createdAt) && + value.project === current.identity.composeProject && + value.instance === current.identity.composeProject && + value.owner === current.identity.ownerToken && + value.version === "1" && + value.storage === selection.storage && + value.driver === "local" && + value.mountpoint === `/var/lib/docker/volumes/${selection.name}/_data` && + (value.options === null || + (isRecord(value.options) && Object.keys(value.options).length === 0)) + ) + ) { + return refuse(); + } return value; } @@ -73,48 +131,134 @@ export async function observeNativeComposeStorageDockerTarget(opts: { readonly engineId: string; readonly stopped: boolean; /** Only the separately inspected exact created helper may be excluded. */ - readonly carrier?: { readonly id: string; readonly createdAt: string; readonly invocationId: string }; + readonly carrier?: { + readonly id: string; + readonly createdAt: string; + readonly invocationId: string; + }; }): Promise { const { probe, selection, current, engineId, stopped } = opts; - if (!NAME.test(selection.name)) return refuse(); - if (JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId) return refuse(); + if (!NAME.test(selection.name)) { + return refuse(); + } + if ( + JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId + ) { + return refuse(); + } const beforeVolumes = await volumes(probe); - const volume = beforeVolumes.includes(selection.name) ? await inspectNativeComposeStorageDockerVolume(opts) : null; + const volume = beforeVolumes.includes(selection.name) + ? await inspectNativeComposeStorageDockerVolume(opts) + : null; const beforeContainers = await containers(probe); const holders: Record[] = []; for (let offset = 0; offset < beforeContainers.length; offset += 128) { const batch = beforeContainers.slice(offset, offset + 128); - const values = rows(await probe(["container", "inspect", "--format", HOLDER, ...batch])); - if (values.length !== batch.length || new Set(values.map((value) => value.id)).size !== batch.length) return refuse(); + const values = rows( + await probe(["container", "inspect", "--format", HOLDER, ...batch]) + ); + if ( + values.length !== batch.length || + new Set(values.map((value) => value.id)).size !== batch.length + ) { + return refuse(); + } for (const value of values) { - if (!(typeof value.id === "string" && batch.includes(value.id) && Array.isArray(value.mounts))) return refuse(); + if ( + !( + typeof value.id === "string" && + batch.includes(value.id) && + Array.isArray(value.mounts) + ) + ) { + return refuse(); + } let selected = false; for (const mount of value.mounts) { - if (!(isRecord(mount) && typeof mount.Type === "string" && typeof mount.Source === "string" && typeof mount.Destination === "string" && typeof mount.RW === "boolean")) return refuse(); - if ((mount.Type === "volume" && mount.Name === selection.name) || - nativeComposeStorageDockerPathsOverlap(mount.Source, `/var/lib/docker/volumes/${selection.name}/_data`)) selected = true; + if ( + !( + isRecord(mount) && + typeof mount.Type === "string" && + typeof mount.Source === "string" && + typeof mount.Destination === "string" && + typeof mount.RW === "boolean" + ) + ) { + return refuse(); + } + if ( + (mount.Type === "volume" && mount.Name === selection.name) || + nativeComposeStorageDockerPathsOverlap( + mount.Source, + `/var/lib/docker/volumes/${selection.name}/_data` + ) + ) { + selected = true; + } + } + if (!selected) { + continue; } - if (!selected) continue; if (opts.carrier && value.id === opts.carrier.id) { - if (!(value.createdAt === opts.carrier.createdAt && value.carrier === opts.carrier.invocationId && - value.carrierOwner === current.identity.ownerToken && value.carrierGeneration === current.generationId && value.running === false)) return refuse(); + if ( + !( + value.createdAt === opts.carrier.createdAt && + value.carrier === opts.carrier.invocationId && + value.carrierOwner === current.identity.ownerToken && + value.carrierGeneration === current.generationId && + value.running === false + ) + ) { + return refuse(); + } continue; } - if (!(value.project === current.identity.composeProject && value.instance === current.identity.composeProject && - value.owner === current.identity.ownerToken && value.version === "1")) return refuse(); - holders.push({ id: value.id, runtimeIdentity: value.project, ownerToken: value.owner, generationId: value.generation, running: value.running }); + if ( + !( + value.project === current.identity.composeProject && + value.instance === current.identity.composeProject && + value.owner === current.identity.ownerToken && + value.version === "1" + ) + ) { + return refuse(); + } + holders.push({ + id: value.id, + runtimeIdentity: value.project, + ownerToken: value.owner, + generationId: value.generation, + running: value.running, + }); } } - const repeated = volume ? await inspectNativeComposeStorageDockerVolume(opts) : null; - if (JSON.stringify(repeated) !== JSON.stringify(volume) || + const repeated = volume + ? await inspectNativeComposeStorageDockerVolume(opts) + : null; + if ( + JSON.stringify(repeated) !== JSON.stringify(volume) || JSON.stringify(await volumes(probe)) !== JSON.stringify(beforeVolumes) || - JSON.stringify(await containers(probe)) !== JSON.stringify(beforeContainers) || - JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId) return refuse(); + JSON.stringify(await containers(probe)) !== + JSON.stringify(beforeContainers) || + JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId + ) { + return refuse(); + } return checkNativeComposeStorageXattrTarget({ - current, engineId, selection, stopped, - value: { engineId, runtimeIdentity: current.identity.composeProject, ownerToken: current.identity.ownerToken, - ...selection, volume: volume ? { ...selection, createdAt: volume.createdAt } : null, - mountpoint: volume?.mountpoint ?? null, driver: volume ? "local" : null, - options: volume ? {} : null, holders }, + current, + engineId, + selection, + stopped, + value: { + engineId, + runtimeIdentity: current.identity.composeProject, + ownerToken: current.identity.ownerToken, + ...selection, + volume: volume ? { ...selection, createdAt: volume.createdAt } : null, + mountpoint: volume?.mountpoint ?? null, + driver: volume ? "local" : null, + options: volume ? {} : null, + holders, + }, }); } diff --git a/src/lib/native-compose-storage-witness-docker-io.ts b/src/lib/native-compose-storage-witness-docker-io.ts index 9da9502c..5fedbbda 100644 --- a/src/lib/native-compose-storage-witness-docker-io.ts +++ b/src/lib/native-compose-storage-witness-docker-io.ts @@ -14,30 +14,72 @@ const unsettledDescriptors = new Set(); function checkLeaf(leaf: HeldLeaf, limit: number): Stats { try { - const info = fstatSync(leaf.file.fd), named = lstatSync(leaf.path); - if (!(info.isFile() && named.isFile() && sameFile(info, leaf.info) && sameFile(info, named) && - info.nlink === 1 && named.nlink === 1 && info.uid === process.getuid?.() && named.uid === info.uid && - (info.mode & 0o777) === 0o600 && (named.mode & 0o777) === 0o600 && info.size <= limit)) return refuse(); + const info = fstatSync(leaf.file.fd), + named = lstatSync(leaf.path); + if ( + !( + info.isFile() && + named.isFile() && + sameFile(info, leaf.info) && + sameFile(info, named) && + info.nlink === 1 && + named.nlink === 1 && + info.uid === process.getuid?.() && + named.uid === info.uid && + (info.mode & 0o777) === 0o600 && + (named.mode & 0o777) === 0o600 && + info.size <= limit + ) + ) { + return refuse(); + } return info; - } catch { return refuse(); } + } catch { + return refuse(); + } } function readLeaf(leaf: HeldLeaf, limit: number): string { - const before = checkLeaf(leaf, limit), bytes = Buffer.alloc(before.size + 1); + const before = checkLeaf(leaf, limit), + bytes = Buffer.alloc(before.size + 1); const count = readSync(leaf.file.fd, bytes, 0, bytes.length, 0); const after = checkLeaf(leaf, limit); - if (!(count === before.size && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs)) return refuse(); - return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count)); + if ( + !( + count === before.size && + before.size === after.size && + before.mtimeMs === after.mtimeMs && + before.ctimeMs === after.ctimeMs + ) + ) { + return refuse(); + } + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes.subarray(0, count) + ); } /** Empty command leaves are not receipts: the shared receipt writer deliberately * requires nonempty bytes. This separate create-only leaf keeps that invariant. */ -export async function createNativeComposeStorageDockerEmptyLeaf(path: string): Promise { - const file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); +export async function createNativeComposeStorageDockerEmptyLeaf( + path: string +): Promise { + const file = await open( + path, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + constants.O_NOFOLLOW, + 0o600 + ); try { await file.sync(); const info = await file.stat(); return checkLeaf({ path, info, file }, 0); - } catch { return refuse(); } finally { await file.close(); } + } catch { + return refuse(); + } finally { + await file.close(); + } } /** Holds exact no-follow leaves through shared run() and owned group settlement. @@ -51,32 +93,88 @@ export async function holdNativeComposeStorageDockerIo(opts: { readonly limit: number; }) { const directories = [...opts.directories]; - const input = { ...opts.input }, stdout = { ...opts.stdout }, stderr = { ...opts.stderr }; + const input = { ...opts.input }, + stdout = { ...opts.stdout }, + stderr = { ...opts.stderr }; const limit = opts.limit; - if (!(Number.isSafeInteger(limit) && limit > 0 && limit <= 4096 && Buffer.byteLength(input.text) <= limit)) return refuse(); + if ( + !( + Number.isSafeInteger(limit) && + limit > 0 && + limit <= 4096 && + Buffer.byteLength(input.text) <= limit + ) + ) { + return refuse(); + } const held: HeldLeaf[] = []; try { - for (const [leaf, flags] of [[input, constants.O_RDONLY], [stdout, constants.O_RDWR], [stderr, constants.O_RDWR]] as const) { - const file = await open(leaf.path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK); + for (const [leaf, flags] of [ + [input, constants.O_RDONLY], + [stdout, constants.O_RDWR], + [stderr, constants.O_RDWR], + ] as const) { + const file = await open( + leaf.path, + flags | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); held.push({ ...leaf, file }); } - const source = held[0] ?? refuse(), out = held[1] ?? refuse(), err = held[2] ?? refuse(); - const assertFresh = () => { try { - for (const directory of directories) { - const info = fstatSync(directory.file.fd), named = lstatSync(directory.path); - if (!(info.isDirectory() && named.isDirectory() && sameFile(info, directory.info) && sameFile(info, named) && - info.uid === process.getuid?.() && named.uid === info.uid && (info.mode & 0o022) === 0 && - (!directory.private || (info.mode & 0o777) === 0o700))) return refuse(); + const source = held[0] ?? refuse(), + out = held[1] ?? refuse(), + err = held[2] ?? refuse(); + const assertFresh = () => { + try { + for (const directory of directories) { + const info = fstatSync(directory.file.fd), + named = lstatSync(directory.path); + if ( + !( + info.isDirectory() && + named.isDirectory() && + sameFile(info, directory.info) && + sameFile(info, named) && + info.uid === process.getuid?.() && + named.uid === info.uid && + (info.mode & 0o022) === 0 && + (!directory.private || (info.mode & 0o777) === 0o700) + ) + ) { + return refuse(); + } + } + if ( + readLeaf(source, limit) !== input.text || + checkLeaf(out, limit).size !== 0 || + checkLeaf(err, limit).size !== 0 + ) { + return refuse(); + } + } catch { + return refuse(); } - if (readLeaf(source, limit) !== input.text || checkLeaf(out, limit).size !== 0 || checkLeaf(err, limit).size !== 0) return refuse(); - } catch { return refuse(); } }; + }; assertFresh(); return { - descriptors: { stdin: source.file.fd, stdout: out.file.fd, stderr: err.file.fd }, + descriptors: { + stdin: source.file.fd, + stdout: out.file.fd, + stderr: err.file.fd, + }, assertFresh, - read: () => { try { return { stdout: readLeaf(out, limit), stderr: readLeaf(err, limit) }; } catch { return refuse(); } }, - close: async () => { await Promise.all(held.map((leaf) => leaf.file.close())); }, - retainUnsettled: () => { unsettledDescriptors.add(held); }, + read: () => { + try { + return { stdout: readLeaf(out, limit), stderr: readLeaf(err, limit) }; + } catch { + return refuse(); + } + }, + close: async () => { + await Promise.all(held.map((leaf) => leaf.file.close())); + }, + retainUnsettled: () => { + unsettledDescriptors.add(held); + }, }; } catch { await Promise.allSettled(held.map((leaf) => leaf.file.close())); diff --git a/src/lib/native-compose-storage-witness-docker-policy.ts b/src/lib/native-compose-storage-witness-docker-policy.ts index bf26635f..200fe90d 100644 --- a/src/lib/native-compose-storage-witness-docker-policy.ts +++ b/src/lib/native-compose-storage-witness-docker-policy.ts @@ -6,7 +6,8 @@ import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-stor const ID = /^[a-f0-9]{64}$/; export const NATIVE_STORAGE_CARRIER_LABEL = "io.hack.storage-witness.carrier"; -export const NATIVE_STORAGE_CARRIER_FORMAT = '{"id":{{json .Id}},"createdAt":{{json .Created}},"image":{{json .Image}},"configImage":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"openStdin":{{json .Config.OpenStdin}},"tty":{{json .Config.Tty}},"labels":{{json .Config.Labels}},"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"state":{{json .State}},"execIds":{{json .ExecIDs}}}'; +export const NATIVE_STORAGE_CARRIER_FORMAT = + '{"id":{{json .Id}},"createdAt":{{json .Created}},"image":{{json .Image}},"configImage":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"openStdin":{{json .Config.OpenStdin}},"tty":{{json .Config.Tty}},"labels":{{json .Config.Labels}},"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"state":{{json .State}},"execIds":{{json .ExecIDs}}}'; export type NativeComposeStorageDockerCarrier = Record & { readonly id: string; readonly createdAt: string; @@ -19,60 +20,197 @@ export function checkNativeComposeStorageDockerCarrier(opts: { readonly imageIds: readonly string[]; }): NativeComposeStorageDockerCarrier { const { value, input, program, imageIds } = opts; - if (!(isRecord(value) && typeof value.id === "string" && ID.test(value.id) && nativeComposeVolumeCreatedAt(value.createdAt) && - isRecord(value.host) && isRecord(value.state) && isRecord(value.labels) && Array.isArray(value.mounts) && - value.configImage === input.artifact.imageId && typeof value.image === "string" && imageIds.includes(value.image) && - value.user === `${input.uid}:${input.gid}` && value.openStdin === true && value.tty === false && - JSON.stringify(value.entrypoint) === '["/usr/local/bin/bun"]' && JSON.stringify(value.cmd) === '["--no-env-file","/hack-storage-witness-helper.mjs"]' && - value.labels[NATIVE_STORAGE_CARRIER_LABEL] === input.invocationId && value.labels["io.hack.storage-witness.owner"] === input.target.ownerToken && - value.labels["io.hack.storage-witness.generation"] === input.scope.generationId && value.labels["io.hack.storage-witness.helper"] === input.artifact.helperHash && - !Object.hasOwn(value.labels, "com.docker.compose.project") && - Object.hasOwn(value, "execIds") && (value.execIds === null || (Array.isArray(value.execIds) && value.execIds.length === 0)))) return refuse(); + if ( + !( + isRecord(value) && + typeof value.id === "string" && + ID.test(value.id) && + nativeComposeVolumeCreatedAt(value.createdAt) && + isRecord(value.host) && + isRecord(value.state) && + isRecord(value.labels) && + Array.isArray(value.mounts) && + value.configImage === input.artifact.imageId && + typeof value.image === "string" && + imageIds.includes(value.image) && + value.user === `${input.uid}:${input.gid}` && + value.openStdin === true && + value.tty === false && + JSON.stringify(value.entrypoint) === '["/usr/local/bin/bun"]' && + JSON.stringify(value.cmd) === + '["--no-env-file","/hack-storage-witness-helper.mjs"]' && + value.labels[NATIVE_STORAGE_CARRIER_LABEL] === input.invocationId && + value.labels["io.hack.storage-witness.owner"] === + input.target.ownerToken && + value.labels["io.hack.storage-witness.generation"] === + input.scope.generationId && + value.labels["io.hack.storage-witness.helper"] === + input.artifact.helperHash && + !Object.hasOwn(value.labels, "com.docker.compose.project") && + Object.hasOwn(value, "execIds") && + (value.execIds === null || + (Array.isArray(value.execIds) && value.execIds.length === 0)) + ) + ) { + return refuse(); + } const host = value.host; - if (!(host.Privileged === false && host.ReadonlyRootfs === true && host.NetworkMode === "none" && host.Memory === 268_435_456 && - host.NanoCpus === 1_000_000_000 && host.PidsLimit === 32 && JSON.stringify(host.CapDrop) === '["ALL"]' && - (host.CapAdd === null || (Array.isArray(host.CapAdd) && host.CapAdd.length === 0)) && - JSON.stringify(host.SecurityOpt) === '["no-new-privileges:true"]' && - isRecord(host.LogConfig) && host.LogConfig.Type === "none" && isRecord(host.LogConfig.Config) && Object.keys(host.LogConfig.Config).length === 0 && - isRecord(host.RestartPolicy) && host.RestartPolicy.Name === "no" && - (host.Binds === null || (Array.isArray(host.Binds) && host.Binds.length === 0)) && - (host.Tmpfs === null || (isRecord(host.Tmpfs) && Object.keys(host.Tmpfs).length === 0)) && - (host.Devices === null || (Array.isArray(host.Devices) && host.Devices.length === 0)) && - (host.DeviceRequests === null || (Array.isArray(host.DeviceRequests) && host.DeviceRequests.length === 0)) && - isRecord(host.PortBindings) && Object.keys(host.PortBindings).length === 0 && - Array.isArray(host.Mounts) && host.Mounts.length === 2 && value.mounts.length === 2)) return refuse(); - for (const [source, target, readonly] of [[program, "/hack-storage-witness-helper.mjs", true], [input.target.mountpoint, "/hack-storage-witness", input.readonly]] as const) { - const configured = host.Mounts.filter((mount) => isRecord(mount) && mount.Target === target); - const physical = value.mounts.filter((mount) => isRecord(mount) && mount.Destination === target); - if (!(typeof source === "string" && configured.length === 1 && physical.length === 1 && - isRecord(configured[0]) && configured[0].Type === "bind" && configured[0].Source === source && configured[0].ReadOnly === readonly && - isRecord(configured[0].BindOptions) && configured[0].BindOptions.NonRecursive === true && configured[0].BindOptions.Propagation === "rprivate" && configured[0].BindOptions.CreateMountpoint !== true && - isRecord(physical[0]) && physical[0].Type === "bind" && physical[0].Source === source && physical[0].RW === !readonly && physical[0].Propagation === "rprivate")) return refuse(); + if ( + !( + host.Privileged === false && + host.ReadonlyRootfs === true && + host.NetworkMode === "none" && + host.Memory === 268_435_456 && + host.NanoCpus === 1_000_000_000 && + host.PidsLimit === 32 && + JSON.stringify(host.CapDrop) === '["ALL"]' && + (host.CapAdd === null || + (Array.isArray(host.CapAdd) && host.CapAdd.length === 0)) && + JSON.stringify(host.SecurityOpt) === '["no-new-privileges:true"]' && + isRecord(host.LogConfig) && + host.LogConfig.Type === "none" && + isRecord(host.LogConfig.Config) && + Object.keys(host.LogConfig.Config).length === 0 && + isRecord(host.RestartPolicy) && + host.RestartPolicy.Name === "no" && + (host.Binds === null || + (Array.isArray(host.Binds) && host.Binds.length === 0)) && + (host.Tmpfs === null || + (isRecord(host.Tmpfs) && Object.keys(host.Tmpfs).length === 0)) && + (host.Devices === null || + (Array.isArray(host.Devices) && host.Devices.length === 0)) && + (host.DeviceRequests === null || + (Array.isArray(host.DeviceRequests) && + host.DeviceRequests.length === 0)) && + isRecord(host.PortBindings) && + Object.keys(host.PortBindings).length === 0 && + Array.isArray(host.Mounts) && + host.Mounts.length === 2 && + value.mounts.length === 2 + ) + ) { + return refuse(); + } + for (const [source, target, readonly] of [ + [program, "/hack-storage-witness-helper.mjs", true], + [input.target.mountpoint, "/hack-storage-witness", input.readonly], + ] as const) { + const configured = host.Mounts.filter( + (mount) => isRecord(mount) && mount.Target === target + ); + const physical = value.mounts.filter( + (mount) => isRecord(mount) && mount.Destination === target + ); + if ( + !( + typeof source === "string" && + configured.length === 1 && + physical.length === 1 && + isRecord(configured[0]) && + configured[0].Type === "bind" && + configured[0].Source === source && + configured[0].ReadOnly === readonly && + isRecord(configured[0].BindOptions) && + configured[0].BindOptions.NonRecursive === true && + configured[0].BindOptions.Propagation === "rprivate" && + configured[0].BindOptions.CreateMountpoint !== true && + isRecord(physical[0]) && + physical[0].Type === "bind" && + physical[0].Source === source && + physical[0].RW === !readonly && + physical[0].Propagation === "rprivate" + ) + ) { + return refuse(); + } } return value as NativeComposeStorageDockerCarrier; } /** Preserve every static field and complete mount row. Only the two observed closed * optional defaults are normalized; no value, duplicate or projected field is dropped. */ -export function nativeComposeStorageDockerCarrierPolicy(value: NativeComposeStorageDockerCarrier): string { - if (!(isRecord(value.host) && Array.isArray(value.mounts) && Object.hasOwn(value.host, "OomKillDisable") && - (value.host.OomKillDisable === null || value.host.OomKillDisable === false) && Object.hasOwn(value, "execIds") && - (value.execIds === null || (Array.isArray(value.execIds) && value.execIds.length === 0)))) return refuse(); +export function nativeComposeStorageDockerCarrierPolicy( + value: NativeComposeStorageDockerCarrier +): string { + if ( + !( + isRecord(value.host) && + Array.isArray(value.mounts) && + Object.hasOwn(value.host, "OomKillDisable") && + (value.host.OomKillDisable === null || + value.host.OomKillDisable === false) && + Object.hasOwn(value, "execIds") && + (value.execIds === null || + (Array.isArray(value.execIds) && value.execIds.length === 0)) + ) + ) { + return refuse(); + } const { state: _, ...fixed } = value; - const mounts = [...value.mounts].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); - return JSON.stringify({ ...fixed, host: { ...value.host, OomKillDisable: false }, execIds: [], mounts }); + const mounts = [...value.mounts].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)) + ); + return JSON.stringify({ + ...fixed, + host: { ...value.host, OomKillDisable: false }, + execIds: [], + mounts, + }); } export function nativeComposeStorageDockerCreateArgs(opts: { readonly input: NativeComposeStorageXattrInvocation; readonly program: string; }): readonly string[] { const { input, program } = opts; - if (input.artifact.imageId !== NATIVE_STORAGE_DOCKER_ARTIFACT.imageId || !input.target.mountpoint) return refuse(); - return ["create", "--interactive", "--name", `hack-storage-witness-${input.invocationId}`, "--pull", "never", "--platform", input.artifact.platform, - "--user", `${input.uid}:${input.gid}`, "--read-only", "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", - "--memory", "256m", "--cpus", "1", "--pids-limit", "32", "--restart", "no", "--log-driver", "none", - "--mount", `type=bind,src=${program},dst=/hack-storage-witness-helper.mjs,readonly,bind-recursive=disabled,bind-propagation=rprivate`, - "--mount", `type=bind,src=${input.target.mountpoint},dst=/hack-storage-witness${input.readonly ? ",readonly" : ""},bind-recursive=disabled,bind-propagation=rprivate`, - "--label", `${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, "--label", `io.hack.storage-witness.owner=${input.target.ownerToken}`, - "--label", `io.hack.storage-witness.generation=${input.scope.generationId}`, "--label", `io.hack.storage-witness.helper=${input.artifact.helperHash}`, - "--entrypoint", "/usr/local/bin/bun", input.artifact.imageId, "--no-env-file", "/hack-storage-witness-helper.mjs"]; + if ( + input.artifact.imageId !== NATIVE_STORAGE_DOCKER_ARTIFACT.imageId || + !input.target.mountpoint + ) { + return refuse(); + } + return [ + "create", + "--interactive", + "--name", + `hack-storage-witness-${input.invocationId}`, + "--pull", + "never", + "--platform", + input.artifact.platform, + "--user", + `${input.uid}:${input.gid}`, + "--read-only", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges:true", + "--memory", + "256m", + "--cpus", + "1", + "--pids-limit", + "32", + "--restart", + "no", + "--log-driver", + "none", + "--mount", + `type=bind,src=${program},dst=/hack-storage-witness-helper.mjs,readonly,bind-recursive=disabled,bind-propagation=rprivate`, + "--mount", + `type=bind,src=${input.target.mountpoint},dst=/hack-storage-witness${input.readonly ? ",readonly" : ""},bind-recursive=disabled,bind-propagation=rprivate`, + "--label", + `${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, + "--label", + `io.hack.storage-witness.owner=${input.target.ownerToken}`, + "--label", + `io.hack.storage-witness.generation=${input.scope.generationId}`, + "--label", + `io.hack.storage-witness.helper=${input.artifact.helperHash}`, + "--entrypoint", + "/usr/local/bin/bun", + input.artifact.imageId, + "--no-env-file", + "/hack-storage-witness-helper.mjs", + ]; } diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index c1c805d1..b3525cc3 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -26,7 +26,10 @@ import { inspectNativeComposeStorageDockerVolume, observeNativeComposeStorageDockerTarget, } from "./native-compose-storage-witness-docker-inventory.ts"; -import { createNativeComposeStorageDockerEmptyLeaf, holdNativeComposeStorageDockerIo } from "./native-compose-storage-witness-docker-io.ts"; +import { + createNativeComposeStorageDockerEmptyLeaf, + holdNativeComposeStorageDockerIo, +} from "./native-compose-storage-witness-docker-io.ts"; import { checkNativeComposeStorageDockerCarrier, NATIVE_STORAGE_CARRIER_FORMAT, @@ -45,12 +48,14 @@ import { parseNativeComposeStorageXattrResponse, refuseNativeComposeStorageXattr as refuse, } from "./native-compose-storage-witness-xattr-codec.ts"; -import { findExecutableInPath, run, type RunExitEvent } from "./shell.ts"; +import { findExecutableInPath, type RunExitEvent, run } from "./shell.ts"; const ID = /^[a-f0-9]{64}$/; -const IMAGE_IDS = [artifact.imageId, +const IMAGE_IDS = [ + artifact.imageId, "sha256:5c51cee225076d3c7db2150683141476298062489de4660f2d1729e522641f91", - "sha256:1cb8f81099813a0ec61f99b69348f6594e188cf1689c20a0a53a5ddd23b37708"] as const; + "sha256:1cb8f81099813a0ec61f99b69348f6594e188cf1689c20a0a53a5ddd23b37708", +] as const; const CAPTURE_LIMIT = 4096; type Selection = { readonly name: string; readonly storage: string }; type Context = { @@ -68,39 +73,100 @@ type Files = { readonly programInfo: Awaited>; readonly inputInfo: Awaited>; readonly requestText: string; - readonly captures: { readonly path: string; readonly info: Awaited> }[]; + readonly captures: { + readonly path: string; + readonly info: Awaited>; + }[]; }; function same(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } function lifetime(context: Context): void { - if (context.signal.aborted || Date.now() >= context.deadline) refuse(); + if (context.signal.aborted || Date.now() >= context.deadline) { + refuse(); + } } -async function current(context: Context, selection?: Selection, effect = false): Promise { +async function current( + context: Context, + selection?: Selection, + effect = false +): Promise { const saved = await context.store.loadCurrent(); - const generation = await context.store.loadPending() ?? saved.generation ?? refuse(); + const generation = + (await context.store.loadPending()) ?? saved.generation ?? refuse(); if (selection) { const document = await context.store.readGenerationDocument(generation); - const declared = isRecord(document.volumes) && Object.hasOwn(document.volumes, selection.storage) && - isRecord(document.volumes[selection.storage]) && document.volumes[selection.storage].name === selection.name; - const retained = saved.storageWitnesses?.some((entry) => entry.name === selection.name && entry.storage === selection.storage) ?? false; - if (!(declared || (!effect && retained))) return refuse(); + const selected = + isRecord(document.volumes) && + Object.hasOwn(document.volumes, selection.storage) + ? document.volumes[selection.storage] + : null; + const declared = isRecord(selected) && selected.name === selection.name; + const retained = + saved.storageWitnesses?.some( + (entry) => + entry.name === selection.name && entry.storage === selection.storage + ) ?? false; + if (!(declared || (!effect && retained))) { + return refuse(); + } } - return await assertNativeComposeMaterialAuthority({ authority: context.authority, generation, phase: effect ? "effect" : "inspect" }); + return await assertNativeComposeMaterialAuthority({ + authority: context.authority, + generation, + phase: effect ? "effect" : "inspect", + }); } -function engine(probe: ReturnType, context: Context): Promise { +function engine( + probe: ReturnType, + context: Context +): Promise { return probe(["info", "--format", "{{json .ID}}"]).then((text) => { - if (JSON.parse(text) !== context.engineId) refuse(); + if (JSON.parse(text) !== context.engineId) { + refuse(); + } }); } -async function image(probe: ReturnType): Promise { - const value: unknown = JSON.parse(await probe(["image", "inspect", "--format", '{"id":{{json .Id}},"os":{{json .Os}},"arch":{{json .Architecture}},"volumes":{{json (index .Config "Volumes")}}', artifact.imageId])); - if (!(isRecord(value) && value.id === artifact.imageId && value.os === "linux" && value.arch === "arm64" && value.volumes === null)) return refuse(); +async function image( + probe: ReturnType +): Promise { + const value: unknown = JSON.parse( + await probe([ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"os":{{json .Os}},"arch":{{json .Architecture}},"volumes":{{json (index .Config "Volumes")}}', + artifact.imageId, + ]) + ); + if ( + !( + isRecord(value) && + value.id === artifact.imageId && + value.os === "linux" && + value.arch === "arm64" && + value.volumes === null + ) + ) { + return refuse(); + } } -async function invocationFiles(context: Context, input: NativeComposeStorageXattrInvocation): Promise { +async function invocationFiles( + context: Context, + input: NativeComposeStorageXattrInvocation +): Promise { const held: HeldDirectory[] = []; try { - const owner = await holdDirectory(join(context.store.identity.checkoutRoot, ".hack", ".internal", "native-compose", context.store.identity.instanceId), true); + const owner = await holdDirectory( + join( + context.store.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + context.store.identity.instanceId + ), + true + ); held.push(owner); const root = await privateDirectory(join(owner.path, "storage-carriers")); held.push(root); @@ -110,16 +176,34 @@ async function invocationFiles(context: Context, input: NativeComposeStorageXatt await root.file.sync(); const directory = await holdDirectory(path, true); held.push(directory); - const program = join(path, "helper.mjs"), request = join(path, "request.json"); - const programInfo = await writeExclusive(program, nativeComposeStorageDockerHelper()); + const program = join(path, "helper.mjs"), + request = join(path, "request.json"); + const programInfo = await writeExclusive( + program, + nativeComposeStorageDockerHelper() + ); const requestText = encodeNativeComposeStorageXattrRequest(input.request); const inputInfo = await writeExclusive(request, requestText); // read-only guest code, private owned parent. This leaf is not executable on the host. const file = await open(program, constants.O_RDONLY | constants.O_NOFOLLOW); - try { await file.chmod(0o444); await file.sync(); } finally { await file.close(); } + try { + await file.chmod(0o444); + await file.sync(); + } finally { + await file.close(); + } await directory.file.sync(); await recheckDirectories(held); - return { held, path, program, input: request, programInfo, inputInfo, requestText, captures: [] }; + return { + held, + path, + program, + input: request, + programInfo, + inputInfo, + requestText, + captures: [], + }; } catch { await Promise.allSettled(held.map((directory) => directory.file.close())); return refuse(); @@ -127,13 +211,30 @@ async function invocationFiles(context: Context, input: NativeComposeStorageXatt } async function retireInvocationFiles(files: Files): Promise { await checkFiles(files); - const owned = [{ path: files.program, info: files.programInfo }, { path: files.input, info: files.inputInfo }, ...files.captures]; - const names = owned.map((file) => file.path.slice(files.path.length + 1)).sort(); - if (!same((await readdir(files.path)).sort(), names)) return refuse(); + const owned = [ + { path: files.program, info: files.programInfo }, + { path: files.input, info: files.inputInfo }, + ...files.captures, + ]; + const names = owned + .map((file) => file.path.slice(files.path.length + 1)) + .sort(); + if (!same((await readdir(files.path)).sort(), names)) { + return refuse(); + } for (const file of owned) { await recheckDirectories(files.held); const named = await lstat(file.path); - if (!(named.isFile() && named.nlink === 1 && named.uid === process.getuid?.() && sameFile(named, file.info))) return refuse(); + if ( + !( + named.isFile() && + named.nlink === 1 && + named.uid === process.getuid?.() && + sameFile(named, file.info) + ) + ) { + return refuse(); + } await unlink(file.path); } await files.held.at(-1)?.file.sync(); @@ -143,27 +244,61 @@ async function retireInvocationFiles(files: Files): Promise { } async function checkFiles(files: Files): Promise { await recheckDirectories(files.held); - for (const [path, original, text] of [[files.program, files.programInfo, nativeComposeStorageDockerHelper()], [files.input, files.inputInfo, files.requestText]] as const) { + for (const [path, original, text] of [ + [files.program, files.programInfo, nativeComposeStorageDockerHelper()], + [files.input, files.inputInfo, files.requestText], + ] as const) { const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const before = await file.stat(); - if (!(before.isFile() && before.nlink === 1 && before.uid === process.getuid?.() && sameFile(before, original) && before.size === Buffer.byteLength(text) && - (before.mode & 0o777) === (path === files.program ? 0o444 : 0o600))) return refuse(); + if ( + !( + before.isFile() && + before.nlink === 1 && + before.uid === process.getuid?.() && + sameFile(before, original) && + before.size === Buffer.byteLength(text) && + (before.mode & 0o777) === (path === files.program ? 0o444 : 0o600) + ) + ) { + return refuse(); + } const bytes = await file.readFile(); - const after = await file.stat(), named = await lstat(path); - if (!(sameFile(after, before) && sameFile(named, before) && bytes.toString("utf8") === text && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs)) return refuse(); - } finally { await file.close(); } + const after = await file.stat(), + named = await lstat(path); + if ( + !( + sameFile(after, before) && + sameFile(named, before) && + bytes.toString("utf8") === text && + after.mtimeMs === before.mtimeMs && + after.ctimeMs === before.ctimeMs + ) + ) { + return refuse(); + } + } finally { + await file.close(); + } } } async function groupAbsent(pid: number): Promise { - if (!(Number.isInteger(pid) && pid > 1)) return refuse(); + if (!(Number.isInteger(pid) && pid > 1)) { + return refuse(); + } const deadline = Date.now() + 3000; while (true) { - try { process.kill(-pid, 0); } catch (error: unknown) { - if (isRecord(error) && error.code === "ESRCH") return; + try { + process.kill(-pid, 0); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ESRCH") { + return; + } + return refuse(); + } + if (Date.now() >= deadline) { return refuse(); } - if (Date.now() >= deadline) return refuse(); await new Promise((resolve) => setTimeout(resolve, 20)); } } @@ -171,203 +306,656 @@ async function groupAbsent(pid: number): Promise { * unproven group retains the journal; daemon effects are never inferred from exit. */ export async function runNativeComposeStorageDockerCommand(supplied: { readonly context: Pick; - readonly files: Pick; + readonly files: Pick< + Files, + "held" | "path" | "input" | "inputInfo" | "requestText" | "captures" + >; readonly args: readonly string[]; readonly beforeSpawn: () => void; readonly assertAdmitted: () => Promise; readonly timeoutMs: number; readonly cleanup?: boolean; }): Promise<{ readonly exitCode: number; readonly stdout: string }> { - const opts = { ...supplied, context: { ...supplied.context }, args: [...supplied.args] }; + const opts = { + ...supplied, + context: { ...supplied.context }, + args: [...supplied.args], + }; const capture = randomBytes(16).toString("hex"); - const stdout = join(opts.files.path, `${capture}.stdout`), stderr = join(opts.files.path, `${capture}.stderr`); - const out = await createNativeComposeStorageDockerEmptyLeaf(stdout), err = await createNativeComposeStorageDockerEmptyLeaf(stderr); - opts.files.captures.push({ path: stdout, info: out }, { path: stderr, info: err }); + const stdout = join(opts.files.path, `${capture}.stdout`), + stderr = join(opts.files.path, `${capture}.stderr`); + const out = await createNativeComposeStorageDockerEmptyLeaf(stdout), + err = await createNativeComposeStorageDockerEmptyLeaf(stderr); + opts.files.captures.push( + { path: stdout, info: out }, + { path: stderr, info: err } + ); await opts.files.held.at(-1)?.file.sync(); - const io = await holdNativeComposeStorageDockerIo({ directories: opts.files.held, - input: { path: opts.files.input, info: opts.files.inputInfo, text: opts.files.requestText }, - stdout: { path: stdout, info: out }, stderr: { path: stderr, info: err }, limit: CAPTURE_LIMIT }); - let admitted = false, settled = false, pid = 0, exit: RunExitEvent | null = null; + const io = await holdNativeComposeStorageDockerIo({ + directories: opts.files.held, + input: { + path: opts.files.input, + info: opts.files.inputInfo, + text: opts.files.requestText, + }, + stdout: { path: stdout, info: out }, + stderr: { path: stderr, info: err }, + limit: CAPTURE_LIMIT, + }); + let admitted = false, + settled = false, + pid = 0, + exit: RunExitEvent | null = null; try { await opts.assertAdmitted(); - const timeoutMs = opts.cleanup ? opts.timeoutMs : Math.min(opts.timeoutMs, opts.context.deadline - Date.now()); - if (!(timeoutMs > 0)) return refuse(); + const timeoutMs = opts.cleanup + ? opts.timeoutMs + : Math.min(opts.timeoutMs, opts.context.deadline - Date.now()); + if (!(timeoutMs > 0)) { + return refuse(); + } // Fixed FSIZE quota bounds both inherited capture files, including daemon // stderr. Acceptance remains 4096 bytes; no child reopens a named file. - const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec "$@"', "storage-witness-command", ...opts.args], { - privateIo: io.descriptors, timeoutMs, signal: opts.cleanup ? undefined : opts.context.signal, - beforeSpawn: () => { io.assertFresh(); opts.beforeSpawn(); admitted = true; }, - onSpawn: async (event) => { pid = event.ownsProcessGroup ? event.processGroupId ?? event.pid : 0; }, - onExit: async (event) => { exit = event; }, - }); + const code = await run( + [ + "/bin/sh", + "-c", + 'ulimit -f 8; exec "$@"', + "storage-witness-command", + ...opts.args, + ], + { + privateIo: io.descriptors, + timeoutMs, + signal: opts.cleanup ? undefined : opts.context.signal, + beforeSpawn: () => { + io.assertFresh(); + opts.beforeSpawn(); + admitted = true; + }, + onSpawn: (event) => { + pid = event.ownsProcessGroup + ? (event.processGroupId ?? event.pid) + : 0; + return Promise.resolve(); + }, + onExit: (event) => { + exit = event; + return Promise.resolve(); + }, + } + ); await groupAbsent(pid); settled = true; - if (!(exit && !(exit as RunExitEvent).timedOut && !(exit as RunExitEvent).cancelled)) return refuse(); + if ( + !( + exit && + !(exit as RunExitEvent).timedOut && + !(exit as RunExitEvent).cancelled + ) + ) { + return refuse(); + } return { exitCode: code, stdout: io.read().stdout }; } finally { - if (!admitted || settled) await io.close(); else io.retainUnsettled(); + if (!admitted || settled) { + await io.close(); + } else { + io.retainUnsettled(); + } } } -function matchesScope(input: NativeComposeStorageXattrInvocation, binding: NativeComposeMaterialBinding): boolean { - return input.scope.generationId === binding.generationId && input.scope.currentGenerationId === binding.currentGenerationId && - input.scope.pendingGenerationId === binding.pendingGenerationId && input.scope.pendingToken === binding.pendingToken && - input.target.runtimeIdentity === binding.identity.composeProject && input.target.ownerToken === binding.identity.ownerToken; +function matchesScope( + input: NativeComposeStorageXattrInvocation, + binding: NativeComposeMaterialBinding +): boolean { + return ( + input.scope.generationId === binding.generationId && + input.scope.currentGenerationId === binding.currentGenerationId && + input.scope.pendingGenerationId === binding.pendingGenerationId && + input.scope.pendingToken === binding.pendingToken && + input.target.runtimeIdentity === binding.identity.composeProject && + input.target.ownerToken === binding.identity.ownerToken + ); } /** SOURCE-only transport until its persistent-volume/PGDATA qualification and CLI * admission are complete. No module initialization executes Docker or the helper. */ -export async function createNativeComposeDockerStorageXattrCarrier(opts: Context): Promise { +export async function createNativeComposeDockerStorageXattrCarrier( + opts: Context +): Promise { const context: Context = Object.freeze({ ...opts }); const docker = findExecutableInPath("docker") ?? refuse(); - const selectors = ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"].map((key) => [key, process.env[key]] as const); + const selectors = [ + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + ].map((key) => [key, process.env[key]] as const); const tool = () => { - if (findExecutableInPath("docker") !== docker || selectors.some(([key, value]) => process.env[key] !== value)) refuse(); + if ( + findExecutableInPath("docker") !== docker || + selectors.some(([key, value]) => process.env[key] !== value) + ) { + refuse(); + } }; const probe = (cleanup = false) => { tool(); - if (!cleanup) lifetime(context); - return createNativeComposeProbe({ signal: cleanup ? undefined : context.signal, timeoutMs: cleanup ? 10_000 : Math.min(15_000, context.deadline - Date.now()) }); + if (!cleanup) { + lifetime(context); + } + return createNativeComposeProbe({ + signal: cleanup ? undefined : context.signal, + timeoutMs: cleanup + ? 10_000 + : Math.min(15_000, context.deadline - Date.now()), + }); }; // Resolve the declared dependency before Expected, volume creation or any helper // footprint. This first platform remains explicit; emulation is not a fallback. try { const prerequisite = probe(); - const daemon: unknown = JSON.parse(await prerequisite(["info", "--format", '{"id":{{json .ID}},"os":{{json .OSType}},"arch":{{json .Architecture}}}'])); - if (!(isRecord(daemon) && daemon.id === context.engineId && daemon.os === "linux" && (daemon.arch === "arm64" || daemon.arch === "aarch64"))) refuse(); + const daemon: unknown = JSON.parse( + await prerequisite([ + "info", + "--format", + '{"id":{{json .ID}},"os":{{json .OSType}},"arch":{{json .Architecture}}}', + ]) + ); + if ( + !( + isRecord(daemon) && + daemon.id === context.engineId && + daemon.os === "linux" && + (daemon.arch === "arm64" || daemon.arch === "aarch64") + ) + ) { + refuse(); + } await image(prerequisite); nativeComposeStorageDockerHelper(); lifetime(context); } catch { - throw new Error("Native storage witnesses require a Linux arm64 daemon and the fixed cached Bun 1.4.2 helper image; no image is pulled automatically."); + throw new Error( + "Native storage witnesses require a Linux arm64 daemon and the fixed cached Bun 1.4.2 helper image; no image is pulled automatically." + ); } const inspect = async (selection: Selection) => { const before = await current(context, selection); - const target = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection, engineId: context.engineId, stopped: false }); + const target = await observeNativeComposeStorageDockerTarget({ + probe: probe(), + current: before, + selection, + engineId: context.engineId, + stopped: false, + }); const after = await current(context, selection); - if (!same(before, after)) return refuse(); + if (!same(before, after)) { + return refuse(); + } lifetime(context); return target; }; - const provision = async (selection: Selection & { readonly engineId: string; readonly runtimeIdentity: string; readonly ownerToken: string }) => { + const provision = async ( + selection: Selection & { + readonly engineId: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; + } + ) => { const before = await current(context, selection, true); - if (!(selection.engineId === context.engineId && selection.runtimeIdentity === before.identity.composeProject && selection.ownerToken === before.identity.ownerToken)) return refuse(); - const absent = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection, engineId: context.engineId, stopped: true }); - if (absent.volume !== null) return refuse(); + if ( + !( + selection.engineId === context.engineId && + selection.runtimeIdentity === before.identity.composeProject && + selection.ownerToken === before.identity.ownerToken + ) + ) { + return refuse(); + } + const absent = await observeNativeComposeStorageDockerTarget({ + probe: probe(), + current: before, + selection, + engineId: context.engineId, + stopped: true, + }); + if (absent.volume !== null) { + return refuse(); + } await image(probe()); const nonce = randomBytes(16).toString("hex"); - const directory = await holdDirectory(join(context.store.identity.checkoutRoot, ".hack", ".internal", "native-compose", context.store.identity.instanceId), true); + const directory = await holdDirectory( + join( + context.store.identity.checkoutRoot, + ".hack", + ".internal", + "native-compose", + context.store.identity.instanceId + ), + true + ); const held: HeldDirectory[] = [directory]; let files: Files | null = null; try { - const root = await privateDirectory(join(directory.path, "storage-provisions")); held.push(root); - const path = join(root.path, nonce); await mkdir(path, { mode: 0o700 }); await root.file.sync(); - const leaf = await holdDirectory(path, true); held.push(leaf); - const request = join(path, "request.json"), program = join(path, "intent.json"); - const inputInfo = await createNativeComposeStorageDockerEmptyLeaf(request); - const programInfo = await writeExclusive(program, JSON.stringify({ ...selection, nonce })); await leaf.file.sync(); - files = { held, path, input: request, program, programInfo, inputInfo, requestText: "", captures: [] }; - const read = probe(); await engine(read, context); - const finalAbsent = await observeNativeComposeStorageDockerTarget({ probe: read, current: before, selection, engineId: context.engineId, stopped: true }); - if (finalAbsent.volume !== null) return refuse(); - tool(); await recheckDirectories(held); + const root = await privateDirectory( + join(directory.path, "storage-provisions") + ); + held.push(root); + const path = join(root.path, nonce); + await mkdir(path, { mode: 0o700 }); + await root.file.sync(); + const leaf = await holdDirectory(path, true); + held.push(leaf); + const request = join(path, "request.json"), + program = join(path, "intent.json"); + const inputInfo = + await createNativeComposeStorageDockerEmptyLeaf(request); + const programInfo = await writeExclusive( + program, + JSON.stringify({ ...selection, nonce }) + ); + await leaf.file.sync(); + files = { + held, + path, + input: request, + program, + programInfo, + inputInfo, + requestText: "", + captures: [], + }; + const read = probe(); + await engine(read, context); + const finalAbsent = await observeNativeComposeStorageDockerTarget({ + probe: read, + current: before, + selection, + engineId: context.engineId, + stopped: true, + }); + if (finalAbsent.volume !== null) { + return refuse(); + } + tool(); + await recheckDirectories(held); const latest = await current(context, selection, true); - if (!same(before, latest)) return refuse(); - const result = await runNativeComposeStorageDockerCommand({ context, files, - args: [docker, "volume", "create", "--driver", "local", "--label", `com.docker.compose.project=${selection.runtimeIdentity}`, - "--label", `com.docker.compose.volume=${selection.storage}`, "--label", "io.hack.native-config.version=1", - "--label", `io.hack.native-config.instance=${selection.runtimeIdentity}`, "--label", `io.hack.native-config.owner=${selection.ownerToken}`, - "--label", `io.hack.native-config.storage=${selection.storage}`, "--label", `io.hack.native-config.storage-provision=${nonce}`, selection.name], - timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, - assertAdmitted: async () => { await engine(probe(), context); tool(); if (!same(before, await current(context, selection, true))) refuse(); } }); - if (!(result.exitCode === 0 && result.stdout.trim() === selection.name)) return refuse(); - const created = await inspectNativeComposeStorageDockerVolume({ probe: probe(), current: latest, selection }); - if (created.provision !== nonce) return refuse(); + if (!same(before, latest)) { + return refuse(); + } + const result = await runNativeComposeStorageDockerCommand({ + context, + files, + args: [ + docker, + "volume", + "create", + "--driver", + "local", + "--label", + `com.docker.compose.project=${selection.runtimeIdentity}`, + "--label", + `com.docker.compose.volume=${selection.storage}`, + "--label", + "io.hack.native-config.version=1", + "--label", + `io.hack.native-config.instance=${selection.runtimeIdentity}`, + "--label", + `io.hack.native-config.owner=${selection.ownerToken}`, + "--label", + `io.hack.native-config.storage=${selection.storage}`, + "--label", + `io.hack.native-config.storage-provision=${nonce}`, + selection.name, + ], + timeoutMs: 15_000, + beforeSpawn: () => { + tool(); + lifetime(context); + }, + assertAdmitted: async () => { + await engine(probe(), context); + tool(); + if (!same(before, await current(context, selection, true))) { + refuse(); + } + }, + }); + if (!(result.exitCode === 0 && result.stdout.trim() === selection.name)) { + return refuse(); + } + const created = await inspectNativeComposeStorageDockerVolume({ + probe: probe(), + current: latest, + selection, + }); + if (created.provision !== nonce) { + return refuse(); + } const after = await current(context, selection, true); - if (!same(before, after)) return refuse(); - } finally { await Promise.allSettled(held.map((entry) => entry.file.close())); } + if (!same(before, after)) { + return refuse(); + } + } finally { + await Promise.allSettled(held.map((entry) => entry.file.close())); + } }; const invoke = async (input: NativeComposeStorageXattrInvocation) => { const before = await current(context, input.target, !input.readonly); - if (!(matchesScope(input, before) && same(input.artifact, artifact))) return refuse(); + if (!(matchesScope(input, before) && same(input.artifact, artifact))) { + return refuse(); + } const target = await inspect(input.target); - if (!same(target, input.target)) return refuse(); + if (!same(target, input.target)) { + return refuse(); + } const files = await invocationFiles(context, input); - let known: { readonly id: string; readonly createdAt: string; readonly policy: string } | null = null; + let known: { + readonly id: string; + readonly createdAt: string; + readonly policy: string; + } | null = null; let completed = false; try { - const read = probe(); await engine(read, context); await image(read); await checkFiles(files); tool(); + const read = probe(); + await engine(read, context); + await image(read); + await checkFiles(files); + tool(); const admitted = await current(context, input.target, !input.readonly); - if (!same(before, admitted)) return refuse(); - const created = await runNativeComposeStorageDockerCommand({ context, files, args: [docker, ...nativeComposeStorageDockerCreateArgs({ input, program: files.program })], - timeoutMs: 15_000, beforeSpawn: () => { tool(); lifetime(context); }, - assertAdmitted: async () => { await engine(probe(), context); tool(); if (!same(before, await current(context, input.target, !input.readonly))) refuse(); } }); + if (!same(before, admitted)) { + return refuse(); + } + const created = await runNativeComposeStorageDockerCommand({ + context, + files, + args: [ + docker, + ...nativeComposeStorageDockerCreateArgs({ + input, + program: files.program, + }), + ], + timeoutMs: 15_000, + beforeSpawn: () => { + tool(); + lifetime(context); + }, + assertAdmitted: async () => { + await engine(probe(), context); + tool(); + if ( + !same(before, await current(context, input.target, !input.readonly)) + ) { + refuse(); + } + }, + }); const id = created.stdout.trim(); - if (!(created.exitCode === 0 && ID.test(id))) return refuse(); - const inspectCarrier = async (cleanup = false): Promise => { - const observed = probe(cleanup); await engine(observed, context); - const value: unknown = JSON.parse(await observed(["container", "inspect", "--format", NATIVE_STORAGE_CARRIER_FORMAT, id])); - return checkNativeComposeStorageDockerCarrier({ value, input, program: files.program, imageIds: IMAGE_IDS }); + if (!(created.exitCode === 0 && ID.test(id))) { + return refuse(); + } + const inspectCarrier = async ( + cleanup = false + ): Promise => { + const observed = probe(cleanup); + await engine(observed, context); + const value: unknown = JSON.parse( + await observed([ + "container", + "inspect", + "--format", + NATIVE_STORAGE_CARRIER_FORMAT, + id, + ]) + ); + return checkNativeComposeStorageDockerCarrier({ + value, + input, + program: files.program, + imageIds: IMAGE_IDS, + }); }; const carrier = await inspectCarrier(); - known = { id, createdAt: carrier.createdAt, policy: nativeComposeStorageDockerCarrierPolicy(carrier) }; + known = { + id, + createdAt: carrier.createdAt, + policy: nativeComposeStorageDockerCarrierPolicy(carrier), + }; const owned = known; - if (!(carrier.state.Running === false && carrier.state.Pid === 0 && carrier.state.Status === "created")) return refuse(); + if ( + !( + carrier.state.Running === false && + carrier.state.Pid === 0 && + carrier.state.Status === "created" + ) + ) { + return refuse(); + } await input.recordCreated({ id, createdAt: known.createdAt }); - await checkFiles(files); await image(probe()); - const volume = await inspectNativeComposeStorageDockerVolume({ probe: probe(), current: before, selection: input.target }); - if (!(input.target.volume && volume.createdAt === input.target.volume.createdAt && volume.mountpoint === input.target.mountpoint)) return refuse(); - const holders = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection: input.target, engineId: context.engineId, - stopped: !input.readonly, carrier: { ...known, invocationId: input.invocationId } }); - if (!same(holders, input.target)) return refuse(); + await checkFiles(files); + await image(probe()); + const volume = await inspectNativeComposeStorageDockerVolume({ + probe: probe(), + current: before, + selection: input.target, + }); + if ( + !( + input.target.volume && + volume.createdAt === input.target.volume.createdAt && + volume.mountpoint === input.target.mountpoint + ) + ) { + return refuse(); + } + const holders = await observeNativeComposeStorageDockerTarget({ + probe: probe(), + current: before, + selection: input.target, + engineId: context.engineId, + stopped: !input.readonly, + carrier: { ...known, invocationId: input.invocationId }, + }); + if (!same(holders, input.target)) { + return refuse(); + } const ready = await inspectCarrier(); - if (!(ready.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(ready) === known.policy && ready.state.Running === false && ready.state.Pid === 0 && ready.state.Status === "created")) return refuse(); + if ( + !( + ready.createdAt === known.createdAt && + nativeComposeStorageDockerCarrierPolicy(ready) === known.policy && + ready.state.Running === false && + ready.state.Pid === 0 && + ready.state.Status === "created" + ) + ) { + return refuse(); + } tool(); const final = await current(context, input.target, !input.readonly); - if (!same(before, final)) return refuse(); - const result = await runNativeComposeStorageDockerCommand({ context, files, args: [docker, "start", "-ai", id], timeoutMs: 15_000, - beforeSpawn: () => { tool(); lifetime(context); }, + if (!same(before, final)) { + return refuse(); + } + const result = await runNativeComposeStorageDockerCommand({ + context, + files, + args: [docker, "start", "-ai", id], + timeoutMs: 15_000, + beforeSpawn: () => { + tool(); + lifetime(context); + }, assertAdmitted: async () => { - const selected = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, selection: input.target, engineId: context.engineId, - stopped: !input.readonly, carrier: { ...owned, invocationId: input.invocationId } }); - if (!same(selected, input.target)) refuse(); + const selected = await observeNativeComposeStorageDockerTarget({ + probe: probe(), + current: before, + selection: input.target, + engineId: context.engineId, + stopped: !input.readonly, + carrier: { ...owned, invocationId: input.invocationId }, + }); + if (!same(selected, input.target)) { + refuse(); + } await checkFiles(files); const ready = await inspectCarrier(); - if (!(ready.createdAt === owned.createdAt && nativeComposeStorageDockerCarrierPolicy(ready) === owned.policy && ready.state.Running === false && ready.state.Pid === 0 && ready.state.Status === "created")) refuse(); - await engine(probe(), context); tool(); if (!same(before, await current(context, input.target, !input.readonly))) refuse(); - } }); - if (result.exitCode !== 0 && result.exitCode !== 1) return refuse(); + if ( + !( + ready.createdAt === owned.createdAt && + nativeComposeStorageDockerCarrierPolicy(ready) === owned.policy && + ready.state.Running === false && + ready.state.Pid === 0 && + ready.state.Status === "created" + ) + ) { + refuse(); + } + await engine(probe(), context); + tool(); + if ( + !same(before, await current(context, input.target, !input.readonly)) + ) { + refuse(); + } + }, + }); + if (result.exitCode !== 0 && result.exitCode !== 1) { + return refuse(); + } const response = parseNativeComposeStorageXattrResponse(result.stdout); - if ((response.outcome === "refused") !== (result.exitCode === 1)) return refuse(); + if ((response.outcome === "refused") !== (result.exitCode === 1)) { + return refuse(); + } const stopped = await inspectCarrier(true); - if (!(stopped.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(stopped) === known.policy && - stopped.state.Running === false && stopped.state.Pid === 0 && stopped.state.Status === "exited" && stopped.state.ExitCode === result.exitCode && - stopped.state.Paused === false && stopped.state.Restarting === false && stopped.state.OOMKilled === false && stopped.state.Dead === false && stopped.state.Error === "")) return refuse(); + if ( + !( + stopped.createdAt === known.createdAt && + nativeComposeStorageDockerCarrierPolicy(stopped) === known.policy && + stopped.state.Running === false && + stopped.state.Pid === 0 && + stopped.state.Status === "exited" && + stopped.state.ExitCode === result.exitCode && + stopped.state.Paused === false && + stopped.state.Restarting === false && + stopped.state.OOMKilled === false && + stopped.state.Dead === false && + stopped.state.Error === "" + ) + ) { + return refuse(); + } const repeated = await inspectCarrier(true); - if (!(repeated.createdAt === known.createdAt && nativeComposeStorageDockerCarrierPolicy(repeated) === known.policy && same(repeated.state, stopped.state))) return refuse(); - await checkFiles(files); tool(); + if ( + !( + repeated.createdAt === known.createdAt && + nativeComposeStorageDockerCarrierPolicy(repeated) === known.policy && + same(repeated.state, stopped.state) + ) + ) { + return refuse(); + } + await checkFiles(files); + tool(); const cleanupAuthority = await current(context, input.target); - if (!same(before, cleanupAuthority)) return refuse(); - const removed = await runNativeComposeStorageDockerCommand({ context, files, cleanup: true, args: [docker, "rm", id], timeoutMs: 10_000, beforeSpawn: tool, + if (!same(before, cleanupAuthority)) { + return refuse(); + } + const removed = await runNativeComposeStorageDockerCommand({ + context, + files, + cleanup: true, + args: [docker, "rm", id], + timeoutMs: 10_000, + beforeSpawn: tool, assertAdmitted: async () => { const finalCarrier = await inspectCarrier(true); - if (!(finalCarrier.createdAt === owned.createdAt && nativeComposeStorageDockerCarrierPolicy(finalCarrier) === owned.policy && same(finalCarrier.state, stopped.state))) refuse(); + if ( + !( + finalCarrier.createdAt === owned.createdAt && + nativeComposeStorageDockerCarrierPolicy(finalCarrier) === + owned.policy && + same(finalCarrier.state, stopped.state) + ) + ) { + refuse(); + } await engine(probe(true), context); - tool(); if (!same(before, await current(context, input.target))) refuse(); - } }); - if (removed.exitCode !== 0) return refuse(); - const absent = probe(true); await engine(absent, context); - const inventory = (await absent(["container", "ls", "-a", "--no-trunc", "--format", "{{json .ID}}"])).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as unknown); - if (inventory.some((value) => typeof value !== "string" || !ID.test(value)) || inventory.includes(id) || new Set(inventory).size !== inventory.length) return refuse(); - const selected = (await absent(["container", "ls", "-a", "--no-trunc", "--filter", `label=${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, "--format", "{{json .ID}}"])).trim(); - if (selected !== "") return refuse(); + tool(); + if (!same(before, await current(context, input.target))) { + refuse(); + } + }, + }); + if (removed.exitCode !== 0) { + return refuse(); + } + const absent = probe(true); + await engine(absent, context); + const inventory = ( + await absent([ + "container", + "ls", + "-a", + "--no-trunc", + "--format", + "{{json .ID}}", + ]) + ) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as unknown); + if ( + inventory.some( + (value) => typeof value !== "string" || !ID.test(value) + ) || + inventory.includes(id) || + new Set(inventory).size !== inventory.length + ) { + return refuse(); + } + const selected = ( + await absent([ + "container", + "ls", + "-a", + "--no-trunc", + "--filter", + `label=${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, + "--format", + "{{json .ID}}", + ]) + ).trim(); + if (selected !== "") { + return refuse(); + } const after = await inspect(input.target); - if (!same(after, input.target)) return refuse(); + if (!same(after, input.target)) { + return refuse(); + } await retireInvocationFiles(files); completed = true; - return { artifact, carrierId: id, carrierCreatedAt: known.createdAt, containersAfterCleanup: [], engineId: context.engineId, - invocationId: input.invocationId, readonly: input.readonly, uid: input.uid, gid: input.gid, scope: input.scope, - outcome: "complete", exitCode: result.exitCode, response: result.stdout, target: after, - stopped: { id, running: false, pid: 0, exitCode: result.exitCode } }; + return { + artifact, + carrierId: id, + carrierCreatedAt: known.createdAt, + containersAfterCleanup: [], + engineId: context.engineId, + invocationId: input.invocationId, + readonly: input.readonly, + uid: input.uid, + gid: input.gid, + scope: input.scope, + outcome: "complete", + exitCode: result.exitCode, + response: result.stdout, + target: after, + stopped: { id, running: false, pid: 0, exitCode: result.exitCode }, + }; } finally { // Failure never starts a competing stop/remove after unknown child disposition. // The original prospective/created journal and private input remain authoritative. @@ -377,5 +965,10 @@ export async function createNativeComposeDockerStorageXattrCarrier(opts: Context } } }; - return captureNativeComposeStorageXattrCarrier({ artifact, ports: { inspect, provision, invoke }, signal: context.signal, deadline: context.deadline }); + return captureNativeComposeStorageXattrCarrier({ + artifact, + ports: { inspect, provision, invoke }, + signal: context.signal, + deadline: context.deadline, + }); } diff --git a/src/lib/native-compose-storage-witness-helper-bundle.ts b/src/lib/native-compose-storage-witness-helper-bundle.ts index 826a57d6..ff324263 100644 --- a/src/lib/native-compose-storage-witness-helper-bundle.ts +++ b/src/lib/native-compose-storage-witness-helper-bundle.ts @@ -1,2 +1,4 @@ // Generated from scripts/native-storage-witness-helper.ts. Do not edit the payload. -export const NATIVE_STORAGE_WITNESS_HELPER = "// @bun\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nimport { randomBytes, timingSafeEqual } from \"crypto\";\n\n// src/lib/guards.ts\nfunction isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nvar NATIVE_STORAGE_XATTR_KIND = \"directory-xattr\";\nvar NATIVE_STORAGE_XATTR_VERSION = 1;\nvar NATIVE_STORAGE_XATTR_BYTES = 32;\nvar NATIVE_STORAGE_XATTR_INPUT_LIMIT = 4096;\nvar NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar VALUE = /^[a-f0-9]{64}$/;\nvar DECIMAL = /^(0|[1-9][0-9]{0,19})$/;\nvar UINT64_MAX = 18446744073709551615n;\nvar UINT32_MAX = 4294967295;\nfunction refuseNativeComposeStorageXattr() {\n throw new Error(\"Native storage xattr proof refused; values omitted.\");\n}\nfunction keys(value, expected) {\n return Object.keys(value).sort().join(\",\") === expected;\n}\nfunction uint32(value) {\n return typeof value === \"number\" && Number.isInteger(value) && !Object.is(value, -0) && value >= 0 && value <= UINT32_MAX;\n}\nfunction uint64(value) {\n return typeof value === \"string\" && DECIMAL.test(value) && BigInt(value) <= UINT64_MAX;\n}\nfunction nativeComposeStorageXattrRootValid(value) {\n return isRecord(value) && keys(value, \"device,gid,inode,uid\") && uint64(value.device) && uint64(value.inode) && value.inode !== \"0\" && uint32(value.uid) && uint32(value.gid);\n}\nfunction sameNativeComposeStorageXattrRoot(left, right) {\n return left.device === right.device && left.inode === right.inode && left.uid === right.uid && left.gid === right.gid;\n}\nfunction markerValid(value) {\n return value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && typeof value.name === \"string\" && NAME.test(value.name) && typeof value.valueHex === \"string\" && VALUE.test(value.valueHex);\n}\nfunction decodeNativeComposeStorageXattrRequest(value) {\n if (!isRecord(value)) {\n return refuseNativeComposeStorageXattr();\n }\n if (keys(value, \"kind,operation,version\") && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && value.operation === \"root\") {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: \"root\"\n });\n }\n if (!(keys(value, \"kind,name,operation,root,valueHex,version\") && markerValid(value) && (value.operation === \"seed\" || value.operation === \"verify\") && nativeComposeStorageXattrRootValid(value.root))) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: value.operation,\n name: value.name,\n valueHex: value.valueHex,\n root: Object.freeze({ ...value.root })\n });\n}\nfunction decodeNativeComposeStorageXattrResponse(value) {\n if (!(isRecord(value) && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION)) {\n return refuseNativeComposeStorageXattr();\n }\n if (value.outcome === \"refused\" && keys(value, \"kind,outcome,version\")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n });\n }\n if (!nativeComposeStorageXattrRootValid(value.root)) {\n return refuseNativeComposeStorageXattr();\n }\n const root = Object.freeze({ ...value.root });\n if ((value.outcome === \"root\" || value.outcome === \"seeded\") && keys(value, \"kind,outcome,root,version\")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: value.outcome,\n root\n });\n }\n if (value.outcome === \"verified\" && keys(value, \"kind,outcome,root,valueHex,version\") && typeof value.valueHex === \"string\" && VALUE.test(value.valueHex)) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"verified\",\n root,\n valueHex: value.valueHex\n });\n }\n return refuseNativeComposeStorageXattr();\n}\nfunction encodeNativeComposeStorageXattrResponse(value) {\n return `${JSON.stringify(decodeNativeComposeStorageXattrResponse(value))}\n`;\n}\nfunction parse(text, limit, decode) {\n if (Buffer.byteLength(text) > limit) {\n return refuseNativeComposeStorageXattr();\n }\n try {\n const value = decode(JSON.parse(text));\n if (text !== `${JSON.stringify(value)}\n`) {\n return refuseNativeComposeStorageXattr();\n }\n return value;\n } catch {\n return refuseNativeComposeStorageXattr();\n }\n}\nfunction parseNativeComposeStorageXattrRequest(text) {\n return parse(text, NATIVE_STORAGE_XATTR_INPUT_LIMIT, decodeNativeComposeStorageXattrRequest);\n}\nfunction nativeComposeStorageXattrValueMatches(actual, expectedHex) {\n return actual.byteLength === NATIVE_STORAGE_XATTR_BYTES && VALUE.test(expectedHex) && timingSafeEqual(actual, Buffer.from(expectedHex, \"hex\"));\n}\n\n// src/lib/native-compose-storage-witness-xattr-helper.ts\nfunction root(value) {\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({ ...value });\n}\nfunction requireRoot(actual, expected) {\n if (!sameNativeComposeStorageXattrRoot(root(actual), expected)) {\n refuseNativeComposeStorageXattr();\n }\n}\nfunction runNativeComposeStorageXattrHelper(opts) {\n let fd = null;\n let capturedClose = null;\n try {\n const request = decodeNativeComposeStorageXattrRequest(opts.request);\n const {\n effectiveUid,\n effectiveGid,\n probeRoot,\n openRoot,\n statRoot,\n createXattr,\n syncRoot,\n readXattr,\n closeRoot\n } = opts.kernel;\n capturedClose = closeRoot;\n if (request.operation === \"root\") {\n const before = root(probeRoot());\n requireRoot(probeRoot(), before);\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"root\",\n root: before\n };\n }\n if (effectiveUid() !== request.root.uid || effectiveGid() !== request.root.gid) {\n return refuseNativeComposeStorageXattr();\n }\n fd = openRoot();\n if (!Number.isInteger(fd) || fd < 0) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n let response;\n if (request.operation === \"seed\") {\n createXattr(fd, request.name, Buffer.from(request.valueHex, \"hex\"));\n syncRoot(fd);\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"seeded\",\n root: request.root\n };\n } else {\n const first = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(first, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n const latest = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(latest, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"verified\",\n root: request.root,\n valueHex: Buffer.from(latest).toString(\"hex\")\n };\n }\n const selected = fd;\n fd = null;\n closeRoot(selected);\n return response;\n } catch {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n };\n } finally {\n if (fd !== null && Number.isInteger(fd) && fd >= 0 && capturedClose) {\n try {\n capturedClose(fd);\n } catch {}\n }\n }\n}\n\n// src/lib/native-compose-storage-witness-xattr-linux.ts\nimport {\n closeSync,\n constants,\n fstatSync,\n fsyncSync,\n lstatSync,\n openSync\n} from \"fs\";\nvar XATTR_NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar NATIVE_STORAGE_XATTR_LINUX_ABI = Object.freeze({\n version: 1,\n root: \"/hack-storage-witness\",\n xattrCreate: 1,\n openCloseExec: 1 << 19,\n fGetFd: 1,\n fdCloseExec: 1,\n pointerBits: 64,\n sizeTBits: 64,\n signedSizeBits: 64,\n arm64: Object.freeze({\n libc: \"/lib/aarch64-linux-gnu/libc.so.6\",\n directory: 1 << 14,\n noFollow: 1 << 15\n }),\n x64: Object.freeze({\n libc: \"/lib/x86_64-linux-gnu/libc.so.6\",\n directory: 1 << 16,\n noFollow: 1 << 17\n })\n});\nfunction directory(info) {\n if (!info.isDirectory() || info.isSymbolicLink()) {\n return refuseNativeComposeStorageXattr();\n }\n const value = {\n device: info.dev.toString(),\n inode: info.ino.toString(),\n uid: Number(info.uid),\n gid: Number(info.gid)\n };\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze(value);\n}\nfunction libc() {\n let abi = null;\n if (process.arch === \"arm64\") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.arm64;\n } else if (process.arch === \"x64\") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.x64;\n }\n if (process.platform !== \"linux\" || !abi || constants.O_DIRECTORY !== abi.directory || constants.O_NOFOLLOW !== abi.noFollow || constants.O_RDONLY !== 0 || !process.geteuid || !process.getegid || !process.getuid || !process.getgid || process.getuid() !== process.geteuid() || process.getgid() !== process.getegid()) {\n return refuseNativeComposeStorageXattr();\n }\n return abi.libc;\n}\nasync function createNativeComposeStorageXattrLinuxKernel() {\n const libraryPath = libc();\n const { dlopen, FFIType, ptr } = await import(\"bun:ffi\");\n const library = dlopen(libraryPath, {\n fsetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.i32],\n returns: FFIType.i32\n },\n fgetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64],\n returns: FFIType.i64\n },\n fcntl: {\n args: [FFIType.i32, FFIType.i32, FFIType.i32],\n returns: FFIType.i32\n }\n });\n const { fsetxattr, fgetxattr, fcntl } = library.symbols;\n const held = new Set;\n let closed = false;\n const active = () => {\n if (closed) {\n refuseNativeComposeStorageXattr();\n }\n };\n const fdOwned = (fd) => {\n active();\n if (!held.has(fd)) {\n refuseNativeComposeStorageXattr();\n }\n };\n const nameBytes = (name) => {\n if (!XATTR_NAME.test(name)) {\n return refuseNativeComposeStorageXattr();\n }\n return Buffer.from(`${name}\\x00`, \"ascii\");\n };\n const kernel = Object.freeze({\n effectiveUid: () => process.geteuid?.() ?? refuseNativeComposeStorageXattr(),\n effectiveGid: () => process.getegid?.() ?? refuseNativeComposeStorageXattr(),\n probeRoot() {\n active();\n return directory(lstatSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, {\n bigint: true\n }));\n },\n openRoot() {\n active();\n const fd = openSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | NATIVE_STORAGE_XATTR_LINUX_ABI.openCloseExec);\n held.add(fd);\n const flags = fcntl(fd, NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd, 0);\n if (flags < 0 || (flags & NATIVE_STORAGE_XATTR_LINUX_ABI.fdCloseExec) === 0) {\n held.delete(fd);\n closeSync(fd);\n return refuseNativeComposeStorageXattr();\n }\n return fd;\n },\n statRoot(fd) {\n fdOwned(fd);\n return directory(fstatSync(fd, { bigint: true }));\n },\n createXattr(fd, name, value) {\n fdOwned(fd);\n if (value.byteLength !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const bytes = Buffer.from(value);\n if (fsetxattr(fd, ptr(selectedName), ptr(bytes), BigInt(bytes.length), NATIVE_STORAGE_XATTR_LINUX_ABI.xattrCreate) !== 0) {\n return refuseNativeComposeStorageXattr();\n }\n },\n syncRoot(fd) {\n fdOwned(fd);\n fsyncSync(fd);\n },\n readXattr(fd, name, bytes) {\n fdOwned(fd);\n if (bytes !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const value = Buffer.alloc(bytes);\n if (fgetxattr(fd, ptr(selectedName), ptr(value), BigInt(bytes)) !== BigInt(bytes)) {\n return refuseNativeComposeStorageXattr();\n }\n return new Uint8Array(value);\n },\n closeRoot(fd) {\n fdOwned(fd);\n held.delete(fd);\n closeSync(fd);\n }\n });\n return Object.freeze({\n kernel,\n close() {\n active();\n closed = true;\n try {\n for (const fd of held) {\n closeSync(fd);\n }\n } finally {\n held.clear();\n library.close();\n }\n }\n });\n}\n\n// scripts/native-storage-witness-helper.ts\nasync function input() {\n const reader = Bun.stdin.stream().getReader();\n const chunks = [];\n let length = 0;\n try {\n while (true) {\n const next = await reader.read();\n if (next.done) {\n break;\n }\n length += next.value.byteLength;\n if (length > NATIVE_STORAGE_XATTR_INPUT_LIMIT) {\n await reader.cancel();\n return refuseNativeComposeStorageXattr();\n }\n chunks.push(next.value);\n }\n } finally {\n reader.releaseLock();\n }\n return new TextDecoder(\"utf-8\", { fatal: true }).decode(Buffer.concat(chunks));\n}\nfunction refused() {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: \"refused\"\n };\n}\nasync function nativeStorageWitnessHelperMain() {\n let response = refused();\n let close = null;\n try {\n const request = parseNativeComposeStorageXattrRequest(await input());\n const selected = await createNativeComposeStorageXattrLinuxKernel();\n close = selected.close;\n response = runNativeComposeStorageXattrHelper({\n request,\n kernel: selected.kernel\n });\n } catch {\n response = refused();\n } finally {\n if (close) {\n try {\n close();\n } catch {\n response = refused();\n }\n }\n }\n try {\n await Bun.write(Bun.stdout, encodeNativeComposeStorageXattrResponse(response));\n } catch {\n return 1;\n }\n return response.outcome === \"refused\" ? 1 : 0;\n}\nif (import.meta.main) {\n process.exitCode = await nativeStorageWitnessHelperMain();\n}\nexport {\n nativeStorageWitnessHelperMain\n};\n"; +export const NATIVE_STORAGE_WITNESS_HELPER = + // biome-ignore lint/suspicious/noTemplateCurlyInString: Preserve qualified helper bytes without host interpolation. + '// @bun\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nimport { randomBytes, timingSafeEqual } from "crypto";\n\n// src/lib/guards.ts\nfunction isRecord(value) {\n return typeof value === "object" && value !== null && !Array.isArray(value);\n}\n\n// src/lib/native-compose-storage-witness-xattr-codec.ts\nvar NATIVE_STORAGE_XATTR_KIND = "directory-xattr";\nvar NATIVE_STORAGE_XATTR_VERSION = 1;\nvar NATIVE_STORAGE_XATTR_BYTES = 32;\nvar NATIVE_STORAGE_XATTR_INPUT_LIMIT = 4096;\nvar NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar VALUE = /^[a-f0-9]{64}$/;\nvar DECIMAL = /^(0|[1-9][0-9]{0,19})$/;\nvar UINT64_MAX = 18446744073709551615n;\nvar UINT32_MAX = 4294967295;\nfunction refuseNativeComposeStorageXattr() {\n throw new Error("Native storage xattr proof refused; values omitted.");\n}\nfunction keys(value, expected) {\n return Object.keys(value).sort().join(",") === expected;\n}\nfunction uint32(value) {\n return typeof value === "number" && Number.isInteger(value) && !Object.is(value, -0) && value >= 0 && value <= UINT32_MAX;\n}\nfunction uint64(value) {\n return typeof value === "string" && DECIMAL.test(value) && BigInt(value) <= UINT64_MAX;\n}\nfunction nativeComposeStorageXattrRootValid(value) {\n return isRecord(value) && keys(value, "device,gid,inode,uid") && uint64(value.device) && uint64(value.inode) && value.inode !== "0" && uint32(value.uid) && uint32(value.gid);\n}\nfunction sameNativeComposeStorageXattrRoot(left, right) {\n return left.device === right.device && left.inode === right.inode && left.uid === right.uid && left.gid === right.gid;\n}\nfunction markerValid(value) {\n return value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && typeof value.name === "string" && NAME.test(value.name) && typeof value.valueHex === "string" && VALUE.test(value.valueHex);\n}\nfunction decodeNativeComposeStorageXattrRequest(value) {\n if (!isRecord(value)) {\n return refuseNativeComposeStorageXattr();\n }\n if (keys(value, "kind,operation,version") && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION && value.operation === "root") {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: "root"\n });\n }\n if (!(keys(value, "kind,name,operation,root,valueHex,version") && markerValid(value) && (value.operation === "seed" || value.operation === "verify") && nativeComposeStorageXattrRootValid(value.root))) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n operation: value.operation,\n name: value.name,\n valueHex: value.valueHex,\n root: Object.freeze({ ...value.root })\n });\n}\nfunction decodeNativeComposeStorageXattrResponse(value) {\n if (!(isRecord(value) && value.kind === NATIVE_STORAGE_XATTR_KIND && value.version === NATIVE_STORAGE_XATTR_VERSION)) {\n return refuseNativeComposeStorageXattr();\n }\n if (value.outcome === "refused" && keys(value, "kind,outcome,version")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "refused"\n });\n }\n if (!nativeComposeStorageXattrRootValid(value.root)) {\n return refuseNativeComposeStorageXattr();\n }\n const root = Object.freeze({ ...value.root });\n if ((value.outcome === "root" || value.outcome === "seeded") && keys(value, "kind,outcome,root,version")) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: value.outcome,\n root\n });\n }\n if (value.outcome === "verified" && keys(value, "kind,outcome,root,valueHex,version") && typeof value.valueHex === "string" && VALUE.test(value.valueHex)) {\n return Object.freeze({\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "verified",\n root,\n valueHex: value.valueHex\n });\n }\n return refuseNativeComposeStorageXattr();\n}\nfunction encodeNativeComposeStorageXattrResponse(value) {\n return `${JSON.stringify(decodeNativeComposeStorageXattrResponse(value))}\n`;\n}\nfunction parse(text, limit, decode) {\n if (Buffer.byteLength(text) > limit) {\n return refuseNativeComposeStorageXattr();\n }\n try {\n const value = decode(JSON.parse(text));\n if (text !== `${JSON.stringify(value)}\n`) {\n return refuseNativeComposeStorageXattr();\n }\n return value;\n } catch {\n return refuseNativeComposeStorageXattr();\n }\n}\nfunction parseNativeComposeStorageXattrRequest(text) {\n return parse(text, NATIVE_STORAGE_XATTR_INPUT_LIMIT, decodeNativeComposeStorageXattrRequest);\n}\nfunction nativeComposeStorageXattrValueMatches(actual, expectedHex) {\n return actual.byteLength === NATIVE_STORAGE_XATTR_BYTES && VALUE.test(expectedHex) && timingSafeEqual(actual, Buffer.from(expectedHex, "hex"));\n}\n\n// src/lib/native-compose-storage-witness-xattr-helper.ts\nfunction root(value) {\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze({ ...value });\n}\nfunction requireRoot(actual, expected) {\n if (!sameNativeComposeStorageXattrRoot(root(actual), expected)) {\n refuseNativeComposeStorageXattr();\n }\n}\nfunction runNativeComposeStorageXattrHelper(opts) {\n let fd = null;\n let capturedClose = null;\n try {\n const request = decodeNativeComposeStorageXattrRequest(opts.request);\n const {\n effectiveUid,\n effectiveGid,\n probeRoot,\n openRoot,\n statRoot,\n createXattr,\n syncRoot,\n readXattr,\n closeRoot\n } = opts.kernel;\n capturedClose = closeRoot;\n if (request.operation === "root") {\n const before = root(probeRoot());\n requireRoot(probeRoot(), before);\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "root",\n root: before\n };\n }\n if (effectiveUid() !== request.root.uid || effectiveGid() !== request.root.gid) {\n return refuseNativeComposeStorageXattr();\n }\n fd = openRoot();\n if (!Number.isInteger(fd) || fd < 0) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n let response;\n if (request.operation === "seed") {\n createXattr(fd, request.name, Buffer.from(request.valueHex, "hex"));\n syncRoot(fd);\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "seeded",\n root: request.root\n };\n } else {\n const first = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(first, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n const latest = readXattr(fd, request.name, NATIVE_STORAGE_XATTR_BYTES);\n if (!nativeComposeStorageXattrValueMatches(latest, request.valueHex)) {\n return refuseNativeComposeStorageXattr();\n }\n requireRoot(statRoot(fd), request.root);\n response = {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "verified",\n root: request.root,\n valueHex: Buffer.from(latest).toString("hex")\n };\n }\n const selected = fd;\n fd = null;\n closeRoot(selected);\n return response;\n } catch {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "refused"\n };\n } finally {\n if (fd !== null && Number.isInteger(fd) && fd >= 0 && capturedClose) {\n try {\n capturedClose(fd);\n } catch {}\n }\n }\n}\n\n// src/lib/native-compose-storage-witness-xattr-linux.ts\nimport {\n closeSync,\n constants,\n fstatSync,\n fsyncSync,\n lstatSync,\n openSync\n} from "fs";\nvar XATTR_NAME = /^user\\.hack\\.storage\\.[a-f0-9]{64}$/;\nvar NATIVE_STORAGE_XATTR_LINUX_ABI = Object.freeze({\n version: 1,\n root: "/hack-storage-witness",\n xattrCreate: 1,\n openCloseExec: 1 << 19,\n fGetFd: 1,\n fdCloseExec: 1,\n pointerBits: 64,\n sizeTBits: 64,\n signedSizeBits: 64,\n arm64: Object.freeze({\n libc: "/lib/aarch64-linux-gnu/libc.so.6",\n directory: 1 << 14,\n noFollow: 1 << 15\n }),\n x64: Object.freeze({\n libc: "/lib/x86_64-linux-gnu/libc.so.6",\n directory: 1 << 16,\n noFollow: 1 << 17\n })\n});\nfunction directory(info) {\n if (!info.isDirectory() || info.isSymbolicLink()) {\n return refuseNativeComposeStorageXattr();\n }\n const value = {\n device: info.dev.toString(),\n inode: info.ino.toString(),\n uid: Number(info.uid),\n gid: Number(info.gid)\n };\n if (!nativeComposeStorageXattrRootValid(value)) {\n return refuseNativeComposeStorageXattr();\n }\n return Object.freeze(value);\n}\nfunction libc() {\n let abi = null;\n if (process.arch === "arm64") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.arm64;\n } else if (process.arch === "x64") {\n abi = NATIVE_STORAGE_XATTR_LINUX_ABI.x64;\n }\n if (process.platform !== "linux" || !abi || constants.O_DIRECTORY !== abi.directory || constants.O_NOFOLLOW !== abi.noFollow || constants.O_RDONLY !== 0 || !process.geteuid || !process.getegid || !process.getuid || !process.getgid || process.getuid() !== process.geteuid() || process.getgid() !== process.getegid()) {\n return refuseNativeComposeStorageXattr();\n }\n return abi.libc;\n}\nasync function createNativeComposeStorageXattrLinuxKernel() {\n const libraryPath = libc();\n const { dlopen, FFIType, ptr } = await import("bun:ffi");\n const library = dlopen(libraryPath, {\n fsetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.i32],\n returns: FFIType.i32\n },\n fgetxattr: {\n args: [FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u64],\n returns: FFIType.i64\n },\n fcntl: {\n args: [FFIType.i32, FFIType.i32, FFIType.i32],\n returns: FFIType.i32\n }\n });\n const { fsetxattr, fgetxattr, fcntl } = library.symbols;\n const held = new Set;\n let closed = false;\n const active = () => {\n if (closed) {\n refuseNativeComposeStorageXattr();\n }\n };\n const fdOwned = (fd) => {\n active();\n if (!held.has(fd)) {\n refuseNativeComposeStorageXattr();\n }\n };\n const nameBytes = (name) => {\n if (!XATTR_NAME.test(name)) {\n return refuseNativeComposeStorageXattr();\n }\n return Buffer.from(`${name}\\x00`, "ascii");\n };\n const kernel = Object.freeze({\n effectiveUid: () => process.geteuid?.() ?? refuseNativeComposeStorageXattr(),\n effectiveGid: () => process.getegid?.() ?? refuseNativeComposeStorageXattr(),\n probeRoot() {\n active();\n return directory(lstatSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, {\n bigint: true\n }));\n },\n openRoot() {\n active();\n const fd = openSync(NATIVE_STORAGE_XATTR_LINUX_ABI.root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | NATIVE_STORAGE_XATTR_LINUX_ABI.openCloseExec);\n held.add(fd);\n const flags = fcntl(fd, NATIVE_STORAGE_XATTR_LINUX_ABI.fGetFd, 0);\n if (flags < 0 || (flags & NATIVE_STORAGE_XATTR_LINUX_ABI.fdCloseExec) === 0) {\n held.delete(fd);\n closeSync(fd);\n return refuseNativeComposeStorageXattr();\n }\n return fd;\n },\n statRoot(fd) {\n fdOwned(fd);\n return directory(fstatSync(fd, { bigint: true }));\n },\n createXattr(fd, name, value) {\n fdOwned(fd);\n if (value.byteLength !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const bytes = Buffer.from(value);\n if (fsetxattr(fd, ptr(selectedName), ptr(bytes), BigInt(bytes.length), NATIVE_STORAGE_XATTR_LINUX_ABI.xattrCreate) !== 0) {\n return refuseNativeComposeStorageXattr();\n }\n },\n syncRoot(fd) {\n fdOwned(fd);\n fsyncSync(fd);\n },\n readXattr(fd, name, bytes) {\n fdOwned(fd);\n if (bytes !== NATIVE_STORAGE_XATTR_BYTES) {\n return refuseNativeComposeStorageXattr();\n }\n const selectedName = nameBytes(name);\n const value = Buffer.alloc(bytes);\n if (fgetxattr(fd, ptr(selectedName), ptr(value), BigInt(bytes)) !== BigInt(bytes)) {\n return refuseNativeComposeStorageXattr();\n }\n return new Uint8Array(value);\n },\n closeRoot(fd) {\n fdOwned(fd);\n held.delete(fd);\n closeSync(fd);\n }\n });\n return Object.freeze({\n kernel,\n close() {\n active();\n closed = true;\n try {\n for (const fd of held) {\n closeSync(fd);\n }\n } finally {\n held.clear();\n library.close();\n }\n }\n });\n}\n\n// scripts/native-storage-witness-helper.ts\nasync function input() {\n const reader = Bun.stdin.stream().getReader();\n const chunks = [];\n let length = 0;\n try {\n while (true) {\n const next = await reader.read();\n if (next.done) {\n break;\n }\n length += next.value.byteLength;\n if (length > NATIVE_STORAGE_XATTR_INPUT_LIMIT) {\n await reader.cancel();\n return refuseNativeComposeStorageXattr();\n }\n chunks.push(next.value);\n }\n } finally {\n reader.releaseLock();\n }\n return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));\n}\nfunction refused() {\n return {\n kind: NATIVE_STORAGE_XATTR_KIND,\n version: NATIVE_STORAGE_XATTR_VERSION,\n outcome: "refused"\n };\n}\nasync function nativeStorageWitnessHelperMain() {\n let response = refused();\n let close = null;\n try {\n const request = parseNativeComposeStorageXattrRequest(await input());\n const selected = await createNativeComposeStorageXattrLinuxKernel();\n close = selected.close;\n response = runNativeComposeStorageXattrHelper({\n request,\n kernel: selected.kernel\n });\n } catch {\n response = refused();\n } finally {\n if (close) {\n try {\n close();\n } catch {\n response = refused();\n }\n }\n }\n try {\n await Bun.write(Bun.stdout, encodeNativeComposeStorageXattrResponse(response));\n } catch {\n return 1;\n }\n return response.outcome === "refused" ? 1 : 0;\n}\nif (import.meta.main) {\n process.exitCode = await nativeStorageWitnessHelperMain();\n}\nexport {\n nativeStorageWitnessHelperMain\n};\n'; diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 83f1b5c5..de61f702 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -131,10 +131,23 @@ export async function run( privateIo: opts.privateIo ? { ...opts.privateIo } : undefined, }; const command = [...cmd]; - if (options.privateIo && (options.forwardSignals === true || - !(typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) || - ![options.privateIo.stdin, options.privateIo.stdout, options.privateIo.stderr].every((fd) => Number.isInteger(fd) && fd >= 0 && fd <= 2_147_483_647))) { - throw new Error("Private subprocess descriptors require a non-TTY owned invocation."); + if ( + options.privateIo && + (options.forwardSignals === true || + !( + typeof options.timeoutMs === "number" && + Number.isFinite(options.timeoutMs) && + options.timeoutMs > 0 + ) || + ![ + options.privateIo.stdin, + options.privateIo.stdout, + options.privateIo.stderr, + ].every((fd) => Number.isInteger(fd) && fd >= 0 && fd <= 2_147_483_647)) + ) { + throw new Error( + "Private subprocess descriptors require a non-TTY owned invocation." + ); } const signal = options.signal; if (signal?.aborted) { @@ -172,7 +185,9 @@ export async function run( cwd: options.cwd, env: buildSpawnEnv(options.env, options.unsetEnvKeys), stdin: options.privateIo?.stdin ?? options.stdin ?? "inherit", - stdout: options.privateIo?.stdout ?? (options.stdout === "stderr" ? 2 : (options.stdout ?? "inherit")), + stdout: + options.privateIo?.stdout ?? + (options.stdout === "stderr" ? 2 : (options.stdout ?? "inherit")), stderr: options.privateIo?.stderr ?? options.stderr ?? "inherit", detached: ownsProcessGroup, }); diff --git a/tests/native-compose-storage-witness-docker-io.test.ts b/tests/native-compose-storage-witness-docker-io.test.ts index 6bf9f698..d7da3cd2 100644 --- a/tests/native-compose-storage-witness-docker-io.test.ts +++ b/tests/native-compose-storage-witness-docker-io.test.ts @@ -1,102 +1,244 @@ import { afterEach, expect, test } from "bun:test"; import { fstatSync } from "node:fs"; -import { link, mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { + link, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { holdDirectory, writeExclusive } from "../src/lib/native-compose-private-state.ts"; -import { createNativeComposeStorageDockerEmptyLeaf, holdNativeComposeStorageDockerIo } from "../src/lib/native-compose-storage-witness-docker-io.ts"; +import { + holdDirectory, + writeExclusive, +} from "../src/lib/native-compose-private-state.ts"; import { runNativeComposeStorageDockerCommand } from "../src/lib/native-compose-storage-witness-docker.ts"; +import { + createNativeComposeStorageDockerEmptyLeaf, + holdNativeComposeStorageDockerIo, +} from "../src/lib/native-compose-storage-witness-docker-io.ts"; import { run } from "../src/lib/shell.ts"; const roots: string[] = []; -afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); async function fixture() { - const root = await realpath(await mkdtemp(join(tmpdir(), "native-storage-held-io-"))); + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-storage-held-io-")) + ); roots.push(root); const directory = await holdDirectory(root, true); - const input = join(root, "input"), stdout = join(root, "stdout"), stderr = join(root, "stderr"); + const input = join(root, "input"), + stdout = join(root, "stdout"), + stderr = join(root, "stderr"); const text = "synthetic private input"; - const inputInfo = await writeExclusive(input, text), outInfo = await createNativeComposeStorageDockerEmptyLeaf(stdout), errInfo = await createNativeComposeStorageDockerEmptyLeaf(stderr); - const io = await holdNativeComposeStorageDockerIo({ directories: [directory], input: { path: input, info: inputInfo, text }, - stdout: { path: stdout, info: outInfo }, stderr: { path: stderr, info: errInfo }, limit: 4096 }); + const inputInfo = await writeExclusive(input, text), + outInfo = await createNativeComposeStorageDockerEmptyLeaf(stdout), + errInfo = await createNativeComposeStorageDockerEmptyLeaf(stderr); + const io = await holdNativeComposeStorageDockerIo({ + directories: [directory], + input: { path: input, info: inputInfo, text }, + stdout: { path: stdout, info: outInfo }, + stderr: { path: stderr, info: errInfo }, + limit: 4096, + }); return { root, directory, input, inputInfo, stdout, stderr, text, io }; } test("held IO passes exact stdin and preserves caller descriptors through completion", async () => { const f = await fixture(); try { let exited = false; - const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec cat'], { privateIo: f.io.descriptors, timeoutMs: 3000, + const code = await run(["/bin/sh", "-c", "ulimit -f 8; exec cat"], { + privateIo: f.io.descriptors, + timeoutMs: 3000, beforeSpawn: f.io.assertFresh, - onExit: async () => { exited = true; expect(fstatSync(f.io.descriptors.stdout).isFile()).toBe(true); } }); - expect(code).toBe(0); expect(exited).toBe(true); + onExit: async () => { + exited = true; + expect(fstatSync(f.io.descriptors.stdout).isFile()).toBe(true); + }, + }); + expect(code).toBe(0); + expect(exited).toBe(true); expect(f.io.read()).toEqual({ stdout: f.text, stderr: "" }); expect(fstatSync(f.io.descriptors.stdin).isFile()).toBe(true); - } finally { await f.io.close(); await f.directory.file.close(); } + } finally { + await f.io.close(); + await f.directory.file.close(); + } }); test("separate empty command leaf never overwrites existing bytes or follows a symlink", async () => { const f = await fixture(); try { - await expect(createNativeComposeStorageDockerEmptyLeaf(f.input)).rejects.toThrow(); - const alias = join(f.root, "alias"); await symlink(f.input, alias); - await expect(createNativeComposeStorageDockerEmptyLeaf(alias)).rejects.toThrow(); + await expect( + createNativeComposeStorageDockerEmptyLeaf(f.input) + ).rejects.toThrow(); + const alias = join(f.root, "alias"); + await symlink(f.input, alias); + await expect( + createNativeComposeStorageDockerEmptyLeaf(alias) + ).rejects.toThrow(); expect(await readFile(f.input, "utf8")).toBe(f.text); - await expect(writeExclusive(join(f.root, "receipt-empty"), "")).rejects.toThrow("values omitted"); - } finally { await f.io.close(); await f.directory.file.close(); } + await expect( + writeExclusive(join(f.root, "receipt-empty"), "") + ).rejects.toThrow("values omitted"); + } finally { + await f.io.close(); + await f.directory.file.close(); + } +}); +test.each([ + "input-replace", + "input-symlink", + "stdout-replace", + "stderr-replace", + "input-hardlink", + "input-change", + "stdout-hardlink", + "stdout-symlink", +] as const)("last admission await %s refuses before spawn and preserves the ungranted file", async (failure) => { + const f = await fixture(); + const foreign = join(f.root, "ungranted"); + await writeFile(foreign, "preserved foreign bytes", { mode: 0o600 }); + let admitted = false; + const files = { + held: [f.directory], + path: f.root, + input: f.input, + inputInfo: f.inputInfo, + requestText: f.text, + captures: [] as { path: string; info: typeof f.inputInfo }[], + }; + try { + await expect( + runNativeComposeStorageDockerCommand({ + files, + context: { + signal: new AbortController().signal, + deadline: Date.now() + 3000, + }, + args: ["/bin/cat"], + timeoutMs: 3000, + beforeSpawn: () => { + admitted = true; + }, + assertAdmitted: async () => { + const selected = failure.startsWith("input") + ? f.input + : (files.captures[failure.startsWith("stdout") ? 0 : 1]?.path ?? + ""); + if (failure.endsWith("replace")) { + await rename(selected, `${selected}.original`); + await link(foreign, selected); + } else if (failure.endsWith("symlink")) { + await rename(selected, `${selected}.original`); + await symlink(foreign, selected); + } else if (failure.endsWith("hardlink")) { + await link(selected, `${selected}.alias`); + } else { + await writeFile(selected, "substituted input"); + } + }, + }) + ).rejects.toThrow("values omitted"); + expect(admitted).toBe(false); + expect(await readFile(foreign, "utf8")).toBe("preserved foreign bytes"); + } finally { + await f.io.close(); + await f.directory.file.close(); + } }); -test.each(["input-replace", "input-symlink", "stdout-replace", "stderr-replace", "input-hardlink", "input-change", "stdout-hardlink", "stdout-symlink"] as const)( - "last admission await %s refuses before spawn and preserves the ungranted file", async (failure) => { - const f = await fixture(); - const foreign = join(f.root, "ungranted"); - await writeFile(foreign, "preserved foreign bytes", { mode: 0o600 }); - let admitted = false; - const files = { held: [f.directory], path: f.root, input: f.input, inputInfo: f.inputInfo, requestText: f.text, - captures: [] as { path: string; info: typeof f.inputInfo }[] }; - try { - await expect(runNativeComposeStorageDockerCommand({ files, context: { signal: new AbortController().signal, deadline: Date.now() + 3000 }, - args: ["/bin/cat"], timeoutMs: 3000, beforeSpawn: () => { admitted = true; }, assertAdmitted: async () => { - const selected = failure.startsWith("input") ? f.input : files.captures[failure.startsWith("stdout") ? 0 : 1]?.path ?? ""; - if (failure.endsWith("replace")) { - await rename(selected, `${selected}.original`); await link(foreign, selected); - } else if (failure.endsWith("symlink")) { - await rename(selected, `${selected}.original`); await symlink(foreign, selected); - } else if (failure.endsWith("hardlink")) await link(selected, `${selected}.alias`); - else await writeFile(selected, "substituted input"); - } })).rejects.toThrow("values omitted"); - expect(admitted).toBe(false); - expect(await readFile(foreign, "utf8")).toBe("preserved foreign bytes"); - } finally { await f.io.close(); await f.directory.file.close(); } - }); test("cancellation at the final admission leaves captures empty and spawns no child", async () => { - const f = await fixture(), controller = new AbortController(); + const f = await fixture(), + controller = new AbortController(); let spawned = false; try { - const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec cat'], { privateIo: f.io.descriptors, timeoutMs: 3000, - signal: controller.signal, beforeSpawn: () => { f.io.assertFresh(); controller.abort(); }, - onSpawn: async () => { spawned = true; } }); - expect(code).toBe(143); expect(spawned).toBe(false); + const code = await run(["/bin/sh", "-c", "ulimit -f 8; exec cat"], { + privateIo: f.io.descriptors, + timeoutMs: 3000, + signal: controller.signal, + beforeSpawn: () => { + f.io.assertFresh(); + controller.abort(); + }, + onSpawn: async () => { + spawned = true; + }, + }); + expect(code).toBe(143); + expect(spawned).toBe(false); expect(f.io.read()).toEqual({ stdout: "", stderr: "" }); - } finally { await f.io.close(); await f.directory.file.close(); } + } finally { + await f.io.close(); + await f.directory.file.close(); + } }); test("actual transport refuses an abort during its last admission await without child effects", async () => { - const f = await fixture(), controller = new AbortController(); - const files = { held: [f.directory], path: f.root, input: f.input, inputInfo: f.inputInfo, requestText: f.text, - captures: [] as { path: string; info: typeof f.inputInfo }[] }; + const f = await fixture(), + controller = new AbortController(); + const files = { + held: [f.directory], + path: f.root, + input: f.input, + inputInfo: f.inputInfo, + requestText: f.text, + captures: [] as { path: string; info: typeof f.inputInfo }[], + }; let admitted = false; try { - await expect(runNativeComposeStorageDockerCommand({ files, context: { signal: controller.signal, deadline: Date.now() + 3000 }, - args: ["/bin/cat"], timeoutMs: 3000, beforeSpawn: () => { admitted = true; }, - assertAdmitted: async () => { await Promise.resolve(); controller.abort(); } })).rejects.toThrow("values omitted"); + await expect( + runNativeComposeStorageDockerCommand({ + files, + context: { signal: controller.signal, deadline: Date.now() + 3000 }, + args: ["/bin/cat"], + timeoutMs: 3000, + beforeSpawn: () => { + admitted = true; + }, + assertAdmitted: async () => { + await Promise.resolve(); + controller.abort(); + }, + }) + ).rejects.toThrow("values omitted"); expect(admitted).toBe(false); - for (const capture of files.captures) expect(await readFile(capture.path, "utf8")).toBe(""); - } finally { await f.io.close(); await f.directory.file.close(); } + for (const capture of files.captures) { + expect(await readFile(capture.path, "utf8")).toBe(""); + } + } finally { + await f.io.close(); + await f.directory.file.close(); + } }); test("fixed wrapper quota bounds capture growth and oversized output cannot be accepted", async () => { const f = await fixture(); try { - const code = await run(["/bin/sh", "-c", 'ulimit -f 8; exec /bin/sh -c "while :; do printf 0123456789; done"'], { - privateIo: f.io.descriptors, timeoutMs: 3000, beforeSpawn: f.io.assertFresh }); - expect(code).not.toBe(0); expect(code).not.toBe(124); + const code = await run( + [ + "/bin/sh", + "-c", + 'ulimit -f 8; exec /bin/sh -c "while :; do printf 0123456789; done"', + ], + { + privateIo: f.io.descriptors, + timeoutMs: 3000, + beforeSpawn: f.io.assertFresh, + } + ); + expect(code).not.toBe(0); + expect(code).not.toBe(124); expect(fstatSync(f.io.descriptors.stdout).size).toBeLessThanOrEqual(8192); - if (fstatSync(f.io.descriptors.stdout).size > 4096) expect(() => f.io.read()).toThrow("values omitted"); - } finally { await f.io.close(); await f.directory.file.close(); } + if (fstatSync(f.io.descriptors.stdout).size > 4096) { + expect(() => f.io.read()).toThrow("values omitted"); + } + } finally { + await f.io.close(); + await f.directory.file.close(); + } }); diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 57dea246..843fae7e 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -12,7 +12,10 @@ import { NATIVE_STORAGE_DOCKER_ARTIFACT, nativeComposeStorageDockerHelper, } from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; -import { nativeComposeStorageDockerPathsOverlap, observeNativeComposeStorageDockerTarget } from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; +import { + nativeComposeStorageDockerPathsOverlap, + observeNativeComposeStorageDockerTarget, +} from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; import { checkNativeComposeStorageDockerCarrier, NATIVE_STORAGE_CARRIER_LABEL, @@ -21,58 +24,155 @@ import { } from "../src/lib/native-compose-storage-witness-docker-policy.ts"; import type { NativeComposeStorageXattrInvocation } from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; -const roots: string[] = [], stores: NativeComposeGenerationStore[] = []; +const roots: string[] = [], + stores: NativeComposeGenerationStore[] = []; const engineId = "engine-fixture"; const selection = { name: "owned_data", storage: "data" }; const createdAt = "2026-10-08T12:00:00Z"; afterEach(async () => { await Promise.all(stores.splice(0).map((store) => store.close())); - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); }); -async function withBinding(run: (value: NativeComposeMaterialBinding) => Promise) { - const root = await realpath(await mkdtemp(join(tmpdir(), "native-storage-docker-"))); +async function withBinding( + run: (value: NativeComposeMaterialBinding) => Promise +) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-storage-docker-")) + ); roots.push(root); await mkdir(join(root, ".hack")); - await Bun.write(join(root, ".hack/hack.project.json"), JSON.stringify({ schema_version: 1, name: "storage" })); - const store = await openNativeComposeGenerationStore({ projectRoot: root, instance: null }); + await Bun.write( + join(root, ".hack/hack.project.json"), + JSON.stringify({ schema_version: 1, name: "storage" }) + ); + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + }); stores.push(store); await store.withMutation(async (mutation) => { const reservation = mutation.reserveGeneration(); - const labels = { "io.hack.native-config.version": "1", "io.hack.native-config.owner": store.identity.ownerToken, - "io.hack.native-config.instance": store.identity.composeProject }; - const generation = await mutation.publish({ reservation, profiles: [], inputRevision: "a".repeat(64), assertFresh: async () => {}, composeJson: JSON.stringify({ - name: store.identity.composeProject, - services: { app: { image: "synthetic:1", labels: { ...labels, "io.hack.native-config.generation": reservation.generationId, "io.hack.native-config.workload": "service" } } }, - volumes: { data: { name: selection.name, labels: { ...labels, "io.hack.native-config.storage": selection.storage } } }, - }) }); - await mutation.runEffect({ generation, operation: "up", assertOwned: async () => {}, assertFresh: async () => {}, + const labels = { + "io.hack.native-config.version": "1", + "io.hack.native-config.owner": store.identity.ownerToken, + "io.hack.native-config.instance": store.identity.composeProject, + }; + const generation = await mutation.publish({ + reservation, + profiles: [], + inputRevision: "a".repeat(64), + assertFresh: async () => {}, + composeJson: JSON.stringify({ + name: store.identity.composeProject, + services: { + app: { + image: "synthetic:1", + labels: { + ...labels, + "io.hack.native-config.generation": reservation.generationId, + "io.hack.native-config.workload": "service", + }, + }, + }, + volumes: { + data: { + name: selection.name, + labels: { + ...labels, + "io.hack.native-config.storage": selection.storage, + }, + }, + }, + }), + }); + await mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, effect: async () => { - await run(await assertNativeComposeMaterialAuthority({ authority: mutation.materialAuthority, generation, phase: "effect" })); + await run( + await assertNativeComposeMaterialAuthority({ + authority: mutation.materialAuthority, + generation, + phase: "effect", + }) + ); return { outcome: "complete", value: 0 }; - } }); + }, + }); }); } function inventory(binding: NativeComposeMaterialBinding) { - const state = { present: true, drift: false, foreign: false, running: false, mounts: true, version: "1", volumeReads: 0, - mountType: "volume", mountName: selection.name, mountSource: `/var/lib/docker/volumes/${selection.name}/_data` }; + const state = { + present: true, + drift: false, + foreign: false, + running: false, + mounts: true, + version: "1", + volumeReads: 0, + mountType: "volume", + mountName: selection.name, + mountSource: `/var/lib/docker/volumes/${selection.name}/_data`, + }; const id = "c".repeat(64); const mutableCalls: string[][] = []; const probe = async (args: readonly string[]): Promise => { mutableCalls.push([...args]); - if (args[0] === "info") return JSON.stringify(engineId); - if (args[0] === "volume" && args[1] === "ls") return state.present ? JSON.stringify(selection.name) : ""; + if (args[0] === "info") { + return JSON.stringify(engineId); + } + if (args[0] === "volume" && args[1] === "ls") { + return state.present ? JSON.stringify(selection.name) : ""; + } if (args[0] === "volume" && args[1] === "inspect") { state.volumeReads++; - return JSON.stringify({ ...selection, createdAt: state.drift && state.volumeReads > 1 ? "2026-10-08T13:00:00Z" : createdAt, - driver: "local", options: null, mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, - project: binding.identity.composeProject, instance: binding.identity.composeProject, owner: binding.identity.ownerToken, version: state.version, provision: null }); + return JSON.stringify({ + ...selection, + createdAt: + state.drift && state.volumeReads > 1 + ? "2026-10-08T13:00:00Z" + : createdAt, + driver: "local", + options: null, + mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, + project: binding.identity.composeProject, + instance: binding.identity.composeProject, + owner: binding.identity.ownerToken, + version: state.version, + provision: null, + }); + } + if (args[0] === "container" && args[1] === "ls") { + return state.mounts ? JSON.stringify(id) : ""; + } + if (args[0] === "container" && args[1] === "inspect") { + return JSON.stringify({ + id, + createdAt, + mounts: [ + { + Type: state.mountType, + Name: state.mountName, + Source: state.mountSource, + Destination: "/data", + RW: true, + }, + ], + running: state.running, + project: binding.identity.composeProject, + instance: binding.identity.composeProject, + owner: state.foreign ? "f".repeat(32) : binding.identity.ownerToken, + version: state.version, + generation: binding.generationId, + carrier: null, + carrierOwner: null, + carrierGeneration: null, + }); } - if (args[0] === "container" && args[1] === "ls") return state.mounts ? JSON.stringify(id) : ""; - if (args[0] === "container" && args[1] === "inspect") return JSON.stringify({ id, createdAt, - mounts: [{ Type: state.mountType, Name: state.mountName, Source: state.mountSource, Destination: "/data", RW: true }], - running: state.running, project: binding.identity.composeProject, instance: binding.identity.composeProject, - owner: state.foreign ? "f".repeat(32) : binding.identity.ownerToken, version: state.version, generation: binding.generationId, - carrier: null, carrierOwner: null, carrierGeneration: null }); throw new Error("Unexpected synthetic Docker request"); }; return { state, probe, calls: mutableCalls }; @@ -80,97 +180,333 @@ function inventory(binding: NativeComposeMaterialBinding) { test("full holder inventory admits stopped and exact owned running readers and preserves strict sparse reads", async () => { await withBinding(async (current) => { const fake = inventory(current); - const target = await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe }); + const target = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }); expect(target.volume).toEqual({ ...selection, createdAt }); expect(target.holders).toHaveLength(1); fake.state.running = true; - expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: false, probe: fake.probe })).holders[0]?.running).toBe(true); - expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm") && !args.join(" ").includes("Config.Env"))).toBe(true); + expect( + ( + await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: false, + probe: fake.probe, + }) + ).holders[0]?.running + ).toBe(true); + expect( + fake.calls.every( + (args) => + !( + args.includes("create") || + args.includes("start") || + args.includes("rm") || + args.join(" ").includes("Config.Env") + ) + ) + ).toBe(true); }); }); -test.each(["foreign", "running", "drift", "version"] as const)("selected holder or volume %s refuses before any effects", async (failure) => { +test.each([ + "foreign", + "running", + "drift", + "version", +] as const)("selected holder or volume %s refuses before any effects", async (failure) => { await withBinding(async (current) => { const fake = inventory(current); - if (failure === "version") fake.state.version = "2"; else fake.state[failure] = true; - await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow("values omitted"); - expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm"))).toBe(true); + if (failure === "version") { + fake.state.version = "2"; + } else { + fake.state[failure] = true; + } + await expect( + observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }) + ).rejects.toThrow("values omitted"); + expect( + fake.calls.every( + (args) => + !( + args.includes("create") || + args.includes("start") || + args.includes("rm") + ) + ) + ).toBe(true); }); }); test("genuinely absent cold storage needs an empty complete holder selection", async () => { await withBinding(async (current) => { - const fake = inventory(current); fake.state.present = false; fake.state.mounts = false; - expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).volume).toBeNull(); + const fake = inventory(current); + fake.state.present = false; + fake.state.mounts = false; + expect( + ( + await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }) + ).volume + ).toBeNull(); fake.state.mounts = true; - await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow(); + await expect( + observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }) + ).rejects.toThrow(); }); }); test.each([ - "/var/lib/docker/volumes", "/var/lib/docker/volumes/owned_data/", "/var/lib/docker/volumes/owned_data/_data/", - "/var/lib/docker/volumes/owned_data/_data/child", "/var/lib/docker/volumes/other/../owned_data/_data", + "/var/lib/docker/volumes", + "/var/lib/docker/volumes/owned_data/", + "/var/lib/docker/volumes/owned_data/_data/", + "/var/lib/docker/volumes/owned_data/_data/child", + "/var/lib/docker/volumes/other/../owned_data/_data", ] as const)("foreign overlapping bind %s refuses rather than disappearing from holder selection", async (source) => { await withBinding(async (current) => { const fake = inventory(current); - fake.state.foreign = true; fake.state.mountType = "bind"; fake.state.mountName = ""; fake.state.mountSource = source; - await expect(observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).rejects.toThrow("values omitted"); - expect(fake.calls.every((args) => !args.includes("create") && !args.includes("start") && !args.includes("rm"))).toBe(true); + fake.state.foreign = true; + fake.state.mountType = "bind"; + fake.state.mountName = ""; + fake.state.mountSource = source; + await expect( + observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }) + ).rejects.toThrow("values omitted"); + expect( + fake.calls.every( + (args) => + !( + args.includes("create") || + args.includes("start") || + args.includes("rm") + ) + ) + ).toBe(true); }); }); test("complete holder inventory keeps a similarly named foreign sibling independent", async () => { await withBinding(async (current) => { const fake = inventory(current); - fake.state.foreign = true; fake.state.mountType = "bind"; fake.state.mountName = ""; + fake.state.foreign = true; + fake.state.mountType = "bind"; + fake.state.mountName = ""; fake.state.mountSource = "/var/lib/docker/volumes/owned_data_other/_data"; - expect((await observeNativeComposeStorageDockerTarget({ current, engineId, selection, stopped: true, probe: fake.probe })).holders).toEqual([]); + expect( + ( + await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }) + ).holders + ).toEqual([]); }); - expect(nativeComposeStorageDockerPathsOverlap("relative/path", "/var/lib/docker/volumes/owned_data/_data")).toBe(false); + expect( + nativeComposeStorageDockerPathsOverlap( + "relative/path", + "/var/lib/docker/volumes/owned_data/_data" + ) + ).toBe(false); }); function input(): NativeComposeStorageXattrInvocation { - return { recordCreated: async () => {}, invocationId: "a".repeat(32), artifact: NATIVE_STORAGE_DOCKER_ARTIFACT, - target: { ...selection, engineId, runtimeIdentity: "owned", ownerToken: "b".repeat(32), volume: { ...selection, createdAt }, - mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, driver: "local", options: {}, holders: [] }, - readonly: true, uid: 70, gid: 70, request: { kind: "directory-xattr", version: 1, operation: "root" }, - scope: { generationId: "d".repeat(32), currentGenerationId: "d".repeat(32), pendingGenerationId: null, pendingToken: null } }; + return { + recordCreated: async () => {}, + invocationId: "a".repeat(32), + artifact: NATIVE_STORAGE_DOCKER_ARTIFACT, + target: { + ...selection, + engineId, + runtimeIdentity: "owned", + ownerToken: "b".repeat(32), + volume: { ...selection, createdAt }, + mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, + driver: "local", + options: {}, + holders: [], + }, + readonly: true, + uid: 70, + gid: 70, + request: { kind: "directory-xattr", version: 1, operation: "root" }, + scope: { + generationId: "d".repeat(32), + currentGenerationId: "d".repeat(32), + pendingGenerationId: null, + pendingToken: null, + }, + }; } function carrier(input: NativeComposeStorageXattrInvocation) { const program = "/private-owned/helper.mjs"; - const mounts = [[program, "/hack-storage-witness-helper.mjs"], [input.target.mountpoint, "/hack-storage-witness"]].map(([source, target]) => ({ - Type: "bind", Source: source, Destination: target, RW: false, Propagation: "rprivate" })); - const value = { id: "e".repeat(64), createdAt, image: input.artifact.imageId, configImage: input.artifact.imageId, - user: "70:70", entrypoint: ["/usr/local/bin/bun"], cmd: ["--no-env-file", "/hack-storage-witness-helper.mjs"], openStdin: true, tty: false, - labels: { [NATIVE_STORAGE_CARRIER_LABEL]: input.invocationId, "io.hack.storage-witness.owner": input.target.ownerToken, - "io.hack.storage-witness.generation": input.scope.generationId, "io.hack.storage-witness.helper": input.artifact.helperHash }, - host: { Privileged: false, ReadonlyRootfs: true, NetworkMode: "none", Memory: 268_435_456, NanoCpus: 1_000_000_000, PidsLimit: 32, - CapDrop: ["ALL"], CapAdd: null, SecurityOpt: ["no-new-privileges:true"], LogConfig: { Type: "none", Config: {} }, RestartPolicy: { Name: "no" }, - Binds: null, Tmpfs: null, Devices: [], DeviceRequests: null, PortBindings: {}, OomKillDisable: false as false | null | true, - Mounts: mounts.map((mount) => ({ Type: mount.Type, Source: mount.Source, Target: mount.Destination, ReadOnly: true, - BindOptions: { NonRecursive: true, Propagation: "rprivate", CreateMountpoint: false } })) }, - mounts, state: {}, execIds: null as null | string[] }; + const mounts = [ + [program, "/hack-storage-witness-helper.mjs"], + [input.target.mountpoint, "/hack-storage-witness"], + ].map(([source, target]) => ({ + Type: "bind", + Source: source, + Destination: target, + RW: false, + Propagation: "rprivate", + })); + const value = { + id: "e".repeat(64), + createdAt, + image: input.artifact.imageId, + configImage: input.artifact.imageId, + user: "70:70", + entrypoint: ["/usr/local/bin/bun"], + cmd: ["--no-env-file", "/hack-storage-witness-helper.mjs"], + openStdin: true, + tty: false, + labels: { + [NATIVE_STORAGE_CARRIER_LABEL]: input.invocationId, + "io.hack.storage-witness.owner": input.target.ownerToken, + "io.hack.storage-witness.generation": input.scope.generationId, + "io.hack.storage-witness.helper": input.artifact.helperHash, + }, + host: { + Privileged: false, + ReadonlyRootfs: true, + NetworkMode: "none", + Memory: 268_435_456, + NanoCpus: 1_000_000_000, + PidsLimit: 32, + CapDrop: ["ALL"], + CapAdd: null, + SecurityOpt: ["no-new-privileges:true"], + LogConfig: { Type: "none", Config: {} }, + RestartPolicy: { Name: "no" }, + Binds: null, + Tmpfs: null, + Devices: [], + DeviceRequests: null, + PortBindings: {}, + OomKillDisable: false as false | null | true, + Mounts: mounts.map((mount) => ({ + Type: mount.Type, + Source: mount.Source, + Target: mount.Destination, + ReadOnly: true, + BindOptions: { + NonRecursive: true, + Propagation: "rprivate", + CreateMountpoint: false, + }, + })), + }, + mounts, + state: {}, + execIds: null as null | string[], + }; return { program, value }; } test("helper args require explicit cached dependency/noncreating RO binds and contain no witness token", () => { - const selected = input(), fake = carrier(selected); + const selected = input(), + fake = carrier(selected); expect(nativeComposeStorageDockerHelper().length).toBeGreaterThan(1000); - const args = nativeComposeStorageDockerCreateArgs({ input: selected, program: fake.program }); - expect(args).toContain("never"); expect(args).toContain("none"); - expect(args.filter((value) => value.startsWith("type=bind,")).every((value) => value.includes("readonly,bind-recursive=disabled"))).toBe(true); - expect(args.some((value) => value.includes("bind-create-src") || value.includes("type=volume"))).toBe(false); - checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] }); + const args = nativeComposeStorageDockerCreateArgs({ + input: selected, + program: fake.program, + }); + expect(args).toContain("never"); + expect(args).toContain("none"); + expect( + args + .filter((value) => value.startsWith("type=bind,")) + .every((value) => value.includes("readonly,bind-recursive=disabled")) + ).toBe(true); + expect( + args.some( + (value) => + value.includes("bind-create-src") || value.includes("type=volume") + ) + ).toBe(false); + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }); }); -test.each(["empty", "subset", "duplicate", "writable", "creating", "logging"] as const)("physical/configured projection %s cannot qualify a helper", (failure) => { - const selected = input(), fake = carrier(selected); - if (failure === "empty") fake.value.mounts = []; - if (failure === "subset") fake.value.mounts.pop(); - if (failure === "duplicate") fake.value.mounts[1] = fake.value.mounts[0]!; - if (failure === "writable") fake.value.mounts[1]!.RW = true; - if (failure === "creating") fake.value.host.Mounts[1]!.BindOptions.CreateMountpoint = true; - if (failure === "logging") fake.value.host.LogConfig.Type = "json-file"; - expect(() => checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] })).toThrow("values omitted"); +test.each([ + "empty", + "subset", + "duplicate", + "writable", + "creating", + "logging", +] as const)("physical/configured projection %s cannot qualify a helper", (failure) => { + const selected = input(), + fake = carrier(selected); + if (failure === "empty") { + fake.value.mounts = []; + } + if (failure === "subset") { + fake.value.mounts.pop(); + } + if (failure === "duplicate") { + fake.value.mounts[1] = fake.value.mounts[0]!; + } + if (failure === "writable") { + fake.value.mounts[1]!.RW = true; + } + if (failure === "creating") { + fake.value.host.Mounts[1]!.BindOptions.CreateMountpoint = true; + } + if (failure === "logging") { + fake.value.host.LogConfig.Type = "json-file"; + } + expect(() => + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }) + ).toThrow("values omitted"); }); test("policy comparison preserves all physical fields/duplicates and closes only observed optional defaults", () => { - const selected = input(), fake = carrier(selected); - const checked = () => checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, program: fake.program, imageIds: [selected.artifact.imageId] }); + const selected = input(), + fake = carrier(selected); + const checked = () => + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }); const before = nativeComposeStorageDockerCarrierPolicy(checked()); - fake.value.mounts.reverse(); fake.value.host.OomKillDisable = null; fake.value.execIds = []; + fake.value.mounts.reverse(); + fake.value.host.OomKillDisable = null; + fake.value.execIds = []; expect(nativeComposeStorageDockerCarrierPolicy(checked())).toBe(before); fake.value.host.OomKillDisable = true; expect(() => nativeComposeStorageDockerCarrierPolicy(checked())).toThrow(); diff --git a/tests/shell-private-io.test.ts b/tests/shell-private-io.test.ts index 694dabdb..2b27c3a1 100644 --- a/tests/shell-private-io.test.ts +++ b/tests/shell-private-io.test.ts @@ -7,20 +7,65 @@ import { run } from "../src/lib/shell.ts"; test("private descriptor capture precedes spawn admission and run does not close caller FDs", async () => { const root = await mkdtemp(join(tmpdir(), "shell-held-io-")); - const inputPath = join(root, "input"), outPath = join(root, "output"), errPath = join(root, "error"), otherPath = join(root, "other"); - await Promise.all([writeFile(inputPath, "captured input"), writeFile(outPath, ""), writeFile(errPath, ""), writeFile(otherPath, "")]); - const input = await open(inputPath, constants.O_RDONLY), output = await open(outPath, constants.O_RDWR), error = await open(errPath, constants.O_RDWR), other = await open(otherPath, constants.O_RDWR); + const inputPath = join(root, "input"), + outPath = join(root, "output"), + errPath = join(root, "error"), + otherPath = join(root, "other"); + await Promise.all([ + writeFile(inputPath, "captured input"), + writeFile(outPath, ""), + writeFile(errPath, ""), + writeFile(otherPath, ""), + ]); + const input = await open(inputPath, constants.O_RDONLY), + output = await open(outPath, constants.O_RDWR), + error = await open(errPath, constants.O_RDWR), + other = await open(otherPath, constants.O_RDWR); const privateIo = { stdin: input.fd, stdout: output.fd, stderr: error.fd }; try { - expect(await run(["/bin/cat"], { privateIo, timeoutMs: 3000, beforeSpawn: () => { privateIo.stdout = other.fd; } })).toBe(0); + expect( + await run(["/bin/cat"], { + privateIo, + timeoutMs: 3000, + beforeSpawn: () => { + privateIo.stdout = other.fd; + }, + }) + ).toBe(0); expect(await readFile(outPath, "utf8")).toBe("captured input"); expect(await readFile(otherPath, "utf8")).toBe(""); - expect(fstatSync(input.fd).isFile()).toBe(true); expect(fstatSync(output.fd).isFile()).toBe(true); - } finally { await Promise.all([input.close(), output.close(), error.close(), other.close()]); await rm(root, { recursive: true, force: true }); } + expect(fstatSync(input.fd).isFile()).toBe(true); + expect(fstatSync(output.fd).isFile()).toBe(true); + } finally { + await Promise.all([ + input.close(), + output.close(), + error.close(), + other.close(), + ]); + await rm(root, { recursive: true, force: true }); + } }); -test.each(["negative", "fractional", "unbounded", "tty"] as const)("private IO %s refuses before admission", async (failure) => { +test.each([ + "negative", + "fractional", + "unbounded", + "tty", +] as const)("private IO %s refuses before admission", async (failure) => { let admitted = false; - await expect(run(["/bin/cat"], { privateIo: { stdin: failure === "negative" ? -1 : failure === "fractional" ? 1.5 : 0, stdout: 1, stderr: 2 }, - timeoutMs: failure === "unbounded" ? undefined : 3000, forwardSignals: failure === "tty", beforeSpawn: () => { admitted = true; } })).rejects.toThrow("Private subprocess descriptors"); + await expect( + run(["/bin/cat"], { + privateIo: { + stdin: failure === "negative" ? -1 : failure === "fractional" ? 1.5 : 0, + stdout: 1, + stderr: 2, + }, + timeoutMs: failure === "unbounded" ? undefined : 3000, + forwardSignals: failure === "tty", + beforeSpawn: () => { + admitted = true; + }, + }) + ).rejects.toThrow("Private subprocess descriptors"); expect(admitted).toBe(false); }); From 9a140d45c78d9f2392d0ab799d0e2ea882251087 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 18:57:09 -0400 Subject: [PATCH 18/23] fix: separate host Git admission from carrier quotas --- src/lib/worktree-local-config.ts | 27 ++++++++++++------ tests/worktree-git-inspection.test.ts | 41 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 tests/worktree-git-inspection.test.ts diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index 24a9e179..73582a82 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -428,13 +428,19 @@ async function readGitCheckoutIdentity(opts: { }; } -/** Ignore caller Git redirection and never surface repository paths or child output. */ -async function readGitInspection(opts: { +/** Bounded read-only Git acquisition, separate from engine command file quotas. + * Ignore caller Git redirection and never surface repository paths or child output. + * An owner may supply a synchronous final admission and observe the owned child. */ +export async function readGitInspection(opts: { readonly projectRoot: string; readonly args: readonly string[]; readonly signal?: AbortSignal; + readonly beforeSpawn?: () => void; + readonly onSpawn?: (pid: number) => void; }): Promise { - throwIfCancelled(opts.signal); + const { projectRoot, signal, beforeSpawn, onSpawn } = opts; + const args = [...opts.args]; + throwIfCancelled(signal); const env: Record = { GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1", @@ -457,7 +463,9 @@ async function readGitInspection(opts: { } } try { - const child = Bun.spawn(["git", "-C", opts.projectRoot, ...opts.args], { + beforeSpawn?.(); + throwIfCancelled(signal); + const child = Bun.spawn(["git", "-C", projectRoot, ...args], { env, stdin: "ignore", stdout: "pipe", @@ -481,29 +489,30 @@ async function readGitInspection(opts: { timedOut = true; kill(); }, 10_000); - opts.signal?.addEventListener("abort", cancel, { once: true }); - if (opts.signal?.aborted) { + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted) { cancel(); } try { + onSpawn?.(child.pid); const [output, code] = await Promise.all([ readGitOutput(child.stdout), child.exited, ]); if (cancelled) { - throwIfCancelled(opts.signal); + throwIfCancelled(signal); } return code === 0 && !timedOut ? output : null; } finally { clearTimeout(timer); - opts.signal?.removeEventListener("abort", cancel); + signal?.removeEventListener("abort", cancel); if (child.exitCode === null) { kill(); } await child.exited; } } catch (error: unknown) { - throwIfCancelled(opts.signal); + throwIfCancelled(signal); if (error instanceof NativeConfigCompilerError) { throw error; } diff --git a/tests/worktree-git-inspection.test.ts b/tests/worktree-git-inspection.test.ts new file mode 100644 index 00000000..3580a07f --- /dev/null +++ b/tests/worktree-git-inspection.test.ts @@ -0,0 +1,41 @@ +import { expect, spyOn, test } from "bun:test"; +import { readGitInspection } from "../src/lib/worktree-local-config.ts"; + +test("Git inspection refuses changed final source admission before spawning", async () => { + const child = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Unexpected child"); + }); + try { + const output = await readGitInspection({ + projectRoot: "/synthetic-owned-source", + args: ["rev-parse", "HEAD"], + beforeSpawn: () => { + throw new Error("Source changed"); + }, + }); + expect(output).toBeNull(); + expect(child).not.toHaveBeenCalled(); + } finally { + child.mockRestore(); + } +}); + +test("Git inspection observes cancellation at the final source admission", async () => { + const controller = new AbortController(); + const child = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Unexpected child"); + }); + try { + await expect( + readGitInspection({ + projectRoot: "/synthetic-owned-source", + args: ["rev-parse", "HEAD"], + signal: controller.signal, + beforeSpawn: () => controller.abort(), + }) + ).rejects.toMatchObject({ code: "E_COMPILER_CANCELLED" }); + expect(child).not.toHaveBeenCalled(); + } finally { + child.mockRestore(); + } +}); From b9e7bf52b0a84146bc32f5a1015c4161783f335b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:02:19 -0400 Subject: [PATCH 19/23] fix: settle Git streams before refusing spawn observations --- src/lib/worktree-local-config.ts | 25 +++++---- tests/worktree-git-inspection.test.ts | 77 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index 73582a82..3b5bda8c 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -435,8 +435,8 @@ export async function readGitInspection(opts: { readonly projectRoot: string; readonly args: readonly string[]; readonly signal?: AbortSignal; - readonly beforeSpawn?: () => void; - readonly onSpawn?: (pid: number) => void; + readonly beforeSpawn?: () => undefined; + readonly onSpawn?: (pid: number) => undefined; }): Promise { const { projectRoot, signal, beforeSpawn, onSpawn } = opts; const args = [...opts.args]; @@ -463,7 +463,10 @@ export async function readGitInspection(opts: { } } try { - beforeSpawn?.(); + const admitted: unknown = beforeSpawn?.(); + if (admitted !== undefined) { + throw worktreeVerificationError(); + } throwIfCancelled(signal); const child = Bun.spawn(["git", "-C", projectRoot, ...args], { env, @@ -494,15 +497,19 @@ export async function readGitInspection(opts: { cancel(); } try { - onSpawn?.(child.pid); - const [output, code] = await Promise.all([ - readGitOutput(child.stdout), - child.exited, - ]); + const reading = readGitOutput(child.stdout); + let observationFailed = false; + try { + const observed: unknown = onSpawn?.(child.pid); + observationFailed = observed !== undefined; + } catch { + observationFailed = true; + } + const [output, code] = await Promise.all([reading, child.exited]); if (cancelled) { throwIfCancelled(signal); } - return code === 0 && !timedOut ? output : null; + return code === 0 && !timedOut && !observationFailed ? output : null; } finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); diff --git a/tests/worktree-git-inspection.test.ts b/tests/worktree-git-inspection.test.ts index 3580a07f..8c2a7782 100644 --- a/tests/worktree-git-inspection.test.ts +++ b/tests/worktree-git-inspection.test.ts @@ -31,7 +31,9 @@ test("Git inspection observes cancellation at the final source admission", async projectRoot: "/synthetic-owned-source", args: ["rev-parse", "HEAD"], signal: controller.signal, - beforeSpawn: () => controller.abort(), + beforeSpawn: () => { + controller.abort(); + }, }) ).rejects.toMatchObject({ code: "E_COMPILER_CANCELLED" }); expect(child).not.toHaveBeenCalled(); @@ -39,3 +41,76 @@ test("Git inspection observes cancellation at the final source admission", async child.mockRestore(); } }); + +test("Git inspection drains peer-held stdout after its spawn observer throws", async () => { + let readerAcquired = false; + let eofObserved = false; + let released = false; + let readCount = 0; + let releasePeer: () => void = () => {}; + const peerClosed = new Promise((resolve) => { + releasePeer = resolve; + }); + const stdout = { + getReader: () => { + readerAcquired = true; + return { + read: async () => { + readCount++; + if (readCount === 1) { + return { + done: false, + value: new TextEncoder().encode("owned-head"), + }; + } + await peerClosed; + eofObserved = true; + return { done: true, value: undefined }; + }, + releaseLock: () => { + released = true; + }, + }; + }, + }; + const child = spyOn(Bun, "spawn").mockReturnValue({ + pid: 123, + stdout, + exitCode: 0, + exited: Promise.resolve(0), + } as unknown as ReturnType); + try { + const output = await readGitInspection({ + projectRoot: "/synthetic-owned-source", + args: ["rev-parse", "HEAD"], + onSpawn: () => { + expect(readerAcquired).toBe(true); + queueMicrotask(releasePeer); + throw new Error("Observation refused"); + }, + }); + expect(output).toBeNull(); + expect(eofObserved).toBe(true); + expect(released).toBe(true); + expect(child).toHaveBeenCalledTimes(1); + } finally { + child.mockRestore(); + } +}); + +test("Git inspection refuses a Promise from final admission before spawning", async () => { + const child = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("Unexpected child"); + }); + try { + const output = await readGitInspection({ + projectRoot: "/synthetic-owned-source", + args: ["rev-parse", "HEAD"], + beforeSpawn: (() => Promise.resolve()) as unknown as () => undefined, + }); + expect(output).toBeNull(); + expect(child).not.toHaveBeenCalled(); + } finally { + child.mockRestore(); + } +}); From 49dd0f1b2b4d7695fffd8a60d32265e677662b1d Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:26:17 -0400 Subject: [PATCH 20/23] fix: close sparse storage image inspection JSON --- .../native-compose-storage-witness-docker.ts | 3 +- ...ive-compose-storage-witness-docker.test.ts | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index b3525cc3..1497e790 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -135,7 +135,7 @@ async function image( "image", "inspect", "--format", - '{"id":{{json .Id}},"os":{{json .Os}},"arch":{{json .Architecture}},"volumes":{{json (index .Config "Volumes")}}', + '{"id":{{json .Id}},"os":{{json .Os}},"arch":{{json .Architecture}},"volumes":{{json (index .Config "Volumes")}}}', artifact.imageId, ]) ); @@ -151,6 +151,7 @@ async function image( return refuse(); } } +export { image as assertNativeComposeStorageDockerImage }; async function invocationFiles( context: Context, input: NativeComposeStorageXattrInvocation diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 843fae7e..ee3f6831 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +9,7 @@ import { type NativeComposeMaterialBinding, openNativeComposeGenerationStore, } from "../src/lib/native-compose-generation.ts"; +import { assertNativeComposeStorageDockerImage } from "../src/lib/native-compose-storage-witness-docker.ts"; import { NATIVE_STORAGE_DOCKER_ARTIFACT, nativeComposeStorageDockerHelper, @@ -29,6 +31,53 @@ const roots: string[] = [], const engineId = "engine-fixture"; const selection = { name: "owned_data", storage: "data" }; const createdAt = "2026-10-08T12:00:00Z"; +const SPARSE_IMAGE_FIELD = /{{json ([^{}]+)}}/g; + +test("cached image prerequisite emits complete sparse JSON before carrier effects", async () => { + const fields: Readonly> = { + ".Id": NATIVE_STORAGE_DOCKER_ARTIFACT.imageId, + ".Os": "linux", + ".Architecture": "arm64", + '(index .Config "Volumes")': null, + }; + const requests: string[][] = []; + await assertNativeComposeStorageDockerImage(async (args) => { + requests.push([...args]); + expect(args).toHaveLength(5); + expect(args.slice(0, 3)).toEqual(["image", "inspect", "--format"]); + expect(args[4]).toBe(NATIVE_STORAGE_DOCKER_ARTIFACT.imageId); + const format = args[3]; + if (typeof format !== "string") { + throw new Error("Missing fixed sparse image format"); + } + const rendered = format.replace(SPARSE_IMAGE_FIELD, (_, field: string) => { + if (!Object.hasOwn(fields, field)) { + throw new Error("Unexpected sparse image field"); + } + const encoded = JSON.stringify(fields[field]); + if (typeof encoded !== "string") { + throw new Error("Invalid fixed sparse image field"); + } + return encoded; + }); + const output = `${rendered}\n`; + expect(Buffer.byteLength(output)).toBe(124); + return output; + }); + expect(requests).toHaveLength(1); +}); + +test("cached image prerequisite refuses the faithful truncated sparse output", async () => { + const truncated = `{"id":"${NATIVE_STORAGE_DOCKER_ARTIFACT.imageId}","os":"linux","arch":"arm64","volumes":null\n`; + expect(Buffer.byteLength(truncated)).toBe(123); + expect(createHash("sha256").update(truncated).digest("hex")).toBe( + "aea01511888599e3e1cacfc66c146dadb3ffdec207e1a620fe0136c2bca72ad8" + ); + await expect( + assertNativeComposeStorageDockerImage(async () => truncated) + ).rejects.toBeInstanceOf(SyntaxError); +}); + afterEach(async () => { await Promise.all(stores.splice(0).map((store) => store.close())); await Promise.all( From 73b65643a04fb1e74c2082248d0af8d5de3b7efc Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 19:41:48 -0400 Subject: [PATCH 21/23] fix: project storage carrier volume selections --- ...ompose-storage-witness-docker-inventory.ts | 50 +++++++++++++---- ...ive-compose-storage-witness-docker.test.ts | 55 +++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/lib/native-compose-storage-witness-docker-inventory.ts b/src/lib/native-compose-storage-witness-docker-inventory.ts index 14b4a089..f9c46416 100644 --- a/src/lib/native-compose-storage-witness-docker-inventory.ts +++ b/src/lib/native-compose-storage-witness-docker-inventory.ts @@ -11,12 +11,27 @@ import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-stor const ID = /^[a-f0-9]{64}$/; const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; +const STORAGE = /^[a-z0-9][a-z0-9._-]{0,62}$/; const PREFIX = "io.hack.native-config"; const TRAILING_SEPARATOR = /\/+$/; const VOLUME = `{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"driver":{{json .Driver}},"options":{{json .Options}},"mountpoint":{{json .Mountpoint}},"project":{{json (index .Labels "com.docker.compose.project")}},"instance":{{json (index .Labels "${PREFIX}.instance")}},"owner":{{json (index .Labels "${PREFIX}.owner")}},"version":{{json (index .Labels "${PREFIX}.version")}},"storage":{{json (index .Labels "${PREFIX}.storage")}},"provision":{{json (index .Labels "${PREFIX}.storage-provision")}}}`; const HOLDER = `{"id":{{json .Id}},"createdAt":{{json .Created}},"mounts":{{json .Mounts}},"running":{{json .State.Running}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"instance":{{json (index .Config.Labels "${PREFIX}.instance")}},"owner":{{json (index .Config.Labels "${PREFIX}.owner")}},"version":{{json (index .Config.Labels "${PREFIX}.version")}},"generation":{{json (index .Config.Labels "${PREFIX}.generation")}},"carrier":{{json (index .Config.Labels "io.hack.storage-witness.carrier")}},"carrierOwner":{{json (index .Config.Labels "io.hack.storage-witness.owner")}},"carrierGeneration":{{json (index .Config.Labels "io.hack.storage-witness.generation")}}}`; type Probe = ReturnType; type Selection = { readonly name: string; readonly storage: string }; +function captureSelection(value: Selection): Selection { + const { name, storage } = value; + if ( + !( + typeof name === "string" && + NAME.test(name) && + typeof storage === "string" && + STORAGE.test(storage) + ) + ) { + return refuse(); + } + return Object.freeze({ name, storage }); +} /** A parent or child bind exposes the selected root too. Normalize complete paths, * then compare separator boundaries so a similarly named sibling stays independent. */ export function nativeComposeStorageDockerPathsOverlap( @@ -91,10 +106,8 @@ export async function inspectNativeComposeStorageDockerVolume(opts: { readonly selection: Selection; readonly current: NativeComposeMaterialBinding; }): Promise> { - const { probe, selection, current } = opts; - if (!NAME.test(selection.name)) { - return refuse(); - } + const { probe, current } = opts; + const selection = captureSelection(opts.selection); const values = rows( await probe(["volume", "inspect", "--format", VOLUME, selection.name]) ); @@ -137,10 +150,8 @@ export async function observeNativeComposeStorageDockerTarget(opts: { readonly invocationId: string; }; }): Promise { - const { probe, selection, current, engineId, stopped } = opts; - if (!NAME.test(selection.name)) { - return refuse(); - } + const { probe, current, engineId, stopped } = opts; + const selection = captureSelection(opts.selection); if ( JSON.parse(await probe(["info", "--format", "{{json .ID}}"])) !== engineId ) { @@ -148,7 +159,11 @@ export async function observeNativeComposeStorageDockerTarget(opts: { } const beforeVolumes = await volumes(probe); const volume = beforeVolumes.includes(selection.name) - ? await inspectNativeComposeStorageDockerVolume(opts) + ? await inspectNativeComposeStorageDockerVolume({ + probe, + current, + selection, + }) : null; const beforeContainers = await containers(probe); const holders: Record[] = []; @@ -233,7 +248,11 @@ export async function observeNativeComposeStorageDockerTarget(opts: { } } const repeated = volume - ? await inspectNativeComposeStorageDockerVolume(opts) + ? await inspectNativeComposeStorageDockerVolume({ + probe, + current, + selection, + }) : null; if ( JSON.stringify(repeated) !== JSON.stringify(volume) || @@ -253,8 +272,15 @@ export async function observeNativeComposeStorageDockerTarget(opts: { engineId, runtimeIdentity: current.identity.composeProject, ownerToken: current.identity.ownerToken, - ...selection, - volume: volume ? { ...selection, createdAt: volume.createdAt } : null, + name: selection.name, + storage: selection.storage, + volume: volume + ? { + name: selection.name, + storage: selection.storage, + createdAt: volume.createdAt, + } + : null, mountpoint: volume?.mountpoint ?? null, driver: volume ? "local" : null, options: volume ? {} : null, diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index ee3f6831..7b5c0e5f 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -263,6 +263,61 @@ test("full holder inventory admits stopped and exact owned running readers and p ).toBe(true); }); }); +test("full carrier target is projected into exact retained volume facts", async () => { + await withBinding(async (current) => { + const fake = inventory(current); + const target = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }); + expect(Object.keys(target)).toHaveLength(10); + const repeated = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection: target, + stopped: true, + probe: fake.probe, + }); + expect(repeated).toEqual(target); + expect(Object.keys(repeated.volume ?? {}).sort()).toEqual([ + "createdAt", + "name", + "storage", + ]); + expect(fake.calls.every((args) => !args.includes("create"))).toBe(true); + }); +}); + +test("holder observation captures selection before the first probe await", async () => { + await withBinding(async (current) => { + const fake = inventory(current); + const mutable = { ...selection }; + let changed = false; + const target = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection: mutable, + stopped: true, + probe: async (args) => { + if (!changed) { + changed = true; + mutable.name = "substituted_data"; + mutable.storage = "substituted"; + } + return await fake.probe(args); + }, + }); + expect(changed).toBe(true); + expect(target.name).toBe(selection.name); + expect(target.storage).toBe(selection.storage); + expect(target.volume).toEqual({ ...selection, createdAt }); + expect(fake.calls.some((args) => args.includes(mutable.name))).toBe(false); + }); +}); + test.each([ "foreign", "running", From b3ef7728edd26bbce7c68bb39d193164741c2096 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:18:41 -0400 Subject: [PATCH 22/23] fix: mount witness carriers with exact named volumes --- .../native-compose-storage-xattr-carrier.md | 41 +-- ...ompose-storage-witness-docker-inventory.ts | 27 ++ ...e-compose-storage-witness-docker-policy.ts | 70 ++++-- .../native-compose-storage-witness-docker.ts | 14 +- ...e-compose-storage-witness-xattr-carrier.ts | 3 +- ...ive-compose-storage-witness-docker.test.ts | 233 +++++++++++++++--- 6 files changed, 312 insertions(+), 76 deletions(-) diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 1b1774f3..57b81ff9 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -119,16 +119,25 @@ anchor, report incomplete and skip dependent retirement. No automatic promotion, reseed, journal rewrite or volume deletion is authorized by a successful helper response. -## Non-creating transport prerequisite - -A Docker named-volume mount can auto-create an absent name even when readonly -and `volume-nocopy`; it is not an admitted resume transport. The candidate is a -fresh non-creating `--mount type=bind` of the exact current qualified local -volume's daemon-side Mountpoint, with `readonly,bind-recursive=disabled`, no -`bind-create-src`, no `-v` and no fallback. Write access is permitted only for the -original seed attempt. Local driver, empty options, canonical source/parent chain, -engine support and exact volume-root correspondence must all be qualified. A -missing source refuses without creating a volume or directory. +## Fresh selected-volume transport + +The candidate mounts the exact selected existing named volume with +`--mount type=volume,src=,dst=/hack-storage-witness,volume-nocopy` and +`readonly` for metadata and verification. Write access belongs only to the +original seed attempt. The helper program remains a separate nonrecursive, +read-only `rprivate` bind. The named-volume mount uses Docker's fixed private +propagation; the carrier does not bind the daemon's internal data directory or +broaden propagation. Configured `NoCopy`, selected name and read-only state must +match, with no driver, label or subpath overrides. Physical name, canonical +Mountpoint, local driver, read/write state and empty propagation must also match. + +Docker can implicitly create a missing named volume even with `volume-nocopy`. +Complete fresh metadata checks precede carrier creation and repeat after its +exact ID and birth are recorded, before start. Missing, foreign or rebound +storage refuses before helper execution, seed or workload. An external removal +racing the create API can leave an empty unqualified volume and carrier anchor; +those effects remain uncertain and cannot grant enrollment, repair or readiness. +This is an observed identity fence, not an atomic engine absence guarantee. Each proof must create a fresh carrier from a freshly checked current root. An old carrier can retain a removed root even when the replacement repeats its @@ -178,9 +187,9 @@ unknown create/start outcome keeps the anchor. Cleanup must prove the exact help stopped before non-force removal and never remove data, witness, material or journals. The candidate Docker port now uses the shared bounded query and child owners, captures canonical stdin privately, and records the created helper before -start. It inspects all selected-volume holders, binds the fresh canonical local -Mountpoint with `bind-recursive=disabled`, and never uses a named-volume helper -mount or requests bind source creation. Known completion requires an exact stopped +start. It inspects all selected-volume holders, mounts only the exact selected +named volume with `volume-nocopy`, and rechecks fresh metadata before start. +It never requests bind source creation. Known completion requires an exact stopped helper, non-force removal and fresh empty helper inventory. Unknown disposition retains the private journal and input. Journal bytes do not replace these actual stop and absence observations. The CLI does not call this unactivated port. @@ -219,7 +228,8 @@ They do not execute the Linux adapter or qualify kernel atomicity, permissions, xattr persistence, filesystem crash durability or Docker semantics. Before activation: qualify exact source/artifact/image/Bun/libc ABI and filesystem; -non-creating current-root bind behavior; create-only existing/symlink/unsupported +fresh named-volume/current-root correspondence and missing/rebound refusal before +start; create-only existing/symlink/unsupported refusals; unchanged PostgreSQL root initialization, real SQL retention, chown and post-start proofs; same-metadata replacement including an old pinned carrier; crash/timeout/cancellation with exact helper absence and retained uncertainty; @@ -239,4 +249,5 @@ Primary contracts: Linux [fsetxattr](https://man7.org/linux/man-pages/man2/fsetx [fsync](https://man7.org/linux/man-pages/man2/fsync.2.html), [arm64 open flags](https://github.com/torvalds/linux/blob/master/arch/arm64/include/uapi/asm/fcntl.h), [generic open flags](https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/fcntl.h), -[Bun FFI](https://bun.sh/docs/runtime/ffi), and Docker [bind mounts](https://docs.docker.com/engine/storage/bind-mounts/). +[Bun FFI](https://bun.sh/docs/runtime/ffi), Docker [volumes](https://docs.docker.com/engine/storage/volumes/), +and tagged Moby [mount fields](https://github.com/moby/moby/blob/v28.5.1/api/types/mount/mount.go). diff --git a/src/lib/native-compose-storage-witness-docker-inventory.ts b/src/lib/native-compose-storage-witness-docker-inventory.ts index f9c46416..b5bdb741 100644 --- a/src/lib/native-compose-storage-witness-docker-inventory.ts +++ b/src/lib/native-compose-storage-witness-docker-inventory.ts @@ -135,6 +135,33 @@ export async function inspectNativeComposeStorageDockerVolume(opts: { return value; } +/** A named mount may recreate a missing name during container creation. Recheck + * its admitted metadata before start; a new footprint grants no seed or workload. */ +export async function assertNativeComposeStorageDockerCarrierVolume(opts: { + readonly probe: Probe; + readonly current: NativeComposeMaterialBinding; + readonly target: NativeComposeStorageXattrTarget; +}): Promise { + const { probe, current } = opts; + const selection = captureSelection(opts.target); + const createdAt = opts.target.volume?.createdAt; + const mountpoint = opts.target.mountpoint; + if ( + !nativeComposeVolumeCreatedAt(createdAt) || + typeof mountpoint !== "string" + ) { + return refuse(); + } + const volume = await inspectNativeComposeStorageDockerVolume({ + probe, + current, + selection, + }); + if (volume.createdAt !== createdAt || volume.mountpoint !== mountpoint) { + return refuse(); + } +} + /** Complete holder reads include foreign containers. The two inventories and selected * volume reread are observed fences; they do not claim an atomic daemon transaction. */ export async function observeNativeComposeStorageDockerTarget(opts: { diff --git a/src/lib/native-compose-storage-witness-docker-policy.ts b/src/lib/native-compose-storage-witness-docker-policy.ts index 200fe90d..03fbb3d4 100644 --- a/src/lib/native-compose-storage-witness-docker-policy.ts +++ b/src/lib/native-compose-storage-witness-docker-policy.ts @@ -5,6 +5,15 @@ import type { NativeComposeStorageXattrInvocation } from "./native-compose-stora import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; const ID = /^[a-f0-9]{64}$/; +function configuredReadOnly( + mount: Record, + expected: boolean +): boolean { + // The Engine API omits the false default. A present value must stay exact. + return Object.hasOwn(mount, "ReadOnly") + ? mount.ReadOnly === expected + : expected === false; +} export const NATIVE_STORAGE_CARRIER_LABEL = "io.hack.storage-witness.carrier"; export const NATIVE_STORAGE_CARRIER_FORMAT = '{"id":{{json .Id}},"createdAt":{{json .Created}},"image":{{json .Image}},"configImage":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"openStdin":{{json .Config.OpenStdin}},"tty":{{json .Config.Tty}},"labels":{{json .Config.Labels}},"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"state":{{json .State}},"execIds":{{json .ExecIDs}}}'; @@ -91,9 +100,9 @@ export function checkNativeComposeStorageDockerCarrier(opts: { ) { return refuse(); } - for (const [source, target, readonly] of [ - [program, "/hack-storage-witness-helper.mjs", true], - [input.target.mountpoint, "/hack-storage-witness", input.readonly], + for (const target of [ + "/hack-storage-witness-helper.mjs", + "/hack-storage-witness", ] as const) { const configured = host.Mounts.filter( (mount) => isRecord(mount) && mount.Target === target @@ -103,22 +112,49 @@ export function checkNativeComposeStorageDockerCarrier(opts: { ); if ( !( - typeof source === "string" && configured.length === 1 && physical.length === 1 && isRecord(configured[0]) && - configured[0].Type === "bind" && - configured[0].Source === source && - configured[0].ReadOnly === readonly && - isRecord(configured[0].BindOptions) && - configured[0].BindOptions.NonRecursive === true && - configured[0].BindOptions.Propagation === "rprivate" && - configured[0].BindOptions.CreateMountpoint !== true && - isRecord(physical[0]) && - physical[0].Type === "bind" && - physical[0].Source === source && - physical[0].RW === !readonly && - physical[0].Propagation === "rprivate" + isRecord(physical[0]) + ) + ) { + return refuse(); + } + const requested = configured[0], + observed = physical[0]; + if (target === "/hack-storage-witness-helper.mjs") { + if ( + !( + requested.Type === "bind" && + requested.Source === program && + configuredReadOnly(requested, true) && + isRecord(requested.BindOptions) && + requested.BindOptions.NonRecursive === true && + requested.BindOptions.Propagation === "rprivate" && + requested.BindOptions.CreateMountpoint !== true && + observed.Type === "bind" && + observed.Source === program && + observed.RW === false && + observed.Propagation === "rprivate" + ) + ) { + return refuse(); + } + } else if ( + !( + requested.Type === "volume" && + requested.Source === input.target.name && + configuredReadOnly(requested, input.readonly) && + !Object.hasOwn(requested, "BindOptions") && + isRecord(requested.VolumeOptions) && + Object.keys(requested.VolumeOptions).length === 1 && + requested.VolumeOptions.NoCopy === true && + observed.Type === "volume" && + observed.Name === input.target.name && + observed.Source === input.target.mountpoint && + observed.Driver === "local" && + observed.RW === !input.readonly && + observed.Propagation === "" ) ) { return refuse(); @@ -198,7 +234,7 @@ export function nativeComposeStorageDockerCreateArgs(opts: { "--mount", `type=bind,src=${program},dst=/hack-storage-witness-helper.mjs,readonly,bind-recursive=disabled,bind-propagation=rprivate`, "--mount", - `type=bind,src=${input.target.mountpoint},dst=/hack-storage-witness${input.readonly ? ",readonly" : ""},bind-recursive=disabled,bind-propagation=rprivate`, + `type=volume,src=${input.target.name},dst=/hack-storage-witness,volume-nocopy${input.readonly ? ",readonly" : ""}`, "--label", `${NATIVE_STORAGE_CARRIER_LABEL}=${input.invocationId}`, "--label", diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index 1497e790..a4a194f9 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -23,6 +23,7 @@ import { nativeComposeStorageDockerHelper, } from "./native-compose-storage-witness-docker-artifact.ts"; import { + assertNativeComposeStorageDockerCarrierVolume, inspectNativeComposeStorageDockerVolume, observeNativeComposeStorageDockerTarget, } from "./native-compose-storage-witness-docker-inventory.ts"; @@ -739,20 +740,11 @@ export async function createNativeComposeDockerStorageXattrCarrier( await input.recordCreated({ id, createdAt: known.createdAt }); await checkFiles(files); await image(probe()); - const volume = await inspectNativeComposeStorageDockerVolume({ + await assertNativeComposeStorageDockerCarrierVolume({ probe: probe(), current: before, - selection: input.target, + target: input.target, }); - if ( - !( - input.target.volume && - volume.createdAt === input.target.volume.createdAt && - volume.mountpoint === input.target.mountpoint - ) - ) { - return refuse(); - } const holders = await observeNativeComposeStorageDockerTarget({ probe: probe(), current: before, diff --git a/src/lib/native-compose-storage-witness-xattr-carrier.ts b/src/lib/native-compose-storage-witness-xattr-carrier.ts index f964ad3f..a43efba0 100644 --- a/src/lib/native-compose-storage-witness-xattr-carrier.ts +++ b/src/lib/native-compose-storage-witness-xattr-carrier.ts @@ -123,7 +123,8 @@ export type NativeComposeStorageXattrPorts = { readonly runtimeIdentity: string; readonly ownerToken: string; }) => Promise; - /** Fresh non-creating current-root mount; durable intent before effects and exact cleanup before return. */ + /** Fresh selected-root identity fence; durable intent and exact cleanup. + * An implicit empty footprint cannot grant helper start, seed or enrollment. */ readonly invoke: ( input: NativeComposeStorageXattrInvocation ) => Promise; diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 7b5c0e5f..86e01191 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -15,6 +15,7 @@ import { nativeComposeStorageDockerHelper, } from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; import { + assertNativeComposeStorageDockerCarrierVolume, nativeComposeStorageDockerPathsOverlap, observeNativeComposeStorageDockerTarget, } from "../src/lib/native-compose-storage-witness-docker-inventory.ts"; @@ -163,6 +164,7 @@ function inventory(binding: NativeComposeMaterialBinding) { mounts: true, version: "1", volumeReads: 0, + volumeOwner: binding.identity.ownerToken, mountType: "volume", mountName: selection.name, mountSource: `/var/lib/docker/volumes/${selection.name}/_data`, @@ -179,6 +181,9 @@ function inventory(binding: NativeComposeMaterialBinding) { } if (args[0] === "volume" && args[1] === "inspect") { state.volumeReads++; + if (!state.present) { + return ""; + } return JSON.stringify({ ...selection, createdAt: @@ -190,7 +195,7 @@ function inventory(binding: NativeComposeMaterialBinding) { mountpoint: `/var/lib/docker/volumes/${selection.name}/_data`, project: binding.identity.composeProject, instance: binding.identity.composeProject, - owner: binding.identity.ownerToken, + owner: state.volumeOwner, version: state.version, provision: null, }); @@ -318,6 +323,74 @@ test("holder observation captures selection before the first probe await", async }); }); +test.each([ + "missing", + "rebirth", + "foreign", +] as const)("post-create %s storage cannot admit helper start", async (failure) => { + await withBinding(async (current) => { + const fake = inventory(current); + const target = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }); + if (failure === "missing") { + fake.state.present = false; + } + if (failure === "rebirth") { + fake.state.drift = true; + } + if (failure === "foreign") { + fake.state.volumeOwner = "f".repeat(32); + } + let starts = 0; + await expect( + (async () => { + await assertNativeComposeStorageDockerCarrierVolume({ + current, + target, + probe: fake.probe, + }); + starts++; + })() + ).rejects.toThrow("values omitted"); + expect(starts).toBe(0); + }); +}); +test("post-create storage proof preserves the full target snapshot across its read", async () => { + await withBinding(async (current) => { + const fake = inventory(current); + const observed = await observeNativeComposeStorageDockerTarget({ + current, + engineId, + selection, + stopped: true, + probe: fake.probe, + }); + const target = { + ...observed, + volume: observed.volume ? { ...observed.volume } : null, + }; + await assertNativeComposeStorageDockerCarrierVolume({ + current, + target, + probe: async (args) => { + target.name = "substituted_data"; + target.storage = "substituted"; + target.mountpoint = "/var/lib/docker/volumes/substituted_data/_data"; + if (target.volume) { + target.volume.createdAt = "2026-10-08T13:00:00Z"; + } + return await fake.probe(args); + }, + }); + expect(fake.calls.at(-1)?.at(-1)).toBe(selection.name); + }); +}); + test.each([ "foreign", "running", @@ -470,16 +543,34 @@ function input(): NativeComposeStorageXattrInvocation { } function carrier(input: NativeComposeStorageXattrInvocation) { const program = "/private-owned/helper.mjs"; - const mounts = [ - [program, "/hack-storage-witness-helper.mjs"], - [input.target.mountpoint, "/hack-storage-witness"], - ].map(([source, target]) => ({ - Type: "bind", - Source: source, - Destination: target, - RW: false, - Propagation: "rprivate", - })); + const rootMount = { + Type: "volume", + Name: input.target.name, + Source: input.target.mountpoint, + Destination: "/hack-storage-witness", + Driver: "local", + Mode: "", + RW: !input.readonly, + Propagation: "", + }; + const volumeOptions: Record = { NoCopy: true }; + const rootConfig: Record = { + Type: "volume", + Source: input.target.name, + Target: "/hack-storage-witness", + ...(input.readonly ? { ReadOnly: true } : {}), + VolumeOptions: volumeOptions, + }; + const mounts: Record[] = [ + { + Type: "bind", + Source: program, + Destination: "/hack-storage-witness-helper.mjs", + RW: false, + Propagation: "rprivate", + }, + rootMount, + ]; const value = { id: "e".repeat(64), createdAt, @@ -514,25 +605,28 @@ function carrier(input: NativeComposeStorageXattrInvocation) { DeviceRequests: null, PortBindings: {}, OomKillDisable: false as false | null | true, - Mounts: mounts.map((mount) => ({ - Type: mount.Type, - Source: mount.Source, - Target: mount.Destination, - ReadOnly: true, - BindOptions: { - NonRecursive: true, - Propagation: "rprivate", - CreateMountpoint: false, + Mounts: [ + { + Type: "bind", + Source: program, + Target: "/hack-storage-witness-helper.mjs", + ReadOnly: true, + BindOptions: { + NonRecursive: true, + Propagation: "rprivate", + CreateMountpoint: false, + }, }, - })), + rootConfig, + ], }, mounts, state: {}, execIds: null as null | string[], }; - return { program, value }; + return { program, value, rootMount, rootConfig, volumeOptions }; } -test("helper args require explicit cached dependency/noncreating RO binds and contain no witness token", () => { +test("helper args require the cached dependency, unchanged program bind and exact nocopy volume", () => { const selected = input(), fake = carrier(selected); expect(nativeComposeStorageDockerHelper().length).toBeGreaterThan(1000); @@ -547,12 +641,13 @@ test("helper args require explicit cached dependency/noncreating RO binds and co .filter((value) => value.startsWith("type=bind,")) .every((value) => value.includes("readonly,bind-recursive=disabled")) ).toBe(true); - expect( - args.some( - (value) => - value.includes("bind-create-src") || value.includes("type=volume") - ) - ).toBe(false); + expect(args.filter((value) => value.startsWith("type=bind,"))).toHaveLength( + 1 + ); + expect(args.filter((value) => value.startsWith("type=volume,"))).toEqual([ + `type=volume,src=${selected.target.name},dst=/hack-storage-witness,volume-nocopy,readonly`, + ]); + expect(args.some((value) => value.includes("bind-create-src"))).toBe(false); checkNativeComposeStorageDockerCarrier({ value: fake.value, input: selected, @@ -565,7 +660,7 @@ test.each([ "subset", "duplicate", "writable", - "creating", + "copying", "logging", ] as const)("physical/configured projection %s cannot qualify a helper", (failure) => { const selected = input(), @@ -582,8 +677,8 @@ test.each([ if (failure === "writable") { fake.value.mounts[1]!.RW = true; } - if (failure === "creating") { - fake.value.host.Mounts[1]!.BindOptions.CreateMountpoint = true; + if (failure === "copying") { + fake.volumeOptions.NoCopy = false; } if (failure === "logging") { fake.value.host.LogConfig.Type = "json-file"; @@ -597,6 +692,80 @@ test.each([ }) ).toThrow("values omitted"); }); +test("cold seed accepts only the Engine's omitted or explicit false writable default", () => { + const selected = { ...input(), readonly: false }; + const fake = carrier(selected); + const check = () => + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }); + expect(check().id).toBe(fake.value.id); + fake.rootConfig.ReadOnly = false; + expect(check().id).toBe(fake.value.id); + fake.rootConfig.ReadOnly = null; + expect(check).toThrow("values omitted"); + expect( + nativeComposeStorageDockerCreateArgs({ + input: selected, + program: fake.program, + }) + ).toContain( + `type=volume,src=${selected.target.name},dst=/hack-storage-witness,volume-nocopy` + ); +}); +test.each([ + "bind", + "name", + "source", + "driver", + "propagation", + "subpath", + "driver-options", + "missing-nocopy", + "missing-ro", +] as const)("named-volume projection %s cannot substitute the selected root", (failure) => { + const selected = input(), + fake = carrier(selected); + if (failure === "bind") { + fake.rootConfig.Type = "bind"; + fake.rootMount.Type = "bind"; + } + if (failure === "name") { + fake.rootMount.Name = "foreign_data"; + } + if (failure === "source") { + fake.rootMount.Source = "/var/lib/docker/volumes/foreign_data/_data"; + } + if (failure === "driver") { + fake.rootMount.Driver = "foreign"; + } + if (failure === "propagation") { + fake.rootMount.Propagation = "rslave"; + } + if (failure === "subpath") { + fake.volumeOptions.Subpath = "data"; + } + if (failure === "driver-options") { + fake.volumeOptions.DriverConfig = { Name: "local", Options: {} }; + } + if (failure === "missing-nocopy") { + Reflect.deleteProperty(fake.volumeOptions, "NoCopy"); + } + if (failure === "missing-ro") { + Reflect.deleteProperty(fake.rootConfig, "ReadOnly"); + } + expect(() => + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }) + ).toThrow("values omitted"); +}); test("policy comparison preserves all physical fields/duplicates and closes only observed optional defaults", () => { const selected = input(), fake = carrier(selected); From b522f105da35587a69db88ce54cc28e2470ab453 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 20:56:39 -0400 Subject: [PATCH 23/23] fix: accept omitted empty carrier tmpfs metadata --- ...e-compose-storage-witness-docker-policy.ts | 3 +- ...ive-compose-storage-witness-docker.test.ts | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/native-compose-storage-witness-docker-policy.ts b/src/lib/native-compose-storage-witness-docker-policy.ts index 03fbb3d4..24fe21a1 100644 --- a/src/lib/native-compose-storage-witness-docker-policy.ts +++ b/src/lib/native-compose-storage-witness-docker-policy.ts @@ -84,7 +84,8 @@ export function checkNativeComposeStorageDockerCarrier(opts: { host.RestartPolicy.Name === "no" && (host.Binds === null || (Array.isArray(host.Binds) && host.Binds.length === 0)) && - (host.Tmpfs === null || + (!Object.hasOwn(host, "Tmpfs") || + host.Tmpfs === null || (isRecord(host.Tmpfs) && Object.keys(host.Tmpfs).length === 0)) && (host.Devices === null || (Array.isArray(host.Devices) && host.Devices.length === 0)) && diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 86e01191..d9e655a3 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -626,6 +626,58 @@ function carrier(input: NativeComposeStorageXattrInvocation) { }; return { program, value, rootMount, rootConfig, volumeOptions }; } +test.each([ + "omitted", + "null", + "empty", +] as const)("Engine empty Tmpfs representation %s preserves the exact carrier projection", (representation) => { + const selected = { ...input(), uid: 0, gid: 0 }; + const fake = carrier(selected); + fake.value.user = "0:0"; + const host: Record = fake.value.host; + if (representation === "omitted") { + Reflect.deleteProperty(host, "Tmpfs"); + } else { + host.Tmpfs = representation === "null" ? null : {}; + } + // Exercise the serialized Engine shape, including both unchanged mount rows. + const value: unknown = JSON.parse(JSON.stringify(fake.value)); + const checked = checkNativeComposeStorageDockerCarrier({ + value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }); + expect(checked.id).toBe(fake.value.id); + const policy = JSON.parse(nativeComposeStorageDockerCarrierPolicy(checked)); + expect(Object.hasOwn(policy.host, "Tmpfs")).toBe( + representation !== "omitted" + ); + expect(policy.mounts).toHaveLength(2); + expect(policy.host.Mounts).toHaveLength(2); +}); +test.each([ + { label: "mount", value: { "/extra": "rw" } }, + { label: "array", value: [] }, + { label: "string", value: "" }, + { label: "number", value: 0 }, + { label: "boolean", value: false }, + { label: "own undefined", value: undefined }, +])("Tmpfs $label cannot qualify a helper", ({ value }) => { + const selected = input(), + fake = carrier(selected); + const host: Record = fake.value.host; + host.Tmpfs = value; + expect(() => + checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }) + ).toThrow("values omitted"); +}); + test("helper args require the cached dependency, unchanged program bind and exact nocopy volume", () => { const selected = input(), fake = carrier(selected);