diff --git a/scripts/build-windows-native.mjs b/scripts/build-windows-native.mjs index e5eae61115..823fcfa507 100644 --- a/scripts/build-windows-native.mjs +++ b/scripts/build-windows-native.mjs @@ -6,6 +6,13 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { importVerifierSource } from "./lib/verifier-source-loader.mjs"; + +const { WINDOWS_NATIVE_ARTIFACT, createWindowsNativeManifest } = + await importVerifierSource( + new URL("../src/domain/windowsNativeArtifact.ts", import.meta.url).href, + import.meta.url, + ); const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const nodeVersion = "22.19.0"; @@ -87,7 +94,7 @@ if (!(await exists(join(headers, "node_api.h")))) { run("tar", ["xzf", archive, "-C", cache]); headers = join(cache, `node-v${nodeVersion}`, "include", "node"); } -const artifact = "rea-windows-x64.node"; +const artifact = WINDOWS_NATIVE_ARTIFACT.artifact; // MSVC short-import libraries do not produce a correct Node import table with // GNU ld. Generate a GNU delay-import library for the exact stable Node-API // symbols; addon.cc binds it to the host executable, whatever its file name. @@ -116,7 +123,7 @@ run("x86_64-w64-mingw32-dlltool", ["-d", definition, "-y", library]); run(compiler, [ "-std=c++17", "-O2", - "-DNAPI_VERSION=8", + `-DNAPI_VERSION=${WINDOWS_NATIVE_ARTIFACT.nodeApiVersion}`, "-D_WIN32_WINNT=0x0A00", "-shared", "-static", @@ -140,15 +147,9 @@ const packageJson = JSON.parse( await readFile(join(root, "package.json"), "utf8"), ); const bytes = await readFile(join(output, artifact)); -const metadata = { - packageVersion: packageJson.version, - platform: "win32", - architecture: "x64", - abiVersion: 1, - nodeApiVersion: 8, - artifact, - artifactSha256: createHash("sha256").update(bytes).digest("hex"), -}; +const created = createWindowsNativeManifest(packageJson.version, bytes); +if (!created.ok) throw created.error; +const metadata = created.value; await writeFile( join(output, "manifest.json"), `${JSON.stringify(metadata, null, 2)}\n`, diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 9d6f7c62ce..478656e1c5 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -125,6 +125,11 @@ export const SCOPES = { "tests/boundary/filesystem/npmRelease.test.ts", "tests/boundary/process/releaseArtifactCanary.test.ts", "src/windows/**", + "src/domain/windowsNativeArtifact*.ts", + "src/domain/peCoffHeader*.ts", + "src/domain/result.ts", + "scripts/lib/verifier-source-loader.mjs", + "tests/boundary/process/windowsNativeArtifactVerifier.test.ts", "scripts/lib/windows-owned-command.mjs", "scripts/run-exclusive-command.mjs", "tests/process-global/runnerCancellation.test.ts", diff --git a/scripts/verify-windows-native-artifact.mjs b/scripts/verify-windows-native-artifact.mjs index 4db66e0ee5..f78881c177 100644 --- a/scripts/verify-windows-native-artifact.mjs +++ b/scripts/verify-windows-native-artifact.mjs @@ -1,39 +1,42 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; const root = new URL("../", import.meta.url); +const source = new URL("src/domain/windowsNativeArtifact.ts", root); +let sourceExists = true; +try { + await access(source); +} catch (cause) { + if (cause?.code !== "ENOENT") throw cause; + sourceExists = false; +} +const owner = sourceExists + ? await ( + await import("./lib/verifier-source-loader.mjs") + ).importVerifierSource(source.href, import.meta.url) + : await import(new URL("dist/domain/windowsNativeArtifact.js", root).href); +const { + WINDOWS_NATIVE_ARTIFACT, + parseWindowsNativeManifest, + validateWindowsNativeArtifact, +} = owner; const packageJson = JSON.parse( await readFile(new URL("package.json", root), "utf8"), ); -const manifest = JSON.parse( - await readFile(new URL("native/windows/build/manifest.json", root), "utf8"), +const parsed = parseWindowsNativeManifest( + JSON.parse( + await readFile(new URL("native/windows/build/manifest.json", root), "utf8"), + ), + packageJson.version, ); +if (!parsed.ok) throw parsed.error; +const manifest = parsed.value; const bytes = await readFile( - new URL("native/windows/build/rea-windows-x64.node", root), + new URL(`native/windows/build/${WINDOWS_NATIVE_ARTIFACT.artifact}`, root), ); -if ( - manifest.packageVersion !== packageJson.version || - manifest.platform !== "win32" || - manifest.architecture !== "x64" || - manifest.abiVersion !== 1 || - manifest.nodeApiVersion !== 8 || - manifest.artifact !== "rea-windows-x64.node" || - createHash("sha256").update(bytes).digest("hex") !== manifest.artifactSha256 -) - throw new Error( - "Windows native artifact metadata or SHA-256 does not match this package; run the Windows native build lane.", - ); -const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length; -if ( - bytes.toString("latin1", 0, 2) !== "MZ" || - offset > bytes.length - 26 || - bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || - bytes.readUInt16LE(offset + 4) !== 0x8664 || - bytes.readUInt16LE(offset + 24) !== 0x20b -) - throw new Error("Windows native artifact must be a Windows x64 PE32+ image."); +const valid = validateWindowsNativeArtifact(manifest, bytes); +if (!valid.ok) throw valid.error; process.stdout.write( `${JSON.stringify({ ok: true, ...manifest, artifactBytes: bytes.length })}\n`, ); diff --git a/src/domain/windowsNativeArtifact.test.ts b/src/domain/windowsNativeArtifact.test.ts new file mode 100644 index 0000000000..248469d7a7 --- /dev/null +++ b/src/domain/windowsNativeArtifact.test.ts @@ -0,0 +1,118 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + createWindowsNativeManifest, + parseWindowsNativeManifest, + validateWindowsNativeArtifact, +} from "./windowsNativeArtifact.js"; + +const fixture = () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ"); + bytes.writeUInt32LE(64, 0x3c); + bytes.write("PE\0\0", 64, "ascii"); + bytes.writeUInt16LE(0x8664, 68); + bytes.writeUInt16LE(0x20b, 88); + const manifest = { + packageVersion: "1.0.0", + platform: "win32", + architecture: "x64", + abiVersion: 1, + nodeApiVersion: 8, + artifact: "rea-windows-x64.node", + artifactSha256: createHash("sha256").update(bytes).digest("hex"), + }; + return { bytes, manifest }; +}; + +it("produces and admits the exact manifest/byte identity", () => { + const { bytes, manifest } = fixture(); + const created = createWindowsNativeManifest("1.0.0", bytes); + expect(created).toEqual({ ok: true, value: manifest }); + const parsed = parseWindowsNativeManifest(manifest, "1.0.0"); + expect(parsed).toEqual(created); + if (!parsed.ok) throw parsed.error; + expect(validateWindowsNativeArtifact(parsed.value, bytes)).toEqual({ + ok: true, + value: undefined, + }); + expect(parseWindowsNativeManifest(manifest, "2.0.0")).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("another REA version") }, + }); + expect( + validateWindowsNativeArtifact(parsed.value, Buffer.alloc(128)), + ).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("SHA-256") }, + }); +}); + +it.each([null, [], {}, "manifest"])( + "rejects malformed manifest %j", + (value) => { + expect(parseWindowsNativeManifest(value, "1.0.0")).toMatchObject({ + ok: false, + error: { _tag: "WindowsNativeArtifactError" }, + }); + }, +); + +it("rejects missing and unknown manifest fields", () => { + const { manifest } = fixture(); + expect( + parseWindowsNativeManifest({ ...manifest, extra: true }, "1.0.0"), + ).toMatchObject({ ok: false }); + for (const key of Object.keys(manifest)) { + const incomplete: Record = { ...manifest }; + delete incomplete[key]; + expect(parseWindowsNativeManifest(incomplete, "1.0.0")).toMatchObject({ + ok: false, + }); + expect( + parseWindowsNativeManifest({ ...manifest, [key]: null }, "1.0.0"), + ).toMatchObject({ ok: false }); + } +}); + +it.each([ + "platform", + "architecture", + "abiVersion", + "nodeApiVersion", + "artifact", + "artifactSha256", +])("rejects unsupported %s", (key) => { + const { manifest } = fixture(); + expect( + parseWindowsNativeManifest({ ...manifest, [key]: "unsupported" }, "1.0.0"), + ).toMatchObject({ ok: false }); +}); + +it.each([0, 63, 64, 89, 128])( + "rejects invalid PE content in %i bytes", + (length) => { + expect( + createWindowsNativeManifest("1.0.0", Buffer.alloc(length)), + ).toMatchObject({ ok: false }); + }, +); + +it.each(["offset", "signature", "machine", "optional magic"])( + "rejects invalid PE %s with a matching digest", + (field) => { + const { bytes } = fixture(); + if (field === "offset") bytes.writeUInt32LE(0xffff_ffff, 0x3c); + if (field === "signature") bytes.write("BAD!", 64, "ascii"); + if (field === "machine") bytes.writeUInt16LE(0x14c, 68); + if (field === "optional magic") bytes.writeUInt16LE(0x10b, 88); + const { manifest } = fixture(); + manifest.artifactSha256 = createHash("sha256").update(bytes).digest("hex"); + const parsed = parseWindowsNativeManifest(manifest, "1.0.0"); + if (!parsed.ok) throw parsed.error; + expect(validateWindowsNativeArtifact(parsed.value, bytes)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("PE32+") }, + }); + }, +); diff --git a/src/domain/windowsNativeArtifact.ts b/src/domain/windowsNativeArtifact.ts new file mode 100644 index 0000000000..96b1edbf1b --- /dev/null +++ b/src/domain/windowsNativeArtifact.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import { readPeCoffHeader } from "./peCoffHeader.js"; +import { err, ok, type Result } from "./result.js"; + +/** Fixed compatibility contract shared by the artifact producer and readers. */ +export const WINDOWS_NATIVE_ARTIFACT = { + platform: "win32", + architecture: "x64", + abiVersion: 1, + nodeApiVersion: 8, + artifact: "rea-windows-x64.node", +} as const; + +export type WindowsNativeManifest = typeof WINDOWS_NATIVE_ARTIFACT & { + readonly packageVersion: string; + readonly artifactSha256: string; +}; + +export class WindowsNativeArtifactError extends Error { + readonly _tag = "WindowsNativeArtifactError" as const; +} + +/** Parse the exact package manifest before acquiring or loading its artifact. */ +export const parseWindowsNativeManifest = ( + value: unknown, + packageVersion: string, +): Result => { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return err( + new WindowsNativeArtifactError( + "Windows native artifact manifest must be an object", + ), + ); + const manifest = value as Record; + const keys = [ + "packageVersion", + ...Object.keys(WINDOWS_NATIVE_ARTIFACT), + "artifactSha256", + ]; + if ( + Object.keys(manifest).length !== keys.length || + keys.some((key) => !Object.hasOwn(manifest, key)) + ) + return err( + new WindowsNativeArtifactError( + "Windows native artifact manifest has missing or unknown fields", + ), + ); + for (const [key, expected] of Object.entries(WINDOWS_NATIVE_ARTIFACT)) + if (manifest[key] !== expected) + return err( + new WindowsNativeArtifactError( + `Windows native artifact manifest has an invalid ${key}`, + ), + ); + if (typeof manifest.packageVersion !== "string") + return err( + new WindowsNativeArtifactError( + "Windows native artifact manifest has an invalid packageVersion", + ), + ); + if ( + typeof manifest.artifactSha256 !== "string" || + !/^[a-f0-9]{64}$/u.test(manifest.artifactSha256) + ) + return err( + new WindowsNativeArtifactError( + "Windows native artifact manifest has an invalid artifactSha256", + ), + ); + if (manifest.packageVersion !== packageVersion) + return err( + new WindowsNativeArtifactError( + "Windows native artifact belongs to another REA version", + ), + ); + return ok({ + ...WINDOWS_NATIVE_ARTIFACT, + packageVersion: manifest.packageVersion, + artifactSha256: manifest.artifactSha256, + }); +}; + +const validateWindowsX64Artifact = ( + bytes: Buffer, +): Result => { + if (bytes.length < 64 || bytes.toString("latin1", 0, 2) !== "MZ") + return err( + new WindowsNativeArtifactError( + "Windows native artifact is not a PE image", + ), + ); + const offset = bytes.readUInt32LE(0x3c); + if ( + offset > bytes.length - 26 || + bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || + bytes.readUInt16LE(offset + 24) !== 0x20b + ) + return err( + new WindowsNativeArtifactError( + "Windows native artifact is not a Windows x64 PE32+ image", + ), + ); + const coff = readPeCoffHeader(bytes.subarray(offset + 4, offset + 24)); + if (!coff.ok || coff.value.machine !== 0x8664) + return err( + new WindowsNativeArtifactError( + "Windows native artifact is not a Windows x64 PE32+ image", + ), + ); + return ok(undefined); +}; + +/** Bind the parsed manifest to its exact bytes before native code can load. */ +export const validateWindowsNativeArtifact = ( + manifest: WindowsNativeManifest, + bytes: Buffer, +): Result => { + if ( + createHash("sha256").update(bytes).digest("hex") !== manifest.artifactSha256 + ) + return err( + new WindowsNativeArtifactError( + "Windows native artifact SHA-256 does not match package metadata", + ), + ); + return validateWindowsX64Artifact(bytes); +}; + +/** Produce metadata only for a Windows x64 PE32+ artifact. */ +export const createWindowsNativeManifest = ( + packageVersion: string, + bytes: Buffer, +): Result => { + const valid = validateWindowsX64Artifact(bytes); + if (!valid.ok) return valid; + return parseWindowsNativeManifest( + { + ...WINDOWS_NATIVE_ARTIFACT, + packageVersion, + artifactSha256: createHash("sha256").update(bytes).digest("hex"), + }, + packageVersion, + ); +}; diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index b036e88b82..b91f5e6cf4 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -1,5 +1,3 @@ -import { readPeCoffHeader } from "../domain/peCoffHeader.js"; -import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -7,6 +5,11 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import { PACKAGE_METADATA } from "../generatedPackageMetadata.js"; +import { + WINDOWS_NATIVE_ARTIFACT, + parseWindowsNativeManifest, + validateWindowsNativeArtifact, +} from "../domain/windowsNativeArtifact.js"; /** A native resource remains opaque until the addon validates its object tag. */ export const windowsHandleSchema = z.custom( @@ -25,21 +28,10 @@ export const windowsFileIdentitySchema = z.strictObject({ directory: z.boolean(), }); -/** Metadata bound to one packaged Windows x64 Node-API artifact. */ -export const windowsNativeManifestSchema = z.strictObject({ - packageVersion: z.string(), - platform: z.literal("win32"), - architecture: z.literal("x64"), - abiVersion: z.literal(1), - nodeApiVersion: z.literal(8), - artifact: z.literal("rea-windows-x64.node"), - artifactSha256: z.string().regex(/^[a-f0-9]{64}$/u), -}); - const inspectionSchema = z.strictObject({ - abiVersion: z.literal(1), - nodeApiVersion: z.literal(8), - architecture: z.literal("x64"), + abiVersion: z.literal(WINDOWS_NATIVE_ARTIFACT.abiVersion), + nodeApiVersion: z.literal(WINDOWS_NATIVE_ARTIFACT.nodeApiVersion), + architecture: z.literal(WINDOWS_NATIVE_ARTIFACT.architecture), filesystem: windowsFileIdentitySchema, privateDacl: z.literal(true), privateBridgeDescriptorAccess: z.literal(true), @@ -126,29 +118,27 @@ export const loadWindowsNativeAuthority = ( reason: "REA Windows native controls require a Windows x64 host.", }; const nodeApiVersion = Number(host.nodeApiVersion); - if (!Number.isSafeInteger(nodeApiVersion) || nodeApiVersion < 8) + if ( + !Number.isSafeInteger(nodeApiVersion) || + nodeApiVersion < WINDOWS_NATIVE_ARTIFACT.nodeApiVersion + ) return { available: false, failureStage: "host", reason: "REA Windows native controls require Node-API 8 or newer.", }; try { - const manifest = windowsNativeManifestSchema.parse( + const manifest = parseWindowsNativeManifest( readPackagedWindowsNativeFile("manifest.json", () => host.readManifest()), + host.packageVersion, ); - if (manifest.packageVersion !== host.packageVersion) - throw new Error("Windows native artifact belongs to another REA version"); - const artifact = readPackagedWindowsNativeFile("rea-windows-x64.node", () => - host.readArtifact(), + if (!manifest.ok) throw manifest.error; + const artifact = readPackagedWindowsNativeFile( + WINDOWS_NATIVE_ARTIFACT.artifact, + () => host.readArtifact(), ); - if ( - createHash("sha256").update(artifact).digest("hex") !== - manifest.artifactSha256 - ) - throw new Error( - "Windows native artifact SHA-256 does not match package metadata", - ); - assertWindowsX64Artifact(artifact); + const valid = validateWindowsNativeArtifact(manifest.value, artifact); + if (!valid.ok) throw valid.error; let loadedModule: unknown; try { loadedModule = host.loadArtifact(); @@ -189,21 +179,6 @@ export const loadWindowsNativeAuthority = ( } }; -const assertWindowsX64Artifact = (bytes: Buffer): void => { - if (bytes.length < 64 || bytes.toString("latin1", 0, 2) !== "MZ") - throw new Error("Windows native artifact is not a PE image"); - const offset = bytes.readUInt32LE(0x3c); - if ( - offset > bytes.length - 26 || - bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || - bytes.readUInt16LE(offset + 24) !== 0x20b - ) - throw new Error("Windows native artifact is not a Windows x64 PE32+ image"); - const coff = readPeCoffHeader(bytes.subarray(offset + 4, offset + 24)); - if (!coff.ok || coff.value.machine !== 0x8664) - throw new Error("Windows native artifact is not a Windows x64 PE32+ image"); -}; - let cached: WindowsNativeLoadResult | undefined; /** Cache stable admission; let a later query retry failed owned inspection. */ diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index ffee09d45e..19965f2b67 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -738,6 +738,19 @@ it.each([ "src/windows/Example.ts", { code: true, runtime: true, package: true, apple: false, evm: false }, ], + [ + "src/domain/windowsNativeArtifact.ts", + { code: true, runtime: true, package: true }, + ], + ["src/domain/peCoffHeader.ts", { code: true, runtime: true, package: true }], + [ + "scripts/lib/verifier-source-loader.mjs", + { code: true, runtime: true, package: true }, + ], + [ + "tests/boundary/process/windowsNativeArtifactVerifier.test.ts", + { code: true, package: true }, + ], [ "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", { code: true, runtime: true, package: true, javascript_recovery: true }, diff --git a/tests/boundary/process/windowsNativeArtifactVerifier.test.ts b/tests/boundary/process/windowsNativeArtifactVerifier.test.ts new file mode 100644 index 0000000000..b1d2744661 --- /dev/null +++ b/tests/boundary/process/windowsNativeArtifactVerifier.test.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { processTest } from "../../support/process/processFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const repository = fileURLToPath(new URL("../../..", import.meta.url)); + +for (const mode of ["preinstall source", "packed source-free"]) { + processTest( + `verifies native artifact identity from ${mode} without installed dependencies`, + async ({ processes }) => { + const root = await createTestTempDirectory( + "rea-native-artifact-verifier-", + ); + const paths = + mode === "preinstall source" + ? [ + "scripts/lib/verifier-source-loader.mjs", + "src/domain/windowsNativeArtifact.ts", + "src/domain/peCoffHeader.ts", + "src/domain/result.ts", + ] + : [ + "dist/domain/windowsNativeArtifact.js", + "dist/domain/peCoffHeader.js", + "dist/domain/result.js", + ]; + for (const path of [ + "scripts/verify-windows-native-artifact.mjs", + ...paths, + ]) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await copyFile(join(repository, path), join(root, path)); + } + await writeFile( + join(root, "package.json"), + JSON.stringify({ type: "module", version: "1.0.0" }), + ); + const artifactRoot = join(root, "native/windows/build"); + await mkdir(artifactRoot, { recursive: true }); + // An inert PE header establishes artifact validation, never addon loading. + const bytes = Buffer.alloc(128); + bytes.write("MZ"); + bytes.writeUInt32LE(64, 0x3c); + bytes.write("PE\0\0", 64, "ascii"); + bytes.writeUInt16LE(0x8664, 68); + bytes.writeUInt16LE(0x20b, 88); + const manifest = { + packageVersion: "1.0.0", + platform: "win32", + architecture: "x64", + abiVersion: 1, + nodeApiVersion: 8, + artifact: "rea-windows-x64.node", + artifactSha256: createHash("sha256").update(bytes).digest("hex"), + }; + await writeFile(join(artifactRoot, manifest.artifact), bytes); + const manifestPath = join(artifactRoot, "manifest.json"); + await writeFile(manifestPath, JSON.stringify(manifest)); + const run = () => + processes.run( + process.execPath, + [join(root, "scripts/verify-windows-native-artifact.mjs")], + { cwd: root, timeoutMs: 10_000 }, + ); + const valid = await run(); + expect(valid.exitCode, valid.stderr).toBe(0); + expect(JSON.parse(valid.stdout)).toEqual({ + ok: true, + ...manifest, + artifactBytes: bytes.length, + }); + await writeFile( + manifestPath, + JSON.stringify({ ...manifest, extra: true }), + ); + const invalid = await run(); + expect(invalid.exitCode).not.toBe(0); + expect(invalid.stderr).toContain("missing or unknown fields"); + }, + ); +}