From af61365ea58844b1371872fb912f1c849788c905 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:13:50 -0500 Subject: [PATCH] Verify complete native ACP distributions under existing lifetime guardian Co-Authored-By: Paperclip --- .../drivers/acpx/installation-integrity.ts | 39 ++++ .../native-distribution-integrity.test.ts | 115 +++++++++++ .../acpx/native-distribution-integrity.ts | 178 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index e65d90c947e..21539ea1e78 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1,4 +1,6 @@ import { MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, ACPX_PRIVATE_SNAPSHOT_ENV, createAcpxPrivateSnapshot, type AcpxPrivateSnapshot } from "./private-snapshot.js"; +import { createNativeAcpxDistributionSnapshot, NATIVE_ACPX_BOOTSTRAP_NAME, readNativeAcpxDistributionEntries, type NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; +export type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; import { createHash } from "node:crypto"; import { spawn as spawnChildProcess, @@ -406,6 +408,43 @@ export interface VerifiedAcpxInstallation { openCommand(): Promise; } +/** + * Native candidate distribution primitive. This verifies bytes and lifetime + * ownership; it does not qualify or enable a provider profile. The caller must + * source every expected value from its trusted, versioned profile declaration. + */ +export async function verifyNativeAcpxInstallation( + input: NativeAcpxDistributionInput, +): Promise { + const declaration: NativeAcpxDistributionInput = Object.freeze({ + ...input, fixedArguments: Object.freeze([...input.fixedArguments]), + }); + const entries = await readNativeAcpxDistributionEntries(declaration); + return Object.freeze({ + commandDigest: `sha256:${declaration.expectedClosureSha256}`, + agentServerPackageJsonPath: declaration.manifestPath, + agentRuntimePackageJsonPath: null, + async openCommand(): Promise { + const native = await createNativeAcpxDistributionSnapshot(declaration, entries); + const lease = commandLease( + native.snapshot.roots[0]!, NATIVE_ACPX_BOOTSTRAP_NAME, "commonjs", + native.bootstrap, native.commandDirectory, [], 0, "commonjs", [], + null, null, native.snapshot, + ); + return { + spawn(args = [], options = {}, lifetime) { + if (args.length !== 0) throw new Error("Native ACPX distribution accepts only its fixed profile arguments"); + return lease.spawn([], { ...options, env: { + ...(options.env ?? process.env), + PAPERCLIP_ACPX_NATIVE_GUARDED: lifetime === undefined ? "0" : "1", + } }, lifetime); + }, + close: () => lease.close(), + }; + }, + }); +} + export interface VerifiedAcpxCommandLease { /** * Launch with a Linux descriptor-backed entry identity. Callers must not diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts new file mode 100644 index 00000000000..b837f847279 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { chmod, copyFile, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, verifyNativeAcpxInstallation } from "./installation-integrity.js"; +import { parseNativeAcpxDistributionEntries, type NativeAcpxDistributionInput, type NativeAcpxDistributionEntry } from "./native-distribution-integrity.js"; + +const roots: string[] = []; +const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +async function fixture(options: { node?: boolean; script?: string } = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "native-acpx-test-")); roots.push(root); + const entries: NativeAcpxDistributionEntry[] = []; + if (options.node) { + await copyFile(process.execPath, join(root, "runtime")); await chmod(join(root, "runtime"), 0o700); + const bytes = await readFile(join(root, "runtime")); entries.push({ path: "runtime", sha256: hash(bytes), size: bytes.length, executable: true }); + // Homebrew's test-host Node uses an @rpath libnode dylib. Include that + // dependency in this fixture's closure instead of relying on its old path. + const libraryRoot = join(dirname(process.execPath), "..", "lib"); + for (const name of (await readdir(libraryRoot).catch(() => [] as string[])).filter(name => /^libnode\..*\.dylib$/.test(name))) { + await copyFile(join(libraryRoot, name), join(root, name)); await chmod(join(root, name), 0o600); + const library = await readFile(join(root, name)); entries.push({ path: name, sha256: hash(library), size: library.length, executable: false }); + } + const script = options.script ?? 'console.log("qualified-entry");'; + await writeFile(join(root, "entry.cjs"), script, { mode: 0o600 }); + entries.push({ path: "entry.cjs", sha256: hash(script), size: Buffer.byteLength(script), executable: false }); + } else { + const script = options.script ?? '#!/bin/sh\nprintf "native:%s:%s" "$1" "${COPILOT_PKG_CACHE_HOME:-none}"\n'; + await writeFile(join(root, "runtime"), script, { mode: 0o700 }); + entries.push({ path: "runtime", sha256: hash(script), size: Buffer.byteLength(script), executable: true }); + } + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const manifestPath = join(root, "closure.json"); + await writeFile(manifestPath, JSON.stringify({ entries })); + return { distributionRoot: root, manifestPath, expectedClosureSha256: hash(JSON.stringify(entries)), executable: "runtime", ...(options.node ? { entrypoint: "entry.cjs" } : {}), fixedArguments: options.node ? [] : ["fixed"] }; +} +async function output(child: ChildProcess): Promise<{ text: string; error: string; code: number | null }> { + let text = ""; let error = ""; + child.stdout!.on("data", value => { text += String(value); }); + child.stderr!.on("data", value => { error += String(value); }); + const [code] = await once(child, "close") as [number | null]; + return { text, error, code }; +} + +describe("native ACPX execution closure", () => { + it("rejects paths, ordering, oversized files and altered manifest pins", () => { + const entry = { path: "runtime", sha256: "a".repeat(64), size: 10, executable: true }; + for (const entries of [[{ ...entry, path: "../escape" }], [{ ...entry, path: "/absolute" }], [entry, entry], [{ ...entry, size: 2 ** 40 }], [{ ...entry, unknown: 1 }]]) { + expect(() => parseNativeAcpxDistributionEntries({ entries }, hash(JSON.stringify(entries)))).toThrow(); + } + expect(() => parseNativeAcpxDistributionEntries({ entries: [entry] }, "b".repeat(64))).toThrow("manifest digest mismatch"); + }); + it("checks every file digest and refuses links at admission", async () => { + const declaration = await fixture(); const installation = await verifyNativeAcpxInstallation(declaration); + await writeFile(join(declaration.distributionRoot, "runtime"), "altered"); + await expect(installation.openCommand()).rejects.toThrow(); + await rm(join(declaration.distributionRoot, "runtime")); + await symlink("closure.json", join(declaration.distributionRoot, "runtime")); + await expect(installation.openCommand()).rejects.toThrow("symbolic link"); + }); + it("launches only fixed arguments from a frozen snapshot after installed files change", async () => { + const declaration = await fixture(); const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); + expect(() => lease.spawn(["--untrusted-override"])).toThrow("fixed profile arguments"); + await writeFile(join(declaration.distributionRoot, "runtime"), "changed after lease"); + const result = await output(lease.spawn()); + expect(result).toEqual({ text: "native:fixed:none", error: "", code: 0 }); + expect(() => lease.spawn()).toThrow("closed"); + await lease.close(); + }); + it("gives packaged executables a fresh private extraction cache each launch", async () => { + const declaration = { ...await fixture(), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const }; + const install = await verifyNativeAcpxInstallation(declaration); + const first = await output((await install.openCommand()).spawn([], { env: { COPILOT_PKG_CACHE_HOME: "/ambient/cache" } })); + const second = await output((await install.openCommand()).spawn()); + expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/); + expect(first.text).not.toBe(second.text); + }); + it("loads a pinned Node entrypoint while rejecting unqualified external modules", async () => { + const declaration = await fixture({ node: true }); + const result = await output((await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn()); + expect(result.code, result.error).toBe(0); expect(result.text).toBe("qualified-entry\n"); + const outside = join(declaration.distributionRoot, "outside.cjs"); await writeFile(outside, "module.exports='ambient';"); + const evil = await fixture({ node: true, script: `require(${JSON.stringify(outside)});` }); + const denied = await output((await (await verifyNativeAcpxInstallation(evil)).openCommand()).spawn()); + expect(denied.code).not.toBe(0); expect(denied.error).toContain("escaped its closed distribution"); + }, 30_000); + it("uses the existing guardian ownership and provider-exit proof for native children", async () => { + const declaration = await fixture({ script: '#!/bin/sh\nprintf "ready"\nwhile :; do sleep 1; done\n' }); + const fences = await Promise.all([listen(), listen()]); + const fds = fences.map(server => (server as Server & { _handle?: { fd?: number } })._handle!.fd!); + const owners: number[] = []; + const child = (await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn([], {}, { + credentialFenceFds: [fds[0]!, fds[1]!], activateCredentialFenceOwner: async pid => { owners.push(pid); }, + }); + let stderr = ""; child.stderr!.on("data", value => { stderr += String(value); }); + try { + await awaitVerifiedAcpxProviderOwnership(child); + expect(owners).toEqual([child.pid]); + const [chunk] = await once(child.stdout!, "data"); expect(String(chunk), stderr).toBe("ready"); + const exited = once(child, "exit"); child.kill(); await exited; + await awaitVerifiedAcpxProviderExit(child); + } finally { + child.kill(); await Promise.all(fences.map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))); + } + }, 15_000); +}); + +async function listen(): Promise { + const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); return server; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts new file mode 100644 index 00000000000..efdbc52bbd9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts @@ -0,0 +1,178 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, mkdtemp, open, realpath, rm, writeFile, type FileHandle } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import type { AcpxPrivateSnapshot } from "./private-snapshot.js"; + +export interface NativeAcpxDistributionEntry { path: string; sha256: string; size: number; executable: boolean } +export interface NativeAcpxDistributionInput { + distributionRoot: string; + manifestPath: string; + /** Trusted profile pin, never taken from the installed manifest itself. */ + expectedClosureSha256: string; + executable: string; + /** When present, execute this JS entry with the distribution's bundled Node. */ + entrypoint?: string; + fixedArguments: readonly string[]; + isolatedCacheEnvironmentName?: "COPILOT_PKG_CACHE_HOME"; +} +export interface NativeAcpxDistributionSnapshot { + snapshot: AcpxPrivateSnapshot; + commandDirectory: FileHandle; + bootstrap: Buffer; +} + +const MAX_NATIVE_TREE_BYTES = 1024 * 1024 * 1024; +const MAX_NATIVE_FILE_BYTES = 384 * 1024 * 1024; +const MAX_NATIVE_MANIFEST_BYTES = 4 * 1024 * 1024; +const BOOTSTRAP = ".paperclip-native-entry.cjs"; +const GUARD = ".paperclip-native-module-guard.cjs"; +export const NATIVE_ACPX_BOOTSTRAP_NAME = BOOTSTRAP; +const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isAbsolute(value) && !/[\u0000-\u001f\u007f\\]/.test(value) && value.split("/").every(part => part.length > 0 && part !== "." && part !== ".."); + +/** Parse a closed, canonical execution inventory before opening any file in it. */ +export function parseNativeAcpxDistributionEntries(value: unknown, expectedSha256: string): NativeAcpxDistributionEntry[] { + if (!/^[a-f0-9]{64}$/.test(expectedSha256)) throw new Error("Native ACPX closure pin is invalid"); + const entries = value !== null && typeof value === "object" ? (value as { entries?: unknown }).entries : undefined; + if (!Array.isArray(entries) || entries.length === 0 || entries.length > 10_000) throw new Error("Native ACPX closure inventory is invalid"); + let total = 0; + let previous = ""; + const parsed = entries.map(raw => { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Native ACPX closure entry is invalid"); + const entry = raw as Record; + if (Object.keys(entry).some(key => !["path", "sha256", "size", "executable"].includes(key)) || !validPath(entry.path) || entry.path <= previous || entry.path === BOOTSTRAP || entry.path === GUARD || entry.path === "manifest.json" || entry.path.startsWith(".paperclip-")) throw new Error("Native ACPX closure paths must be safe, unique and sorted"); + if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || Number(entry.size) < 0 || Number(entry.size) > MAX_NATIVE_FILE_BYTES || typeof entry.executable !== "boolean") throw new Error("Native ACPX closure file metadata is invalid"); + previous = entry.path; + total += Number(entry.size); + if (total > MAX_NATIVE_TREE_BYTES) throw new Error("Native ACPX closure exceeds its byte bound"); + return { path: entry.path, sha256: entry.sha256, size: Number(entry.size), executable: entry.executable }; + }); + if (sha256(JSON.stringify(parsed)) !== expectedSha256) throw new Error("Native ACPX closure manifest digest mismatch"); + return parsed; +} + +export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistributionInput): Promise { + if (!validPath(input.executable) || (input.entrypoint !== undefined && !validPath(input.entrypoint)) || input.fixedArguments.length > 128 || input.fixedArguments.some(arg => typeof arg !== "string" || arg.length > 16_384 || arg.includes("\0")) || (input.isolatedCacheEnvironmentName !== undefined && input.isolatedCacheEnvironmentName !== "COPILOT_PKG_CACHE_HOME")) throw new Error("Native ACPX launch declaration is invalid"); + const file = await open(input.manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(MAX_NATIVE_MANIFEST_BYTES)) throw new Error("Native ACPX manifest must be a bounded regular file"); + const bytes = await file.readFile(); + if (!same(before, await file.stat({ bigint: true })) || bytes.length !== Number(before.size)) throw new Error("Native ACPX manifest changed while read"); + const entries = parseNativeAcpxDistributionEntries(JSON.parse(bytes.toString("utf8")), input.expectedClosureSha256); + if (!entries.some(entry => entry.path === input.executable && entry.executable && entry.size > 0) || (input.entrypoint !== undefined && !entries.some(entry => entry.path === input.entrypoint && entry.size > 0))) throw new Error("Native ACPX executable or entrypoint is absent from its closure"); + return entries; + } finally { await file.close(); } +} + +/** + * Freeze only manifest-admitted bytes. Native trees deliberately have a separate + * bound; the tighter JavaScript/npm snapshot limits remain unchanged. + */ +export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[]): Promise { + // Callers cannot substitute entries between manifest validation and copying. + entries = parseNativeAcpxDistributionEntries({ entries }, input.expectedClosureSha256); + if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Native ACPX snapshots require Linux or macOS"); + const source = await realpath(input.distributionRoot); + const rootBefore = await lstat(source, { bigint: true }); + if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) throw new Error("Native ACPX distribution must be a directory"); + const heldRoot = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY); + const privateRoot = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-native-"))); + const packageRoot = join(privateRoot, "distribution"); + const cacheRoot = join(privateRoot, "state"); + const directories = new Set([privateRoot, packageRoot]); + const digests: Record = {}; + const close = async () => { + for (const path of directories) await chmod(path, 0o700).catch(() => undefined); + await rm(privateRoot, { recursive: true, force: true }); + }; + let commandDirectory: FileHandle | undefined; + try { + if (!same(rootBefore, await heldRoot.stat({ bigint: true }))) throw new Error("Native ACPX distribution root changed before snapshot"); + await mkdir(packageRoot, { mode: 0o700 }); + if (input.isolatedCacheEnvironmentName) await mkdir(cacheRoot, { mode: 0o700 }); + for (const entry of entries) { + const path = join(source, ...entry.path.split("/")); + if (await realpath(path) !== path) throw new Error("Native ACPX closure contains a symbolic link"); + const before = await lstat(path, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(entry.size) || Boolean(before.mode & 0o111n) !== entry.executable) throw new Error("Native ACPX closure file identity is invalid"); + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!same(before, await file.stat({ bigint: true }))) throw new Error("Native ACPX closure file changed before read"); + const bytes = Buffer.alloc(entry.size); + let offset = 0; + while (offset < bytes.length) { + const read = await file.read(bytes, offset, bytes.length - offset, offset); + if (read.bytesRead === 0) throw new Error("Native ACPX closure file ended during snapshot"); + offset += read.bytesRead; + } + if (!same(before, await file.stat({ bigint: true })) || !same(before, await lstat(path, { bigint: true })) || await realpath(path) !== path) throw new Error("Native ACPX closure file changed while read"); + if (sha256(bytes) !== entry.sha256) throw new Error(`Native ACPX closure file digest mismatch: ${entry.path}`); + const target = join(packageRoot, ...entry.path.split("/")); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + let directory = dirname(target); + while (directory !== privateRoot) { directories.add(directory); directory = dirname(directory); } + await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" }); + digests[target] = entry.sha256; + } finally { await file.close(); } + } + if (!same(rootBefore, await heldRoot.stat({ bigint: true })) || !same(rootBefore, await lstat(source, { bigint: true }))) throw new Error("Native ACPX distribution root changed during snapshot"); + const executable = join(packageRoot, ...input.executable.split("/")); + const args = [...(input.entrypoint === undefined ? [] : ["--require", join(packageRoot, GUARD), join(packageRoot, ...input.entrypoint.split("/"))]), ...input.fixedArguments]; + if (input.entrypoint !== undefined) { + const guard = Buffer.from(nativeModuleGuard(digests)); + await writeFile(join(packageRoot, GUARD), guard, { mode: 0o400, flag: "wx" }); + digests[join(packageRoot, GUARD)] = sha256(guard); + } + const bootstrap = Buffer.from(nativeBootstrap(executable, digests[executable]!, args, input.isolatedCacheEnvironmentName ? { name: input.isolatedCacheEnvironmentName, value: cacheRoot } : undefined)); + await writeFile(join(packageRoot, BOOTSTRAP), bootstrap, { mode: 0o400, flag: "wx" }); + digests[join(packageRoot, BOOTSTRAP)] = sha256(bootstrap); + // executable is null: commandLease's separately-qualified provider runtime + // slot is unused; this distribution is spawned by the trusted bootstrap. + const manifest = Buffer.from(JSON.stringify({ roots: [packageRoot], executable: null, digests })); + const manifestPath = join(privateRoot, "manifest.json"); + await writeFile(manifestPath, manifest, { mode: 0o400, flag: "wx" }); + for (const directory of directories) await chmod(directory, 0o500); + commandDirectory = await open(packageRoot, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY); + return { commandDirectory, bootstrap, snapshot: { roots: [packageRoot], executable: null, digests, handoff: { path: manifestPath, digest: sha256(manifest) }, close } }; + } catch (error) { + await commandDirectory?.close(); await close(); throw error; + } finally { await heldRoot.close(); } +} + +function nativeBootstrap(executable: string, executableDigest: string, args: string[], cache?: { name: string; value: string }): string { + return [ + 'const {spawn}=require("node:child_process");', + 'const fs=require("node:fs");', + // The commandLease's guarded bootstrap owns FDs 5 (guardian), 6 (exit), + // 7/8 (credential fences). Keep them open in the native process too. + 'const guarded=process.env.PAPERCLIP_ACPX_NATIVE_GUARDED==="1";', + 'const env={...process.env}; delete env.PAPERCLIP_ACPX_NATIVE_GUARDED; delete env.PAPERCLIP_ACPX_PRIVATE_SNAPSHOT; delete env.PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE;', + 'env.NODE_DISABLE_COMPILE_CACHE="1"; delete env.NODE_COMPILE_CACHE;', + ...(cache ? [`env[${JSON.stringify(cache.name)}]=${JSON.stringify(cache.value)};`] : []), + 'if(guarded)for(const fd of [5,6,7,8])fs.fstatSync(fd);', + `const executable=${JSON.stringify(executable)};const fd=fs.openSync(executable,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);`, + `if(require("node:crypto").createHash("sha256").update(fs.readFileSync(fd)).digest("hex")!==${JSON.stringify(executableDigest)})throw new Error("Native ACPX executable changed before spawn");`, + 'const stdio=guarded?[0,1,2,5,6,7,8]:[0,1,2]; const childExecutable=process.platform==="linux"?"/proc/self/fd/"+stdio.length:executable;stdio.push(fd);', + `const child=spawn(childExecutable,${JSON.stringify(args)},{env,cwd:process.cwd(),shell:false,detached:false,stdio});fs.closeSync(fd);`, + 'child.once("error",()=>process.exit(1)); child.once("exit",(code,signal)=>process.exit(signal?1:code??1));', + 'for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.on(signal,()=>child.kill(signal));', + ].join("\n"); +} + +function nativeModuleGuard(digests: Record): string { + return [ + 'const {registerHooks,isBuiltin}=require("node:module");', + 'const {readFileSync,realpathSync}=require("node:fs");', + 'const {fileURLToPath}=require("node:url"); const {createHash}=require("node:crypto");', + `const digests=${JSON.stringify(digests)};`, + 'const verify=url=>{if(!url.startsWith("file:"))throw new Error("Native ACPX module must be a qualified file or builtin");const path=fileURLToPath(url);if(realpathSync(path)!==path||!Object.hasOwn(digests,path))throw new Error("Native ACPX module escaped its closed distribution");const bytes=readFileSync(path);if(createHash("sha256").update(bytes).digest("hex")!==digests[path])throw new Error("Native ACPX module digest mismatch");return bytes;};', + 'registerHooks({resolve(specifier,context,next){const result=next(specifier,context);if(!isBuiltin(specifier)&&!result.url.startsWith("node:"))verify(result.url);return result;},load(url,context,next){if(url.startsWith("node:"))return next(url,context);const bytes=verify(url);if(["module","commonjs","json"].includes(context.format))return {format:context.format,source:bytes,shortCircuit:true};return next(url,context);}});', + ].join("\n"); +} + +function same(a: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint }, b: typeof a): boolean { + return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs; +}