diff --git a/src/execution-world/README.md b/src/execution-world/README.md index eabbe35..d65ec12 100644 --- a/src/execution-world/README.md +++ b/src/execution-world/README.md @@ -44,8 +44,12 @@ Its digest excludes its own digest field. Paths must be normalized and relative. `checkExecutionWorld` verifies plan identity, supported codecs, exact source and binding schema, service fallback, promised targets, declared exports, and all supplied file bytes. The reader must confine paths/symlinks to the checked output -and honor its byte limit before allocation. LF-05 supplies that filesystem adapter, -checks actual module registries/imports, and verifies build receipts; a manifest +and honor its byte limit before allocation. `artifactFileReader` supplies that +adapter for trusted immutable output roots. `loadArtifactWorld` checks candidate +outputs without importing package code. `BrowserArtifactGateway` registers only +declared browser assets and verifies bytes before serving; Web Studio places it +behind workspace authentication. LF-05 still checks actual module +registries/imports and verifies build receipts; a manifest claim alone does not establish the JavaScript exports or an isolated process. The returned `camino-world-v1` descriptor includes every declared implementation @@ -75,8 +79,9 @@ from an asynchronous completion order or the transport sequence number. The coordinator resolves each port through the installed operation binding, checks effects/selected methods, validates typed arguments/references, and joins -nested calls to the outer logical transaction. A nested failure rolls back to its -frame boundary; process completion cannot independently commit. The coordinator +nested calls to the outer logical transaction. A nested failure poisons the outer domain +transaction even if application code catches it; frame cleanup cannot turn it into +a partial success. Process completion cannot independently commit. The coordinator owns savepoints, cancellation, frame expiry, and rejection. These enforcement mechanisms are LF-06/LF-08, not guarantees provided by TypeScript frame types. diff --git a/src/execution-world/files.ts b/src/execution-world/files.ts new file mode 100644 index 0000000..765a590 --- /dev/null +++ b/src/execution-world/files.ts @@ -0,0 +1,155 @@ +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { + artifactPath, + parsePackageArtifacts, + checkExecutionWorld, + checkedWorldDigest, + type ArtifactReader, + type ArtifactFile, + type PackageArtifacts, + type CheckedWorld, +} from "./artifacts.js"; +import { type ExecutionPlan, ExecutionPlanError } from "./plan.js"; + +const maxFileBytes = 256 * 1024 * 1024; +const metadataLimit = 16 * 1024 * 1024; +const fail = (where: string, message: string): never => { + throw new ExecutionPlanError("ARTIFACT_FILE_INVALID", where, message); +}; +/** Roots must be trusted immutable output directories. Refuse symlinks, including + * interior directory links. Hash verification is still required before serving bytes. */ +export async function artifactFileReader(roots: Readonly>): Promise { + const directories = new Map(); + for (const [revision, root] of Object.entries(roots)) { + if (!path.isAbsolute(root) || path.normalize(root) !== root || (await realpath(root)) !== root) + fail(revision, "Output root must be a canonical absolute directory"); + const stat = await lstat(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) fail(root, "Output root is not a directory"); + directories.set(revision, root); + } + return async (revision, relative, limit) => { + const root = directories.get(revision); + if (!root) fail(revision, "Unknown package output"); + artifactPath(relative); + if (!Number.isSafeInteger(limit) || limit < 0 || limit > maxFileBytes) fail(relative, "Invalid file byte limit"); + const components = relative.split("/"); + let resolved = root!; + for (let index = 0; index < components.length; index++) { + resolved = path.join(resolved, components[index]!); + const stat = await lstat(resolved); + if (stat.isSymbolicLink() || (index < components.length - 1 ? !stat.isDirectory() : !stat.isFile())) + fail(relative, "Only real directories and regular files are allowed"); + } + const handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > limit) fail(relative, "File exceeds its declared bound"); + // A fixed allocation and one extra byte detect growth without readFile's unbounded allocation. + const buffer = Buffer.alloc(stat.size + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset !== stat.size) fail(relative, "File changed while reading"); + return buffer.subarray(0, offset); + } finally { + await handle.close(); + } + }; +} +export interface LoadedArtifactWorld { + world: CheckedWorld; + digest: string; + manifests: PackageArtifacts[]; + read: ArtifactReader; +} +/** Immutable-output loader. No module code is imported into the coordinator. */ +export async function loadArtifactWorld( + plan: ExecutionPlan, + roots: Readonly>, +): Promise { + const read = await artifactFileReader(roots), + manifests: PackageArtifacts[] = []; + const expected = plan.definition.packages.map((p) => p.revision).sort(); + if (JSON.stringify(Object.keys(roots).sort()) !== JSON.stringify(expected)) + fail("world", "Output roots must exactly match the installed plan"); + for (const revision of expected) { + const bytes = await read(revision, "share/quixos/package-artifacts.json", metadataLimit); + const manifest = parsePackageArtifacts(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + if (manifest.packageRevisionId !== revision) fail(revision, "Output belongs to another package revision"); + manifests.push(manifest); + } + const world = await checkExecutionWorld(plan, manifests, read); + return { world, digest: checkedWorldDigest(world), manifests, read }; +} +export interface BrowserArtifact { + url: string; + implementation: string; + packageRevisionId: string; + registryExport: string; + exports: readonly string[]; +} +export interface ArtifactResponse { + status: 200 | 304; + headers: Record; + body: Uint8Array; +} +/** This registry is placed behind the workspace gateway's existing authentication. + * Lookup uses an explicit URL table; request paths never become filesystem paths. */ +export class BrowserArtifactGateway { + readonly entries: readonly BrowserArtifact[]; + private readonly routes = new Map(); + private readonly read: ArtifactReader; + constructor(loaded: LoadedArtifactWorld) { + this.read = loaded.read; + const entries: BrowserArtifact[] = []; + for (const manifest of loaded.manifests) + for (const implementation of manifest.implementations) { + if (implementation.target !== "browser" || implementation.compatibility !== "quixos-js-module-v1") continue; + const prefix = `/__quixos/artifacts/${loaded.digest}/${implementation.contentDigest}/`; + for (const file of implementation.files) { + if (!/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(file.mediaType)) fail(file.path, "Invalid asset MIME type"); + const url = prefix + file.path.split("/").map(encodeURIComponent).join("/"); + const previous = this.routes.get(url); + if ( + previous && + (previous.file.digest !== file.digest || + previous.file.bytes !== file.bytes || + previous.file.mediaType !== file.mediaType) + ) + fail(url, "Conflicting browser asset route"); + this.routes.set(url, { revision: manifest.packageRevisionId, file: Object.freeze({ ...file }) }); + } + entries.push({ + url: prefix + implementation.path.split("/").map(encodeURIComponent).join("/"), + implementation: implementation.id, + packageRevisionId: manifest.packageRevisionId, + registryExport: implementation.registryExport!, + exports: Object.freeze([...implementation.exports]), + }); + } + this.entries = Object.freeze(entries.map((entry) => Object.freeze(entry))); + } + async response(url: string, ifNoneMatch?: string): Promise { + const route = this.routes.get(url); + if (!route) return undefined; + const bytes = await this.read(route.revision, route.file.path, route.file.bytes); + if (bytes.length !== route.file.bytes || createHash("sha256").update(bytes).digest("hex") !== route.file.digest) + fail(url, "Browser asset changed after world validation"); + const etag = `"sha256-${route.file.digest}"`; + const headers = { + "content-type": route.file.mediaType, + "cache-control": "private, max-age=31536000, immutable", + etag, + "x-content-type-options": "nosniff", + }; + return ifNoneMatch === etag + ? { status: 304, headers, body: new Uint8Array() } + : { status: 200, headers, body: bytes }; + } +} diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index 110c772..efd6ed6 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -1,3 +1,4 @@ export * from "./plan.js"; export * from "./artifacts.js"; export * from "./ports.js"; +export * from "./files.js"; diff --git a/test/artifact-files.test.ts b/test/artifact-files.test.ts new file mode 100644 index 0000000..45796e1 --- /dev/null +++ b/test/artifact-files.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { + compileExecutionPlan, + artifactFileReader, + loadArtifactWorld, + BrowserArtifactGateway, + implementationDigest, +} from "../src/execution-world/index.js"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { buildArtifactFixture } from "./artifact-fixture.js"; + +test("immutable-output loading rejects traversal, links, and files larger than the declared bound", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "camino-artifact-read-")); + try { + await mkdir(path.join(root, "output")); + await writeFile(path.join(root, "outside"), "secret"); + await writeFile(path.join(root, "output", "file"), "data"); + await symlink(path.join(root, "outside"), path.join(root, "output", "linked-file")); + await symlink(root, path.join(root, "output", "linked-dir")); + const read = await artifactFileReader({ package: path.join(root, "output") }); + assert.equal(new TextDecoder().decode(await read("package", "file", 4)), "data"); + await assert.rejects(read("package", "file", 3), /exceeds its declared bound/); + await assert.rejects(read("package", "linked-file", 100), /Only real directories/); + await assert.rejects(read("package", "linked-dir/outside", 100), /Only real directories/); + await assert.rejects(read("package", "../outside", 100), /normalized relative/); + await assert.rejects(read("unknown", "file", 100), /Unknown package output/); + await assert.rejects(read("package", "file", Infinity), /Invalid file byte limit/); + await assert.rejects( + artifactFileReader({ package: path.join(root, "output", "linked-dir") }), + /canonical absolute/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("gateway serves only declared browser assets with correct Wasm MIME and content identity", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "camino-artifact-world-")); + try { + const plan = compileExecutionPlan(await loadReaderWorkspace()), + fixture = buildArtifactFixture(plan); + const wasm = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const browser = fixture.manifest.implementations.find((i) => i.target === "browser")!; + browser.files.push({ + path: "portable/browser/assets/café.wasm", + bytes: wasm.length, + digest: createHash("sha256").update(wasm).digest("hex"), + mediaType: "application/wasm", + }); + const { contentDigest, ...body } = browser; + browser.contentDigest = implementationDigest(body); + for (const implementation of fixture.manifest.implementations) + for (const file of implementation.files) { + const target = path.join(root, file.path); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, file.mediaType === "application/wasm" ? wasm : fixture.bytes); + } + await mkdir(path.join(root, "share/quixos"), { recursive: true }); + await writeFile(path.join(root, "share/quixos/package-artifacts.json"), JSON.stringify(fixture.manifest)); + const loaded = await loadArtifactWorld(plan, { [fixture.manifest.packageRevisionId]: root }); + const gateway = new BrowserArtifactGateway(loaded); + assert.equal(gateway.entries.length, 1); + const entry = gateway.entries[0]!; + const response = await gateway.response(entry.url); + assert.equal(response?.status, 200); + assert.equal(response?.headers["content-type"], "text/javascript"); + assert.equal(response?.headers["cache-control"], "private, max-age=31536000, immutable"); + assert.deepEqual(response?.body, Buffer.from(fixture.bytes)); + assert.equal((await gateway.response(entry.url, response?.headers.etag))?.status, 304); + const wasmUrl = entry.url.replace("module.mjs", "assets/caf%C3%A9.wasm"); + const wasmResponse = await gateway.response(wasmUrl); + assert.equal(wasmResponse?.headers["content-type"], "application/wasm"); + assert.deepEqual(wasmResponse?.body, Buffer.from(wasm)); + assert.equal(await gateway.response(entry.url.replace("portable/browser/module.mjs", "bin/service")), undefined); + assert.equal(await gateway.response("/nix/store/secret"), undefined); + assert.equal(await gateway.response(entry.url.replace(loaded.digest, "0".repeat(64))), undefined); + await writeFile(path.join(root, browser.path), "x".repeat(fixture.bytes.length)); + await assert.rejects(gateway.response(entry.url), /changed after world validation/); + await assert.rejects(loadArtifactWorld(plan, { [fixture.manifest.packageRevisionId]: root }), /Artifact bytes/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/artifact-fixture.ts b/test/artifact-fixture.ts new file mode 100644 index 0000000..48144e8 --- /dev/null +++ b/test/artifact-fixture.ts @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; +import { + implementationDigest, + type ExecutionPlan, + type PackageArtifacts, + type ArtifactImplementation, +} from "../src/execution-world/index.js"; +export const buildArtifactFixture = (plan: ExecutionPlan) => { + const expected = plan.definition.packages[0]!; + const bytes = new TextEncoder().encode("export const handlers = Object.freeze({});\n"); + const implementation = ( + id: string, + target: string, + compatibility: ArtifactImplementation["compatibility"], + ): ArtifactImplementation => { + const path = compatibility === "quixos-service-v1" ? "bin/service" : `portable/${target}/module.mjs`; + const value = { + id, + target, + compatibility, + path, + ports: "quixos-transaction-ports-v1" as const, + ...(compatibility === "quixos-js-module-v1" ? { registryExport: "handlers" } : {}), + exports: expected.exports.map((e) => e.id), + files: [ + { + path, + digest: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + mediaType: compatibility === "quixos-service-v1" ? "application/octet-stream" : "text/javascript", + }, + ], + }; + return { ...value, contentDigest: implementationDigest(value) }; + }; + const manifest: PackageArtifacts = { + schemaVersion: 1, + packageRevisionId: expected.revision, + bindingDigest: expected.bindingDigest, + sourceDigest: expected.sourceDigest, + versions: plan.definition.versions, + implementations: [ + implementation("service", "server", "quixos-service-v1"), + implementation("server", "server", "quixos-js-module-v1"), + implementation("browser", "browser", "quixos-js-module-v1"), + ], + }; + return { manifest, bytes, read: async () => bytes }; +}; diff --git a/test/execution-world.test.ts b/test/execution-world.test.ts index 2e2568d..3b3afe0 100644 --- a/test/execution-world.test.ts +++ b/test/execution-world.test.ts @@ -15,48 +15,7 @@ import { type ArtifactImplementation, } from "../src/execution-world/index.js"; -const buildFixture = (plan: ExecutionPlan) => { - const expected = plan.definition.packages[0]!; - const bytes = new TextEncoder().encode("export const handlers = Object.freeze({});\n"); - const implementation = ( - id: string, - target: string, - compatibility: ArtifactImplementation["compatibility"], - ): ArtifactImplementation => { - const path = compatibility === "quixos-service-v1" ? "bin/service" : `portable/${target}/module.mjs`; - const value = { - id, - target, - compatibility, - path, - ports: "quixos-transaction-ports-v1" as const, - ...(compatibility === "quixos-js-module-v1" ? { registryExport: "handlers" } : {}), - exports: expected.exports.map((e) => e.id), - files: [ - { - path, - digest: createHash("sha256").update(bytes).digest("hex"), - bytes: bytes.length, - mediaType: compatibility === "quixos-service-v1" ? "application/octet-stream" : "text/javascript", - }, - ], - }; - return { ...value, contentDigest: implementationDigest(value) }; - }; - const manifest: PackageArtifacts = { - schemaVersion: 1, - packageRevisionId: expected.revision, - bindingDigest: expected.bindingDigest, - sourceDigest: expected.sourceDigest, - versions: plan.definition.versions, - implementations: [ - implementation("service", "server", "quixos-service-v1"), - implementation("server", "server", "quixos-js-module-v1"), - implementation("browser", "browser", "quixos-js-module-v1"), - ], - }; - return { manifest, bytes, read: async () => bytes }; -}; +import { buildArtifactFixture as buildFixture } from "./artifact-fixture.js"; const resign = (implementation: ArtifactImplementation) => { const { contentDigest, ...body } = implementation; implementation.contentDigest = implementationDigest(body);