diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb25331..b42e322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: astral-sh/setup-uv@v8.1.0 - uses: actions/setup-node@v4 with: @@ -29,6 +27,11 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check + - name: Qualify real relay in a network-isolated namespace + run: | + relay_node_executable="$(command -v node)" + sudo unshare --net -- sh -c 'ip link set lo up; exec "$@"' \ + motion-relay "$relay_node_executable" scripts/qualify-relay.mjs ios: name: iOS build and Swift quality @@ -37,8 +40,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20.19 diff --git a/knip.json b/knip.json index 0f43cc1..6f700a5 100644 --- a/knip.json +++ b/knip.json @@ -21,8 +21,8 @@ } }, "server": { - "entry": ["src/server.ts"], - "project": ["src/**/*.ts"] + "entry": ["src/server.ts", "scripts/**/*.test.mjs"], + "project": ["src/**/*.ts", "scripts/**/*.mjs"] }, "web": { "entry": ["src/app/main.ts", "src/sdk/index.ts", "src/**/*.test.ts"], diff --git a/package.json b/package.json index 2c8509c..aad517a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,11 @@ "overrides": { "brace-expansion@>=5.0.0 <6.0.0": "5.0.12", "nanoid@>=3.0.0 <4.0.0": "3.3.18", - "undici@^5.28.4": "6.29.0" + "miniflare>undici": "6.29.0" + }, + "patchedDependencies": { + "partykit@0.0.115": "patches/partykit@0.0.115.patch" } - } + }, + "packageManager": "pnpm@10.33.2" } diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..6d74133 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,11 @@ +# PartyKit runtime patch + +PartyKit 0.0.115 bundles its own Undici factories. An override for Miniflare does not update those embedded bytes. This patch delegates the single bundled Undici export to the already installed Miniflare Undici 6.29.0, with an exact version guard. It adds no dependency and preserves all generated line numbers outside the changed factory line. + +The old factories and source map remain in the package. Qualification verifies that all six outside callers use this entry, that executable entry exports are the actual resolved Undici object, and that the real inspector request publishes from the resolved request implementation. Retained inert bytes are not proof of an upstream update. Login, deployment and physical-device behavior remain unqualified. + +Review this patch whenever PartyKit or Miniflare changes. Remove it only after the replacement proves runtime parity and resolves the embedded-runtime boundary. Do not promote source-map observations into publisher proof. + +Fixture-only switches bind Workerd to loopback, choose the inspector port independently of the relay port, and use the read-only source checkout as the module root. The config, generated files and persistence stay inside the owned temporary fixture. Using the fixture directory as the module root while its entry point lives in the checkout creates escaping `..` module names that Workerd rejects. Without the qualification environment, PartyKit keeps its original host, inspector selection and module root. + +The inspector request supplies its own resolved Undici Agent and closes it after consuming the response, including failure paths. Node and installed Undici share a global-dispatcher symbol; an installed fetch function alone can otherwise dispatch through an agent created by Node’s internal Undici. The qualifier observed that internal publisher before this request-specific correction. It verifies the actual external publisher afterward; login and deployment request paths remain unqualified. diff --git a/patches/partykit@0.0.115.patch b/patches/partykit@0.0.115.patch new file mode 100644 index 0000000..c9c6284 --- /dev/null +++ b/patches/partykit@0.0.115.patch @@ -0,0 +1,50 @@ +diff --git a/dist/bin.mjs b/dist/bin.mjs +--- a/dist/bin.mjs ++++ b/dist/bin.mjs +@@ -33159,7 +33159,7 @@ + // ../../node_modules/undici/index.js + var require_undici = __commonJS({ + "../../node_modules/undici/index.js"(exports2, module2) { +- "use strict"; ++ "use strict"; const motionRuntimeRequire = __require("node:module").createRequire(__require.resolve("miniflare")); if (motionRuntimeRequire("undici/package.json").version !== "6.29.0") throw new Error("Motion requires qualified Undici 6.29.0"); module2.exports = motionRuntimeRequire("undici"); return; + var Client = require_client(); + var Dispatcher = require_dispatcher(); + var Pool = require_pool(); +@@ -94207,7 +94207,7 @@ + () => config.vectorize || config.ai ? getUserDetails(config) : null, + [config] + ); +- const portForRuntimeInspector = getPortForServer("runtime-inspector"); ++ const portForRuntimeInspector = getPortForServer("runtime-inspector", process.env.RELAY_FIXTURE_INSPECTOR_PORT ? Number(process.env.RELAY_FIXTURE_INSPECTOR_PORT) : void 0); + const [server] = (0, import_react26.useState)(() => new MiniflareServer()); + const [inspectorUrl, setInspectorUrl] = (0, import_react26.useState)( + void 0 +@@ -94411,7 +94411,7 @@ + https: options.https, + httpsKeyPath: options.httpsKeyPath, + httpsCertPath: options.httpsCertPath, +- host: "0.0.0.0", ++ host: process.env.RELAY_FIXTURE_DIRECTORY ? "127.0.0.1" : "0.0.0.0", + log: new Log(5, { prefix: "pk" }), + verbose: options.verbose, + inspectorPort: portForRuntimeInspector, +@@ -94514,7 +94514,7 @@ + })) + ], +- modulesRoot: process.cwd(), ++ modulesRoot: process.env.RELAY_FIXTURE_DIRECTORY ? process.env.RELAY_FIXTURE_SOURCE_ROOT : process.cwd(), + script: code + }, + { signal: abortController.signal } + ); +@@ -94641,8 +94641,8 @@ + onReady?.(event.url.hostname, parseInt(event.url.port)); + try { + const jsonUrl = `http://127.0.0.1:${portForRuntimeInspector}/json`; +- const res = await (0, import_undici4.fetch)(jsonUrl); +- const body = await res.json(); ++ const motionInspectorAgent = new import_undici4.Agent(); ++ const body = await (async () => { try { const res = await (0, import_undici4.fetch)(jsonUrl, { dispatcher: motionInspectorAgent }); return await res.json(); } finally { await motionInspectorAgent.close(); } })(); + const debuggerUrl = body?.find( + ({ id }) => id.startsWith("core:user") + )?.webSocketDebuggerUrl; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1d2211..27c0080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,12 @@ settings: overrides: brace-expansion@>=5.0.0 <6.0.0: 5.0.12 nanoid@>=3.0.0 <4.0.0: 3.3.18 - undici@^5.28.4: 6.29.0 + miniflare>undici: 6.29.0 + +patchedDependencies: + partykit@0.0.115: + hash: 1ecdc9d440bad4e005701d93e71dd2c47e98ee45d3bc6cb2129e294fb68d73b3 + path: patches/partykit@0.0.115.patch importers: @@ -39,7 +44,7 @@ importers: devDependencies: partykit: specifier: ^0.0.115 - version: 0.0.115 + version: 0.0.115(patch_hash=1ecdc9d440bad4e005701d93e71dd2c47e98ee45d3bc6cb2129e294fb68d73b3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -2616,7 +2621,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - partykit@0.0.115: + partykit@0.0.115(patch_hash=1ecdc9d440bad4e005701d93e71dd2c47e98ee45d3bc6cb2129e294fb68d73b3): dependencies: '@cloudflare/workers-types': 4.20240718.0 clipboardy: 4.0.0 diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs index 04dc8eb..134db20 100644 --- a/scripts/check-code-health.mjs +++ b/scripts/check-code-health.mjs @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { summarizeDependencyAudit } from "./dependency-audit.mjs"; +import { countSwiftFormatDiagnostics } from "./swift-format-result.mjs"; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const productionPaths = ["protocol", "server/src", "web/src", "ios/Sources"]; @@ -216,14 +218,13 @@ function checkDuplication() { function checkDependencies() { const result = run("pnpm", ["audit", "--json"], { allowFailure: true }); - const report = JSON.parse(result.stdout); - const severe = Object.entries(report.advisories ?? {}).filter( - ([, advisory]) => ["critical", "high"].includes(advisory.severity) + const { dependencies, severe } = summarizeDependencyAudit(result); + log( + `Dependencies: ${dependencies} audited, ${severe.length} critical/high advisories.` ); - log(`Dependencies: ${severe.length} critical/high advisories.`); if (severe.length > 0) { throw new Error( - `Critical/high advisories: ${severe + `Critical/high advisories must be resolved before passing: ${severe .map(([id, advisory]) => `${id}/${advisory.github_advisory_id}`) .join(", ")}` ); @@ -278,9 +279,7 @@ function checkSwiftFormat() { ["swift-format", "lint", "--strict", "--recursive", "ios/Sources"], { allowFailure: true } ); - const diagnostics = `${result.stdout}\n${result.stderr}` - .split("\n") - .filter((line) => line.includes("error:")).length; + const diagnostics = countSwiftFormatDiagnostics(result); log(`Swift format debt: ${diagnostics} diagnostics.`); // Ratcheted legacy debt: https://github.com/Significant-Hobbies/motion/issues/26 failRegressions("Swift format", { diagnostics }, { diagnostics: 4553 }); diff --git a/scripts/dependency-audit.mjs b/scripts/dependency-audit.mjs new file mode 100644 index 0000000..82efa9b --- /dev/null +++ b/scripts/dependency-audit.mjs @@ -0,0 +1,95 @@ +const severities = new Set(["info", "low", "moderate", "high", "critical"]); +const advisoryLevels = ["critical", "high", "moderate", "low", "info"]; + +export function summarizeDependencyAudit(result) { + if (![0, 1].includes(result.status)) { + throw new Error( + `Dependency audit exited unexpectedly with status ${result.status}.` + ); + } + + let report; + try { + report = JSON.parse(result.stdout); + } catch (error) { + throw new Error("Dependency audit returned invalid JSON.", { + cause: error, + }); + } + + if ( + !report || + typeof report !== "object" || + Array.isArray(report) || + report.error || + !report.advisories || + typeof report.advisories !== "object" || + Array.isArray(report.advisories) || + !report.metadata?.vulnerabilities || + typeof report.metadata.vulnerabilities !== "object" || + !Number.isInteger(report.metadata.dependencies) || + report.metadata.dependencies < 0 || + !Number.isInteger(report.metadata.totalDependencies) || + report.metadata.totalDependencies < report.metadata.dependencies + ) { + throw new Error( + "Dependency audit returned an incomplete or unexpected report." + ); + } + + for (const severity of advisoryLevels) { + if ( + !Number.isInteger(report.metadata.vulnerabilities[severity]) || + report.metadata.vulnerabilities[severity] < 0 + ) { + throw new Error( + "Dependency audit returned malformed vulnerability counts." + ); + } + } + + for (const [id, advisory] of Object.entries(report.advisories)) { + if ( + !/^\d+$/u.test(id) || + !advisory || + typeof advisory !== "object" || + !severities.has(advisory.severity) || + typeof advisory.github_advisory_id !== "string" + ) { + throw new Error( + `Dependency audit returned a malformed advisory (${id}).` + ); + } + } + + if (result.status === 1 && Object.keys(report.advisories).length === 0) { + throw new Error("Dependency audit failure has no advisory explanation."); + } + + const advisoryCounts = Object.fromEntries( + advisoryLevels.map((severity) => [ + severity, + Object.values(report.advisories).filter( + (advisory) => advisory.severity === severity + ).length, + ]) + ); + if ( + advisoryLevels.some( + (severity) => + report.metadata.vulnerabilities[severity] !== advisoryCounts[severity] + ) + ) { + throw new Error( + "Dependency audit advisory details do not match its vulnerability counts." + ); + } + + const severe = Object.entries(report.advisories).filter(([, advisory]) => + ["critical", "high"].includes(advisory.severity) + ); + return { + dependencies: report.metadata.totalDependencies, + severe, + }; +} diff --git a/scripts/dependency-audit.test.mjs b/scripts/dependency-audit.test.mjs new file mode 100644 index 0000000..4e15437 --- /dev/null +++ b/scripts/dependency-audit.test.mjs @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { summarizeDependencyAudit } from "./dependency-audit.mjs"; + +function auditResult({ status = 0, advisories = {}, error } = {}) { + const vulnerabilities = { + info: 0, + low: 0, + moderate: 0, + high: 0, + critical: 0, + }; + for (const advisory of Object.values(advisories)) { + vulnerabilities[advisory.severity] += 1; + } + return { + status, + stdout: JSON.stringify({ + ...(error ? { error } : {}), + advisories, + metadata: { + vulnerabilities, + dependencies: 10, + totalDependencies: 10, + }, + }), + }; +} + +describe("dependency audit gate", () => { + it("accepts a clean, valid report", () => { + expect(summarizeDependencyAudit(auditResult())).toEqual({ + dependencies: 10, + severe: [], + }); + }); + + it("accepts nonzero status for a valid advisory report and returns severe findings", () => { + const advisory = { severity: "high", github_advisory_id: "GHSA-test" }; + const result = summarizeDependencyAudit( + auditResult({ status: 1, advisories: { 123: advisory } }) + ); + + expect(result.severe).toEqual([["123", advisory]]); + }); + + it("rejects registry errors even when they exit nonzero", () => { + expect(() => + summarizeDependencyAudit( + auditResult({ status: 1, error: { code: "ENOTFOUND" } }) + ) + ).toThrow(/incomplete or unexpected report/u); + }); + + it.each(["not json", "{}", JSON.stringify({ advisories: {} })])( + "rejects malformed reports: %s", + (stdout) => { + expect(() => summarizeDependencyAudit({ status: 0, stdout })).toThrow(); + } + ); + + it("rejects unexpected audit process statuses", () => { + expect(() => summarizeDependencyAudit({ status: 2, stdout: "{}" })).toThrow( + /exited unexpectedly/u + ); + }); + + it("rejects failure status without an advisory explanation", () => { + expect(() => summarizeDependencyAudit(auditResult({ status: 1 }))).toThrow( + /no advisory explanation/u + ); + }); + + it.each([ + { dependencies: -1, totalDependencies: 10 }, + { dependencies: 10, totalDependencies: 9 }, + ])("rejects invalid dependency totals: %j", (counts) => { + const result = auditResult(); + const report = JSON.parse(result.stdout); + Object.assign(report.metadata, counts); + expect(() => + summarizeDependencyAudit({ ...result, stdout: JSON.stringify(report) }) + ).toThrow(/incomplete or unexpected report/u); + }); + + it("rejects report counts that do not match advisory details", () => { + const result = auditResult({ + advisories: { + 123: { severity: "high", github_advisory_id: "GHSA-test" }, + }, + }); + const report = JSON.parse(result.stdout); + report.metadata.vulnerabilities.high = 0; + + expect(() => + summarizeDependencyAudit({ ...result, stdout: JSON.stringify(report) }) + ).toThrow(/do not match/u); + }); +}); diff --git a/scripts/qualify-relay.mjs b/scripts/qualify-relay.mjs new file mode 100644 index 0000000..e900b78 --- /dev/null +++ b/scripts/qualify-relay.mjs @@ -0,0 +1,342 @@ +// Bounded real PartyKit qualification; no login, deployment, camera or provider. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +import { qualifyRuntime } from "./relay-runtime.mjs"; + +const { createCollector } = createRequire(import.meta.url)( + "./relay-diagnostics.cjs" +); + +try { + const root = fileURLToPath(new URL("../", import.meta.url)); + const binary = realpathSync( + resolve(root, "server/node_modules/partykit/dist/bin.mjs") + ); + const partyRequire = createRequire(new URL(`file://${binary}`)); + const runtimeRequire = createRequire(partyRequire.resolve("miniflare")); + const { WebSocket } = runtimeRequire("undici"); + const mapBytes = readFileSync(`${binary}.map`); + const map = JSON.parse(mapBytes); + const manifest = JSON.parse( + map.sourcesContent[map.sources.indexOf("../package.json")] + ); + const inventory = { + node: process.version, + partykit: manifest.version, + binarySha256: createHash("sha256") + .update(readFileSync(binary)) + .digest("hex"), + mapSha256: createHash("sha256").update(mapBytes).digest("hex"), + embeddedUndiciDeclaration: manifest.devDependencies.undici, + embeddedUndiciSources: map.sources.filter((source) => + source.includes("/undici/") + ).length, + miniflare: runtimeRequire("miniflare/package.json").version, + resolvedUndici: runtimeRequire("undici/package.json").version, + securityRemediation: + "patched entry uses resolved Undici; old inert factories retained", + publisherVersion: runtimeRequire("undici/package.json").version, + publisherSha256: createHash("sha256") + .update( + readFileSync(runtimeRequire.resolve("undici/lib/core/request.js")) + ) + .digest("hex"), + }; + const entryMockFetch = await qualifyRuntime( + binary, + partyRequire, + runtimeRequire + ); + if (process.argv[2] === "--inspect-bundle") { + const sources = map.sources.flatMap((source, index) => + source.includes("/undici/") ? [[source, map.sourcesContent[index]]] : [] + ); + const installedRoot = dirname( + runtimeRequire.resolve("undici/package.json") + ); + const changedSources = sources.filter(([source, content]) => { + const localPath = source.split("/undici/")[1]; + try { + return readFileSync(join(installedRoot, localPath), "utf8") !== content; + } catch { + return true; + } + }).length; + const report = JSON.stringify( + { + ...inventory, + embeddedMockFetch: entryMockFetch, + embeddedSourcesSha256: createHash("sha256") + .update(JSON.stringify(sources)) + .digest("hex"), + sourcesDifferentFromResolvedUndici: changedSources, + embeddedExactVersion: + "unknown: no embedded package version metadata; source fingerprint retained", + }, + null, + 2 + ); + await new Promise((done) => process.stdout.write(`${report}\n`, done)); + process.exit(0); + } + const fixtureDirectory = mkdtempSync(join(tmpdir(), "motion-relay-fixture-")); + const port = Number(process.argv[2] ?? 21999); + assert(Number.isInteger(port) && port > 1024 && port < 65536); + const reservation = createServer(); + await new Promise((done, reject) => { + reservation.once("error", reject); + reservation.listen(0, "127.0.0.1", done); + }); + const inspectorPort = reservation.address().port; + await new Promise((done) => reservation.close(done)); + writeFileSync( + join(fixtureDirectory, "partykit.json"), + JSON.stringify({ + name: "motion-fixture", + main: resolve(root, "server/src/server.ts"), + compatibilityDate: "2025-01-01", + }) + ); + // The executable entry was qualified before the real child is launched. + const evidence = createCollector(port, { + ...inventory, + inspectorPort, + publisherQualified: true, + }); + let phase = "relay ready"; + const child = spawn( + process.execPath, + [ + "--require", + resolve(root, "scripts/relay-fixture-guard.cjs"), + binary, + "dev", + "--config", + join(fixtureDirectory, "partykit.json"), + "--port", + String(port), + "--no-hotkeys", + "--disable-request-cf-fetch", + "--persist", + join(fixtureDirectory, "state"), + "--unstable_outdir", + join(fixtureDirectory, "build"), + ], + { + cwd: fixtureDirectory, + env: { + XDG_CONFIG_HOME: fixtureDirectory, + TMPDIR: fixtureDirectory, + RELAY_FIXTURE_DIRECTORY: fixtureDirectory, + RELAY_FIXTURE_SOURCE_ROOT: root, + CI: "1", + RELAY_FIXTURE_PORT: String(port), + RELAY_FIXTURE_INSPECTOR_PORT: String(inspectorPort), + NO_UPDATE_NOTIFIER: "1", + NODE_OPTIONS: `--require=${JSON.stringify(resolve(root, "scripts/relay-fixture-guard.cjs"))}`, + PATH: "/usr/bin:/bin", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + } + ); + let childFailed = false; + let childOutput = ""; + child.on("error", () => { + childFailed = true; + }); + child.stdout.on("data", (chunk) => { + childOutput = (childOutput + chunk).slice(-8192); + evidence.push("stdout", chunk); + }); + child.stderr.on("data", (chunk) => { + childOutput = (childOutput + chunk).slice(-8192); + evidence.push("stderr", chunk); + }); + const sockets = []; + const checks = []; + function stop(signal) { + try { + if (process.platform === "win32") child.kill(signal); + else process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + const deadline = setTimeout(() => stop("SIGTERM"), 25000); + function client() { + const socket = new WebSocket(`ws://127.0.0.1:${port}/parties/main/FIX234`); + const messages = []; + socket.addEventListener("message", ({ data }) => + messages.push(JSON.parse(data)) + ); + sockets.push(socket); + return { + socket, + messages, + send: (message) => socket.send(JSON.stringify(message)), + }; + } + async function until(predicate, label, attempts = 100) { + phase = label; + for (let i = 0; i < attempts; i++) { + if (predicate()) return; + if (childFailed || child.exitCode !== null || child.signalCode !== null) + throw new Error("PartyKit exited"); + await delay(50); + } + throw new Error("Qualification timed out"); + } + async function connect(role) { + const connection = client(); + await until( + () => connection.socket.readyState === WebSocket.OPEN, + "connect" + ); + connection.send({ v: 1, type: "join", role }); + return connection; + } + try { + await until(() => evidence.ready(), "relay ready", 300); + const display = await connect("display"); + const controller = await connect("controller"); + await until( + () => display.messages.some((m) => m.type === "peer" && m.connected), + "presence" + ); + checks.push("real relay handshake and presence"); + controller.send({ v: 1, type: "ping", t: 123 }); + await until( + () => controller.messages.some((m) => m.type === "pong" && m.t === 123), + "pong" + ); + checks.push("ping/pong"); + const pose = { + v: 1, + type: "pose", + seq: 1, + sentAt: 12, + quality: 1, + joints: Object.fromEntries( + [ + "head", + "leftHand", + "rightHand", + "torso", + "leftKnee", + "rightKnee", + "leftFoot", + "rightFoot", + ].map((name) => [name, [0.5, 0.5]]) + ), + }; + controller.send(pose); + await until( + () => display.messages.some((m) => m.type === "pose"), + "pose relay" + ); + assert.deepEqual( + display.messages.find((m) => m.type === "pose"), + pose + ); + display.send({ v: 1, type: "start" }); + await until( + () => controller.messages.some((m) => m.type === "start"), + "start relay" + ); + checks.push("normalized pose and start relay"); + const duplicate = await connect("display"); + await until( + () => duplicate.messages.some((m) => m.code === "room_full"), + "duplicate role" + ); + const replacement = await connect("controller"); + await until( + () => controller.socket.readyState === WebSocket.CLOSED, + "controller eviction" + ); + replacement.socket.send("{bad-json"); + await until( + () => replacement.messages.some((m) => m.code === "bad_message"), + "malformed input" + ); + replacement.socket.close(); + await until( + () => + display.messages.at(-1)?.type === "peer" && + !display.messages.at(-1).connected, + "disconnect presence" + ); + checks.push( + "duplicate display rejection, controller replacement, malformed input, disconnect presence" + ); + await until(() => evidence.qualified(), "qualified inspector fetch"); + checks.push( + "PartyKit inspector fetch through qualified resolved Undici 6.29.0" + ); + console.log( + JSON.stringify( + { + ...inventory, + checks, + observations: evidence.observations(), + }, + null, + 2 + ) + ); + } catch (error) { + // Private, bounded fixture diagnostics; never included in published evidence. + writeFileSync(join(fixtureDirectory, "diagnostics.txt"), childOutput); + console.log( + JSON.stringify({ + status: "failed", + phase, + completedChecks: checks, + observations: evidence.observations(), + provenance: "UNKNOWN", + failureType: error.name, + }) + ); + process.exitCode = 1; + } finally { + clearTimeout(deadline); + for (const socket of sockets) socket.close(); + stop("SIGTERM"); + await new Promise((done) => { + if (child.exitCode !== null) done(); + else { + child.once("exit", done); + setTimeout(() => { + stop("SIGKILL"); + done(); + }, 2000).unref(); + } + }); + } +} catch { + console.log( + JSON.stringify({ + status: "failed", + phase: "setup", + completedChecks: [], + observations: [], + provenance: "UNKNOWN", + }) + ); + process.exitCode = 1; +} diff --git a/scripts/qualify-relay.test.mjs b/scripts/qualify-relay.test.mjs new file mode 100644 index 0000000..3594a70 --- /dev/null +++ b/scripts/qualify-relay.test.mjs @@ -0,0 +1,19 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +it("executes the patched Undici entry without sockets and emits parseable evidence", () => { + const script = fileURLToPath(new URL("./qualify-relay.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [script, "--inspect-bundle"], { + encoding: "utf8", + timeout: 10000, + env: { PATH: "/usr/bin:/bin" }, + }); + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.embeddedMockFetch).toMatch(/^PASS:/u); + expect(report.binarySha256).toMatch(/^[a-f0-9]{64}$/u); + expect(report.embeddedSourcesSha256).toMatch(/^[a-f0-9]{64}$/u); + expect(report.embeddedUndiciSources).toBeGreaterThan(0); + expect(report.embeddedExactVersion).toMatch(/^unknown:/u); +}); diff --git a/scripts/relay-diagnostics.cjs b/scripts/relay-diagnostics.cjs new file mode 100644 index 0000000..ef17124 --- /dev/null +++ b/scripts/relay-diagnostics.cjs @@ -0,0 +1,251 @@ +"use strict"; +const { createHash } = require("node:crypto"); +const { basename } = require("node:path"); +const { SourceMap } = require("node:module"); +const { fileURLToPath } = require("node:url"); +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const digest = (value) => + typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +const PREFIX = "FIXTURE_UNDICI "; +const LIMIT = 4096; +function safeObservation(value) { + if ( + !value || + typeof value.source !== "string" || + !/^[a-zA-Z0-9_.-]{1,80}$/u.test(value.source) || + !digest(value.sourceSha256) || + ![value.line, value.column, value.sourceLine, value.sourceColumn].every( + (n) => Number.isSafeInteger(n) && n > 0 + ) + ) + return {}; + return { + source: value.source, + sourceSha256: value.sourceSha256, + line: value.line, + column: value.column, + sourceLine: value.sourceLine, + sourceColumn: value.sourceColumn, + }; +} + +// Observations only: a mapped frame is never a publisher qualification. +function observe(stack, identity) { + try { + const frames = String(stack).split("\n").slice(1); + let frame; + let owned = false; + let dispatched = false; + for (const line of frames) { + const match = /(?:\(|^at )([^()]+):(\d+):(\d+)\)?$/u.exec(line.trim()); + if (!match) return {}; + if (!dispatched && [__filename, identity.guard].includes(match[1])) { + owned = true; + continue; + } + if (owned && match[1] === "node:diagnostics_channel") { + dispatched = true; + continue; + } + frame = match; + break; + } + if (!frame || !dispatched) return {}; + const executable = frame[1].startsWith("file:") + ? fileURLToPath(frame[1]) + : frame[1]; + if (executable !== identity.binary) return {}; + const line = Number(frame[2]); + const column = Number(frame[3]); + if ( + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 1 || + column < 1 + ) + return {}; + const entry = new SourceMap(identity.map).findEntry(line - 1, column - 1); + const indexes = identity.map.sources.flatMap((mappedSource, index) => + mappedSource === entry.originalSource ? [index] : [] + ); + if (indexes.length !== 1) return {}; + const content = identity.map.sourcesContent[indexes[0]]; + const source = basename(entry.originalSource); + if ( + typeof content !== "string" || + !/^[a-zA-Z0-9_.-]{1,80}$/u.test(source) || + !Number.isSafeInteger(entry.originalLine) || + entry.originalLine < 0 || + !Number.isSafeInteger(entry.originalColumn) || + entry.originalColumn < 0 + ) + return {}; + return { + line, + column, + source, + sourceSha256: hash(content), + sourceLine: entry.originalLine + 1, + sourceColumn: entry.originalColumn + 1, + }; + } catch { + return {}; + } +} + +function diagnosticCallback( + identity, + emit, + capture = () => new Error("Fixture request observation").stack +) { + return ({ request }) => { + const origin = String(request.origin); + if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/u.test(origin)) + throw new Error("Fixture blocked external Undici request"); + const matched = + request.method === "GET" && + request.path === "/json" && + ["127.0.0.1", "localhost", "[::1]"].some( + (host) => + origin === `http://${host}:${identity.inspectorPort ?? identity.port}` + ); + const proof = publisher(identity); + emit( + `${PREFIX}${JSON.stringify({ + method: matched ? "GET" : null, + origin: matched ? origin : null, + path: matched ? "/json" : null, + binarySha256: digest(identity.binarySha256), + mapSha256: digest(identity.mapSha256), + bundled: proof ? "FALSE" : "UNKNOWN", + publisher: proof, + observation: observe(capture(), identity), + })}\n` + ); + }; +} + +// Capture V8's actual call sites before string formatting or source-map mapping. +// A caller-supplied string stack can provide observations, never qualification. +function publisher(identity) { + const prepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_error, callSites) => callSites; + const error = new Error("Fixture publisher observation"); + Error.captureStackTrace(error, publisher); + const sites = error.stack; + let owned = false; + let dispatched = false; + for (const site of sites) { + const file = site.getFileName(); + if (!dispatched && [__filename, identity.guard].includes(file)) { + owned = true; + continue; + } + if (owned && file === "node:diagnostics_channel") { + dispatched = true; + continue; + } + if ( + !dispatched || + file !== identity.publisher || + identity.publisherVersion !== "6.29.0" || + !digest(identity.publisherSha256) + ) + return null; + return { + kind: "external-undici", + version: identity.publisherVersion, + sourceSha256: identity.publisherSha256, + }; + } + } catch { + /* Unqualified frames leave the publisher unknown. */ + } finally { + Error.prepareStackTrace = prepare; + } + return null; +} + +function createCollector(port, identity) { + const streams = new Map(); + const records = []; + let ready = false; + function push(stream, chunk) { + let state = streams.get(stream) ?? { tail: "", oversized: false }; + // Each stream is independently newline framed; no cross-stream joins. + for (const part of String(chunk).split(/(?<=\n)/u)) { + const complete = part.endsWith("\n"); + state.tail = (state.tail + part).slice(-LIMIT); + state.oversized ||= state.tail.length === LIMIT; + if (!complete) continue; + if (!state.oversized) { + const line = state.tail.trim(); + ready ||= new RegExp( + `(?:^|\\s)http://(?:127\\.0\\.0\\.1|localhost|\\[::1\\]):${port}(?:/)?(?:\\s|$)`, + "u" + ).test(line); + if (line.startsWith(PREFIX) && records.length < 16) { + try { + const record = JSON.parse(line.slice(PREFIX.length)); + if ( + record?.method === "GET" && + record.path === "/json" && + ["127.0.0.1", "localhost", "[::1]"].some( + (host) => + record.origin === + `http://${host}:${identity.inspectorPort ?? port}` + ) && + digest(record.binarySha256) && + digest(record.mapSha256) && + record.binarySha256 === identity.binarySha256 && + record.mapSha256 === identity.mapSha256 + ) { + // Only sanitized matched observations enter failure reports. + records.push({ + method: "GET", + origin: record.origin, + path: "/json", + binarySha256: record.binarySha256, + mapSha256: record.mapSha256, + bundled: ["TRUE", "FALSE"].includes(record.bundled) + ? record.bundled + : "UNKNOWN", + publisher: + record.publisher?.kind === "external-undici" && + record.publisher.version === "6.29.0" && + digest(record.publisher.sourceSha256) + ? { + kind: "external-undici", + version: "6.29.0", + sourceSha256: record.publisher.sourceSha256, + } + : null, + observation: safeObservation(record.observation), + }); + } + } catch { + /* Malformed records provide no evidence. */ + } + } + } + state = { tail: "", oversized: false }; + } + streams.set(stream, state); + } + return { + push, + ready: () => ready, + qualified: () => + identity.publisherQualified === true && + records.some( + (record) => + record.bundled === "FALSE" && + record.publisher?.kind === "external-undici" && + record.publisher.version === identity.publisherVersion && + record.publisher.sourceSha256 === identity.publisherSha256 + ), + observations: () => records.slice(), + }; +} +module.exports = { hash, diagnosticCallback, createCollector }; diff --git a/scripts/relay-diagnostics.test.mjs b/scripts/relay-diagnostics.test.mjs new file mode 100644 index 0000000..817bd46 --- /dev/null +++ b/scripts/relay-diagnostics.test.mjs @@ -0,0 +1,182 @@ +import { createRequire } from "node:module"; +import { expect, it } from "vitest"; + +const { diagnosticCallback, createCollector, hash } = createRequire( + import.meta.url +)("./relay-diagnostics.cjs"); +const identity = { + port: 21999, + binary: "/fixture/bin.mjs", + guard: "/fixture/guard.cjs", + binarySha256: hash("executable fixture"), + mapSha256: hash("map fixture"), + map: { + version: 3, + sources: ["request.js"], + sourcesContent: ["mock source"], + names: [], + mappings: "AAAA", + }, +}; +const request = { + method: "GET", + origin: "http://127.0.0.1:21999", + path: "/json", +}; +const prefix = + "Error\n at callback (/fixture/guard.cjs:1:1)\n at Channel.publish (node:diagnostics_channel:1:1)\n"; +function record( + stack = `${prefix} at implementation (/fixture/bin.mjs:1:1)` +) { + let emitted; + diagnosticCallback( + identity, + (value) => { + emitted = value; + }, + () => stack + )({ request }); + return emitted; +} + +it("observes the first implementation via Node SourceMap, always UNKNOWN", () => { + const result = JSON.parse( + record( + "Error\n at callback (/fixture/guard.cjs:1:1)\n at Channel.publish (node:diagnostics_channel:1:1)\n at implementation (/fixture/bin.mjs:1:1)" + ).slice(15) + ); + expect(result.bundled).toBe("UNKNOWN"); + expect(result.observation).toEqual({ + source: "request.js", + sourceSha256: hash("mock source"), + line: 1, + column: 1, + sourceLine: 1, + sourceColumn: 1, + }); + for (const stack of [ + "Error", + "Error\n at native", + `${prefix} at earlier (/other/request.js:1:1)\n at later (/fixture/bin.mjs:1:1)`, + "Error\n at implementation (/fixture/bin.mjs:1:1)", + ]) { + expect(JSON.parse(record(stack).slice(15)).observation).toEqual({}); + } + for (const map of [ + null, + { ...identity.map, sources: ["request.js", "request.js"] }, + ]) { + let output; + diagnosticCallback( + { ...identity, map }, + (value) => { + output = value; + }, + () => `${prefix} at implementation (/fixture/bin.mjs:1:1)` + )({ request }); + expect(JSON.parse(output.slice(15)).observation).toEqual({}); + } +}); + +it("frames readiness at every stdout split and keeps stderr separate", () => { + const url = "Ready http://127.0.0.1:21999/\n"; + for (let split = 0; split <= url.length; split++) { + const collector = createCollector(identity.port, identity); + collector.push("stdout", url.slice(0, split)); + collector.push("stdout", url.slice(split)); + expect(collector.ready()).toBe(true); + } + const collector = createCollector(identity.port, identity); + collector.push("stdout", "http://127.0.0.1:"); + collector.push("stderr", "21999\n"); + collector.push( + "stderr", + "http://127.0.0.1:219990\nhttp://external.invalid:21999\n" + ); + expect(collector.ready()).toBe(false); + collector.push("stderr", url); + expect(collector.ready()).toBe(true); +}); + +it("matches the inspector independently of the relay readiness port", () => { + const scoped = { ...identity, inspectorPort: 22001 }; + const collector = createCollector(identity.port, scoped); + const callback = diagnosticCallback(scoped, (value) => + collector.push("stdout", value) + ); + callback({ request }); + expect(collector.observations()).toHaveLength(0); + callback({ request: { ...request, origin: "http://127.0.0.1:22001" } }); + expect(collector.observations()).toHaveLength(1); + collector.push("stdout", "Ready http://127.0.0.1:22001/\n"); + expect(collector.ready()).toBe(false); + collector.push("stdout", "Ready http://127.0.0.1:21999/\n"); + expect(collector.ready()).toBe(true); + expect(collector.qualified()).toBe(false); +}); + +it("frames records across chunks and newlines with bounded, redacted retention", () => { + const value = record(); + for (let split = 0; split <= value.length; split++) { + const collector = createCollector(identity.port, identity); + collector.push("stdout", value.slice(0, split)); + collector.push("stdout", value.slice(split)); + expect(collector.observations()).toHaveLength(1); + expect(collector.qualified()).toBe(false); + } + const collector = createCollector(identity.port, identity); + collector.push("stdout", `${"secret/home/token?body".repeat(300)}${value}`); + expect(collector.observations()).toHaveLength(0); + const poisoned = JSON.parse(value.slice(15)); + poisoned.stack = "/home/person/token"; + poisoned.observation.extra = "secret query/body"; + for (let i = 0; i < 20; i++) + collector.push("stdout", `FIXTURE_UNDICI ${JSON.stringify(poisoned)}\n`); + expect(collector.observations()).toHaveLength(16); + expect(JSON.stringify(collector.observations())).not.toMatch( + /secret|home|token|extra|stack/u + ); +}); + +it("rejects unmatched GET, origin, path and identities and fails closed", () => { + const value = JSON.parse(record().slice(15)); + for (const changes of [ + { method: "POST" }, + { origin: "http://localhost:219990" }, + { origin: "http://external.invalid:21999" }, + { path: "/json?token=secret" }, + { binarySha256: hash("other") }, + { mapSha256: null }, + ]) { + const collector = createCollector(identity.port, identity); + collector.push( + "stdout", + `FIXTURE_UNDICI ${JSON.stringify({ ...value, ...changes })}\n` + ); + expect(collector.observations()).toHaveLength(0); + expect(collector.qualified()).toBe(false); + } + for (const bundled of ["UNKNOWN", "FALSE", false, null, {}, "malformed"]) { + // Even a separately affirmative gate cannot promote these observations. + const collector = createCollector(identity.port, { + ...identity, + publisherQualified: true, + }); + collector.push( + "stdout", + `FIXTURE_UNDICI ${JSON.stringify({ ...value, bundled })}\nFIXTURE_UNDICI {bad-json}\n` + ); + expect(collector.qualified()).toBe(false); + } + let output; + const callback = diagnosticCallback(identity, (emitted) => { + output = emitted; + }); + callback({ request: { ...request, path: "/json?token=secret" } }); + expect(output).not.toContain("secret"); + expect(() => + callback({ + request: { ...request, origin: "https://external.invalid?token=secret" }, + }) + ).toThrow("Fixture blocked external Undici request"); +}); diff --git a/scripts/relay-fixture-guard.cjs b/scripts/relay-fixture-guard.cjs new file mode 100644 index 0000000..08e4221 --- /dev/null +++ b/scripts/relay-fixture-guard.cjs @@ -0,0 +1,178 @@ +// Only the isolated local qualification child loads this cooperative Node guard. +"use strict"; +const net = require("node:net"); +const fs = require("node:fs"); +const diagnostics = require("node:diagnostics_channel"); +const { dirname, resolve, sep } = require("node:path"); +const { fileURLToPath } = require("node:url"); +const { createRequire, syncBuiltinESMExports } = require("node:module"); +const { hash, diagnosticCallback } = require("./relay-diagnostics.cjs"); +const canonical = fs.realpathSync; +const source = canonical(resolve(__dirname, "..")); +const fixtureAlias = process.env.RELAY_FIXTURE_DIRECTORY + ? resolve(process.env.RELAY_FIXTURE_DIRECTORY) + : null; +const fixture = fixtureAlias ? canonical(fixtureAlias) : null; +const within = (path, root) => + root && (path === root || path.startsWith(root + sep)); +const sensitive = + /(^|\/)\.env(?:\.|$)|(^|\/)\.(?:git|ssh|aws|npmrc|yarnrc)(?:\/|$)|\.partykit\/config\.json$/iu; +function blocked() { + // Missing credentials remain missing without attempting to open them. + const error = new Error("Fixture blocked sensitive or out-of-scope I/O"); + error.code = "ENOENT"; + throw error; +} +function guardPath(path, write = false) { + if (typeof path === "number") blocked(); + const text = resolve( + path instanceof URL ? fileURLToPath(path) : String(path) + ); + if (sensitive.test(text)) blocked(); + if ( + !within(text, fixtureAlias) && + !within(text, fixture) && + (write || !within(text, source)) + ) + blocked(); + // Resolve existing parents as well as existing files, so symlink escapes and + // writes to a new file through a symlink are rejected before content I/O. + let parent = text; + while (true) { + try { + const real = canonical(parent); + if (sensitive.test(real)) blocked(); + if (!within(real, fixture) && (write || !within(real, source))) blocked(); + break; + } catch (error) { + if (error.message.startsWith("Fixture blocked")) throw error; + if (error.code !== "ENOENT" || dirname(parent) === parent) throw error; + parent = dirname(parent); + } + } +} +// Only the exact numeric read-only flag can open checkout source files. +// Other numeric modes fail closed; fixture-local files still allow writes. +const writing = (flags) => + typeof flags === "number" + ? flags !== fs.constants.O_RDONLY + : /[wa+]/u.test(String(flags)); +function wrap(api, name, mode) { + const original = api[name]; + if (!original) return; + const guarded = function (path, ...args) { + guardPath(path, mode === "write" || (mode === "open" && writing(args[0]))); + return original.call(this, path, ...args); + }; + api[name] = + api === fs.promises + ? async function (...args) { + return guarded.apply(this, args); + } + : guarded; +} +for (const name of ["readFileSync", "readFile", "createReadStream"]) + wrap(fs, name, "read"); +for (const name of [ + "writeFileSync", + "writeFile", + "appendFileSync", + "appendFile", + "createWriteStream", + "mkdirSync", + "mkdir", + "rmSync", + "rm", + "unlinkSync", + "unlink", + "rmdirSync", + "rmdir", + "truncateSync", + "truncate", + "chmodSync", + "chmod", + "chownSync", + "chown", +]) + wrap(fs, name, "write"); +for (const name of ["openSync", "open"]) wrap(fs, name, "open"); +for (const name of ["readFile"]) wrap(fs.promises, name, "read"); +for (const name of [ + "writeFile", + "appendFile", + "mkdir", + "rm", + "unlink", + "rmdir", + "truncate", + "chmod", + "chown", +]) + wrap(fs.promises, name, "write"); +wrap(fs.promises, "open", "open"); +for (const api of [fs, fs.promises]) { + for (const name of [ + "rename", + "renameSync", + "copyFile", + "copyFileSync", + "cp", + "cpSync", + "link", + "linkSync", + "symlink", + "symlinkSync", + ]) { + const original = api[name]; + if (!original) continue; + const guarded = function (from, to, ...args) { + guardPath(from, name.startsWith("rename")); + guardPath(to, true); + return original.call(this, from, to, ...args); + }; + api[name] = + api === fs.promises + ? async function (...args) { + return guarded.apply(this, args); + } + : guarded; + } +} +syncBuiltinESMExports(); +const connect = net.Socket.prototype.connect; +net.Socket.prototype.connect = function (...args) { + const options = net._normalizeArgs(args)[0]; + if ( + options.path || + (options.host && !["127.0.0.1", "localhost", "::1"].includes(options.host)) + ) { + throw new Error("Fixture blocked external connection"); + } + return connect.apply(this, args); +}; +const identity = { + guard: __filename, + port: Number(process.env.RELAY_FIXTURE_PORT), + inspectorPort: Number(process.env.RELAY_FIXTURE_INSPECTOR_PORT), +}; +try { + identity.binary = fs.realpathSync(process.argv[1]); + identity.binarySha256 = hash(fs.readFileSync(identity.binary)); + const bytes = fs.readFileSync(`${identity.binary}.map`); + identity.mapSha256 = hash(bytes); + identity.map = JSON.parse(bytes); + const partyRequire = createRequire(identity.binary); + const runtimeRequire = createRequire(partyRequire.resolve("miniflare")); + identity.publisher = fs.realpathSync( + runtimeRequire.resolve("undici/lib/core/request.js") + ); + identity.publisherSha256 = hash(fs.readFileSync(identity.publisher)); + identity.publisherVersion = runtimeRequire("undici/package.json").version; +} catch { + /* Missing identity leaves provenance UNKNOWN. */ +} +diagnostics + .channel("undici:request:create") + .subscribe( + diagnosticCallback(identity, (record) => process.stdout.write(record)) + ); diff --git a/scripts/relay-fixture-guard.test.mjs b/scripts/relay-fixture-guard.test.mjs new file mode 100644 index 0000000..ecb5108 --- /dev/null +++ b/scripts/relay-fixture-guard.test.mjs @@ -0,0 +1,91 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, symlinkSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +it("blocks sync, promise and ESM sensitive I/O plus external sockets before I/O", () => { + const guard = fileURLToPath( + new URL("./relay-fixture-guard.cjs", import.meta.url) + ); + const result = spawnSync( + process.execPath, + [ + "--require", + guard, + "--input-type=module", + "-e", + ` + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + import { readFile, writeFile } from 'node:fs/promises'; + import { Socket } from 'node:net'; + // These fixture paths need not exist: guards must fire before any I/O. + assert.throws(() => readFileSync('/tmp/.env.synthetic-fixture'), /Fixture blocked/); + await assert.rejects(readFile('/tmp/.env.synthetic-fixture'), /Fixture blocked/); + await assert.rejects(writeFile('/tmp/.env.synthetic-fixture', 'synthetic'), /Fixture blocked/); + const socket = new Socket(); + assert.throws(() => socket.connect({ host: 'external.invalid', port: 80 }), /Fixture blocked external/); + socket.destroy(); + console.log('guards passed'); + `, + ], + { encoding: "utf8", timeout: 3000, env: { PATH: "/usr/bin:/bin" } } + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("guards passed"); +}); + +it("allows its own source and fixture, denying source writes and symlink escapes", () => { + const guard = fileURLToPath( + new URL("./relay-fixture-guard.cjs", import.meta.url) + ); + const fixture = mkdtempSync(join(tmpdir(), "motion-guard-fixture-")); + const outside = mkdtempSync(join(tmpdir(), "motion-guard-outside-")); + writeFileSync(join(outside, "fictional.txt"), "fictional private fixture"); + symlinkSync(outside, join(fixture, "escape")); + const before = readFileSync(guard); + const result = spawnSync( + process.execPath, + [ + "--require", + guard, + "--input-type=module", + "-e", + ` + import assert from 'node:assert/strict'; + import { readFileSync, writeFileSync, openSync, renameSync } from 'node:fs'; + import { readFile, writeFile, open } from 'node:fs/promises'; + import { join } from 'node:path'; + const fixture = process.env.RELAY_FIXTURE_DIRECTORY; + const source = process.env.RELAY_GUARD_SOURCE; + assert.match(readFileSync(source, 'utf8'), /qualification child/); + assert.match(await readFile(source, 'utf8'), /qualification child/); + writeFileSync(join(fixture, 'owned.txt'), 'owned'); + await writeFile(join(fixture, 'async.txt'), 'async'); + assert.equal(await readFile(join(fixture, 'async.txt'), 'utf8'), 'async'); + assert.throws(() => writeFileSync(source, 'must not write'), /Fixture blocked/); + assert.throws(() => openSync(source, 'r+'), /Fixture blocked/); + await assert.rejects(open(source, 'w'), /Fixture blocked/); + assert.throws(() => renameSync(join(fixture, 'owned.txt'), source), /Fixture blocked/); + assert.throws(() => readFileSync(join(fixture, 'escape/fictional.txt')), /Fixture blocked/); + await assert.rejects(writeFile(join(fixture, 'escape/new.txt'), 'blocked'), /Fixture blocked/); + console.log('scoped guards passed'); + `, + ], + { + encoding: "utf8", + timeout: 3000, + cwd: fixture, + env: { + PATH: "/usr/bin:/bin", + RELAY_FIXTURE_DIRECTORY: fixture, + RELAY_GUARD_SOURCE: guard, + }, + } + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("scoped guards passed"); + expect(readFileSync(guard)).toEqual(before); +}); diff --git a/scripts/relay-runtime.mjs b/scripts/relay-runtime.mjs new file mode 100644 index 0000000..9b4db64 --- /dev/null +++ b/scripts/relay-runtime.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { dirname } from "node:path"; + +// Execute only the installed executable's factories, stopping before CLI code. +// The patch must route every outside caller to the installed Undici export. +export async function qualifyRuntime(binary, partyRequire, runtimeRequire) { + const bytes = readFileSync(binary, "utf8"); + const comments = [...bytes.matchAll(/^\/\/ [^\n]+$/gmu)]; + const factories = comments.flatMap((comment, index) => { + const end = comments[index + 1]?.index ?? bytes.length; + const factory = /var (require_[a-zA-Z0-9_]+) = __commonJS/u.exec( + bytes.slice(comment.index, end) + ); + return comment[0].includes("/undici/") && factory + ? [{ start: comment.index, end, name: factory[1] }] + : []; + }); + assert.equal(factories.length, 95); + const names = new Set(factories.map(({ name }) => name)); + const outside = [...bytes.matchAll(/\b(require_[a-zA-Z0-9_]+)\s*\(/gu)] + .filter( + (call) => + names.has(call[1]) && + !factories.some( + ({ start: rangeStart, end: rangeEnd }) => + call.index >= rangeStart && call.index < rangeEnd + ) + ) + .map((call) => call[1]); + assert.deepEqual(outside, new Array(6).fill("require_undici")); + const start = bytes.indexOf("var __create = Object.create;"); + const factory = bytes.indexOf("var require_undici = __commonJS({"); + const end = bytes.indexOf("\n// ", factory); + assert(start > 0 && factory > start && end > factory); + const builtins = new Set( + builtinModules.map((name) => name.replace(/^node:/u, "")) + ); + const loader = (name) => { + assert( + builtins.has(name.replace(/^node:/u, "")), + `Unexpected dependency: ${name}` + ); + return partyRequire(name); + }; + loader.resolve = (name) => { + assert.equal(name, "miniflare"); + return partyRequire.resolve(name); + }; + const entry = new Function( + "require", + "__filename", + "__dirname", + `${bytes.slice(start, end)}\nreturn require_undici();\n//# sourceURL=${binary}` + )(loader, binary, dirname(binary)); + assert.equal(runtimeRequire("undici/package.json").version, "6.29.0"); + assert.equal(entry, runtimeRequire("undici")); + const mock = new entry.MockAgent(); + mock.disableNetConnect(); + mock + .get("http://fixture.invalid") + .intercept({ path: "/bundled", method: "GET" }) + .reply(200, "entry-bytes-ok"); + try { + const response = await entry.fetch("http://fixture.invalid/bundled", { + dispatcher: mock, + }); + assert.equal(await response.text(), "entry-bytes-ok"); + mock.assertNoPendingInterceptors(); + } finally { + await mock.close(); + } + return "PASS: executable entry delegates to resolved Undici 6.29.0; network-disabled mock"; +} diff --git a/scripts/swift-format-result.mjs b/scripts/swift-format-result.mjs new file mode 100644 index 0000000..280086a --- /dev/null +++ b/scripts/swift-format-result.mjs @@ -0,0 +1,15 @@ +export function countSwiftFormatDiagnostics(result) { + const diagnostics = `${result.stdout ?? ""}\n${result.stderr ?? ""}` + .split("\n") + .filter((line) => /\.swift:\d+:\d+: error:/u.test(line)).length; + if (result.error) throw result.error; + if ( + ![0, 1].includes(result.status) || + (result.status === 1 && diagnostics === 0) + ) { + throw new Error( + `Swift format exited ${result.status} without valid lint diagnostics.` + ); + } + return diagnostics; +} diff --git a/scripts/swift-format-result.test.mjs b/scripts/swift-format-result.test.mjs new file mode 100644 index 0000000..a602358 --- /dev/null +++ b/scripts/swift-format-result.test.mjs @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { countSwiftFormatDiagnostics } from "./swift-format-result.mjs"; + +describe("Swift format tool result", () => { + it("accepts clean output and counts legacy lint debt", () => { + expect( + countSwiftFormatDiagnostics({ status: 0, stdout: "", stderr: "" }) + ).toBe(0); + expect( + countSwiftFormatDiagnostics({ + status: 1, + stderr: "A.swift:1:1: error: indentation\nB.swift:2:3: error: spacing", + }) + ).toBe(2); + }); + it.each([1, 2, null])( + "rejects unexplained failure %s instead of passing zero debt", + (status) => { + expect(() => + countSwiftFormatDiagnostics({ status, stderr: "tool failed" }) + ).toThrow(/without valid lint diagnostics/u); + } + ); + it("rejects abnormal exit even with diagnostic text", () => { + expect(() => + countSwiftFormatDiagnostics({ status: 2, stderr: "error: could not run" }) + ).toThrow(); + }); + it("does not count tool errors as ratchetable Swift lint debt", () => { + expect(() => + countSwiftFormatDiagnostics({ + status: 1, + stderr: "error: unable to load configuration", + }) + ).toThrow(); + }); +}); diff --git a/server/scripts/relay-publisher.test.mjs b/server/scripts/relay-publisher.test.mjs new file mode 100644 index 0000000..ac65b16 --- /dev/null +++ b/server/scripts/relay-publisher.test.mjs @@ -0,0 +1,87 @@ +import { createRequire } from "node:module"; +import { readFileSync, realpathSync } from "node:fs"; +import { channel } from "node:diagnostics_channel"; +import { createServer } from "node:http"; +import { expect, it } from "vitest"; + +const { diagnosticCallback, createCollector, hash } = createRequire( + import.meta.url +)("../../scripts/relay-diagnostics.cjs"); +const identity = { + port: 21999, + binary: "/fixture/bin.mjs", + guard: "/fixture/guard.cjs", + binarySha256: hash("executable fixture"), + mapSha256: hash("map fixture"), +}; +const request = { + method: "GET", + origin: "http://127.0.0.1:21999", + path: "/json", +}; + +it("qualifies only the actual installed Undici publisher on native V8 call sites", async () => { + const partyRequire = createRequire( + realpathSync( + new URL("../node_modules/partykit/dist/bin.mjs", import.meta.url) + ) + ); + const runtimeRequire = createRequire(partyRequire.resolve("miniflare")); + const publisher = realpathSync( + runtimeRequire.resolve("undici/lib/core/request.js") + ); + const server = createServer((incoming, response) => { + response.writeHead( + incoming.method === "GET" && incoming.url === "/json" ? 200 : 404 + ); + response.end("fictional inspector"); + }); + await new Promise((done, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", done); + }); + const inspectorPort = server.address().port; + const origin = `http://127.0.0.1:${inspectorPort}`; + const qualified = { + ...identity, + inspectorPort, + publisher, + publisherVersion: "6.29.0", + publisherSha256: hash(readFileSync(publisher)), + publisherQualified: true, + }; + const collector = createCollector(identity.port, qualified); + const callback = diagnosticCallback(qualified, (value) => + collector.push("stdout", value) + ); + const notifications = channel("undici:request:create"); + notifications.subscribe(callback); + const { Agent, fetch } = runtimeRequire("undici"); + const agent = new Agent(); + try { + const response = await fetch(`${origin}/json`, { + dispatcher: agent, + }); + expect(await response.text()).toBe("fictional inspector"); + expect(collector.qualified()).toBe(true); + expect(collector.observations()[0].publisher).toEqual({ + kind: "external-undici", + version: "6.29.0", + sourceSha256: qualified.publisherSha256, + }); + expect(collector.observations()[0].bundled).toBe("FALSE"); + } finally { + notifications.unsubscribe(callback); + await agent.close(); + await new Promise((done) => server.close(done)); + } + // Publishing the same fixture object directly has no implementation frame. + notifications.subscribe(callback); + const before = collector.observations().length; + try { + notifications.publish({ request: { ...request, origin } }); + } finally { + notifications.unsubscribe(callback); + } + expect(collector.observations()[before].bundled).toBe("UNKNOWN"); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 657f41c..c0e1432 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ reportsDirectory: "coverage", }, include: [ + "scripts/**/*.test.mjs", + "server/scripts/**/*.test.mjs", "protocol/**/*.test.ts", "server/src/**/*.test.ts", "web/src/**/*.test.ts",