fix: fail closed on release input discovery

This commit is contained in:
DongHyeonka
2026-08-02 05:11:30 +09:00
parent 381d5549e2
commit d6c98489ee
9 changed files with 975 additions and 69 deletions
+142
View File
@@ -1,7 +1,10 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { verifyBuildManifestOutputs } from "../../scripts/lib/build-manifest-outputs.ts";
import {
dependencyInventoryArtifactSchema,
fieldWebVitalsArtifactSchema,
@@ -58,6 +61,145 @@ const buildManifest = {
} as const;
describe("release artifact contracts", () => {
it("verifies confined build outputs and the raw module inventory bytes", async () => {
const moduleInventoryBytes = Buffer.from(
'{"schemaVersion":1,"chunks":[]}\n',
);
const files = new Map<string, Buffer>([
["/repo/dist/.vite/manifest.json", Buffer.from("{}\n")],
["/repo/artifacts/quality/vite-module-inventory.json", moduleInventoryBytes],
["/repo/dist/runtime-config.schema.json", Buffer.from("{}\n")],
["/repo/dist/assets/home.js", Buffer.from("chunk\n")],
]);
const manifest = {
...buildManifest,
moduleInventoryHash: createHash("sha256")
.update(moduleInventoryBytes)
.digest("hex"),
};
await expect(
verifyBuildManifestOutputs(manifest, {
repositoryRoot: "/repo",
readBytes: async (target) => files.get(target) ?? Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
}),
).resolves.toEqual([]);
});
it.each([
["missing", undefined, /moduleInventory:missing/u],
[
"tampered",
Buffer.from('{"schemaVersion":1,"chunks":[{"fileName":"other.js","modules":[]}]}\n'),
/moduleInventoryHash/u,
],
] as const)("rejects a %s module inventory", async (_name, bytes, expected) => {
const files = new Map<string, Buffer>([
["/repo/dist/.vite/manifest.json", Buffer.from("{}\n")],
["/repo/dist/runtime-config.schema.json", Buffer.from("{}\n")],
["/repo/dist/assets/home.js", Buffer.from("chunk\n")],
...(bytes ? [["/repo/artifacts/quality/vite-module-inventory.json", bytes] as const] : []),
]);
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
repositoryRoot: "/repo",
readBytes: async (target) => files.get(target) ?? Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
});
expect(mismatches.join("\n")).toMatch(expected);
});
it.each(["../outside", "/absolute", "dist\\escape"])(
"rejects unsafe build-manifest output path %s",
async (unsafePath) => {
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
outputs: { ...buildManifest.outputs, moduleInventory: unsafePath },
},
{
repositoryRoot: "/repo",
realpathPath: async (target) => target,
},
);
expect(mismatches).toContain("buildManifest:moduleInventory:path");
},
);
it("rejects a realpath escape from a declared build output", async () => {
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
repositoryRoot: "/repo",
realpathPath: async (target) =>
target.endsWith("vite-module-inventory.json") ? "/outside/inventory.json" : target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
readBytes: async () => Buffer.from("inventory\n"),
});
expect(mismatches).toContain("buildManifest:moduleInventory:path");
});
it("rejects a nested symlink that resolves inside the repository but outside dist", async () => {
const moduleInventoryBytes = Buffer.from(
'{"schemaVersion":1,"chunks":[]}\n',
);
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
moduleInventoryHash: createHash("sha256")
.update(moduleInventoryBytes)
.digest("hex"),
},
{
repositoryRoot: "/repo",
realpathPath: async (target) =>
target === "/repo/dist/assets/home.js"
? "/repo/src/home.js"
: target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
readBytes: async () => moduleInventoryBytes,
},
);
expect(mismatches).toContain("buildManifest:routeChunk:route-home:path");
});
it.each([
["viteManifest", "package.json"],
["runtimeConfigSchema", "schemas/artifacts/build-manifest.schema.json"],
["moduleInventory", "package.json"],
] as const)("rejects %s outside its approved output root", async (field, value) => {
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
outputs: { ...buildManifest.outputs, [field]: value },
},
{ repositoryRoot: process.cwd() },
);
expect(mismatches).toContain(`buildManifest:${field}:path`);
});
it("rejects a parse-invalid module inventory even when its raw hash matches", async () => {
const bytes = Buffer.from("{}\n");
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
moduleInventoryHash: createHash("sha256").update(bytes).digest("hex"),
},
{
repositoryRoot: "/repo",
readBytes: async () => bytes,
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
},
);
expect(mismatches).toContain("buildManifest:moduleInventory:invalid");
});
it("projects the nested V2 contract-set digest without a legacy scalar", () => {
const release = parseReleaseArtifact(releaseV2);