Confine checked artifact loading and browser asset lookup

This commit is contained in:
Timothy J. Aveni
2026-09-19 23:38:06 -07:00
parent ac36edf62d
commit 0aaa80a4ba
6 changed files with 303 additions and 46 deletions
+9 -4
View File
@@ -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 `checkExecutionWorld` verifies plan identity, supported codecs, exact source and
binding schema, service fallback, promised targets, declared exports, and all binding schema, service fallback, promised targets, declared exports, and all
supplied file bytes. The reader must confine paths/symlinks to the checked output 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, and honor its byte limit before allocation. `artifactFileReader` supplies that
checks actual module registries/imports, and verifies build receipts; a manifest 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. claim alone does not establish the JavaScript exports or an isolated process.
The returned `camino-world-v1` descriptor includes every declared implementation 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, The coordinator resolves each port through the installed operation binding,
checks effects/selected methods, validates typed arguments/references, and joins checks effects/selected methods, validates typed arguments/references, and joins
nested calls to the outer logical transaction. A nested failure rolls back to its nested calls to the outer logical transaction. A nested failure poisons the outer domain
frame boundary; process completion cannot independently commit. The coordinator 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 owns savepoints, cancellation, frame expiry, and rejection. These enforcement
mechanisms are LF-06/LF-08, not guarantees provided by TypeScript frame types. mechanisms are LF-06/LF-08, not guarantees provided by TypeScript frame types.
+155
View File
@@ -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<Record<string, string>>): Promise<ArtifactReader> {
const directories = new Map<string, string>();
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<Record<string, string>>,
): Promise<LoadedArtifactWorld> {
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<string, string>;
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<string, { revision: string; file: ArtifactFile }>();
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<ArtifactResponse | undefined> {
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 };
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./plan.js"; export * from "./plan.js";
export * from "./artifacts.js"; export * from "./artifacts.js";
export * from "./ports.js"; export * from "./ports.js";
export * from "./files.js";
+88
View File
@@ -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 });
}
});
+49
View File
@@ -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 };
};
+1 -42
View File
@@ -15,48 +15,7 @@ import {
type ArtifactImplementation, type ArtifactImplementation,
} from "../src/execution-world/index.js"; } from "../src/execution-world/index.js";
const buildFixture = (plan: ExecutionPlan) => { import { buildArtifactFixture as buildFixture } from "./artifact-fixture.js";
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 };
};
const resign = (implementation: ArtifactImplementation) => { const resign = (implementation: ArtifactImplementation) => {
const { contentDigest, ...body } = implementation; const { contentDigest, ...body } = implementation;
implementation.contentDigest = implementationDigest(body); implementation.contentDigest = implementationDigest(body);