Skip to content

Commit 3fbb840

Browse files
committed
공급망: V86 release 조립 gate를 추가
Ubuntu snapshot package와 실제 tool version을 fail-closed lock으로 강화했다. 검증 build의 source, legal, SBOM, runtime을 원자적 release 한 벌로 조립한다. 검증: npm test 13/13, 계약 46개, provenance와 package gate 통과. 실물 검증: run 31948147968 자산 조립과 변조 거부 통과.
1 parent 84aed3b commit 3fbb840

8 files changed

Lines changed: 336 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"assets:wasm-tools": "node scripts/wasmToolBuilder/buildOwnedWasmTools.mjs --check",
138138
"assets:v86": "node scripts/v86Builder/buildV86Assets.mjs",
139139
"assets:v86:verify": "node scripts/v86Builder/verifyV86Assets.mjs",
140+
"assets:v86-release": "node scripts/release/assembleV86Release.mjs",
140141
"package:reproduce": "node scripts/packageBuilder/buildCanonicalPackage.mjs",
141142
"package:verify": "node scripts/packageBuilder/verifyCanonicalPackage.mjs",
142143
"serve": "node scripts/staticServer.mjs",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
// 검증된 V86 실행 자산과 source, legal material을 project release 한 벌로 조립한다.
3+
import { createHash } from "node:crypto";
4+
import { existsSync } from "node:fs";
5+
import { copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6+
import { dirname, join, relative, resolve, sep } from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
9+
import { createDeterministicZip } from "./assembleBuildrootRelease.mjs";
10+
11+
const scriptDir = dirname(fileURLToPath(import.meta.url));
12+
const root = resolve(scriptDir, "..", "..");
13+
const cacheRoot = resolve(root, ".cache");
14+
const lock = JSON.parse(await readFile(resolve(root, "scripts/v86Builder/v86BuildLock.json"), "utf8"));
15+
const sha256Pattern = /^[0-9a-f]{64}$/u;
16+
const commitPattern = /^[0-9a-f]{40}$/u;
17+
const runtimeNames = ["libv86.mjs", "v86.wasm", "seabios.bin", "vgabios.bin"];
18+
const legalNames = [
19+
"legal/COPYING.LESSER.seabios",
20+
"legal/COPYING.seabios",
21+
"legal/LICENSE.softfloat-source.c",
22+
"legal/LICENSE.v86",
23+
"legal/LICENSE.v86-mit",
24+
"legal/LICENSE.zstd-source.c",
25+
];
26+
const inputNames = [
27+
"inputs/fetch-and-build-seabios.sh",
28+
"inputs/seabios.config",
29+
"inputs/v86BuildLock.json",
30+
];
31+
32+
function sortPaths(paths) {
33+
return [...paths].sort((left, right) => Buffer.from(left, "utf8").compare(Buffer.from(right, "utf8")));
34+
}
35+
36+
async function sha256(path) {
37+
return createHash("sha256").update(await readFile(path)).digest("hex");
38+
}
39+
40+
async function descriptor(path, name) {
41+
return Object.freeze({ name, byteLength: (await stat(path)).size, sha256: await sha256(path) });
42+
}
43+
44+
async function filesBelow(directory, current = directory) {
45+
const found = [];
46+
for (const entry of await readdir(current, { withFileTypes: true })) {
47+
const path = resolve(current, entry.name);
48+
if (entry.isDirectory()) found.push(...await filesBelow(directory, path));
49+
else if (entry.isFile()) found.push(relative(directory, path).split(sep).join("/"));
50+
else throw new Error(`V86 verified directory has unsupported entry: ${entry.name}`);
51+
}
52+
return sortPaths(found);
53+
}
54+
55+
function assertSourceIdentity(manifest) {
56+
const v86 = manifest.sources?.v86;
57+
const seabios = manifest.sources?.seabios;
58+
if (v86?.version !== lock.v86.version || v86?.revision !== lock.v86.revision || v86?.tree !== lock.v86.tree
59+
|| seabios?.version !== lock.seabios.version || seabios?.revision !== lock.seabios.revision
60+
|| seabios?.tree !== lock.seabios.tree || manifest.toolchain?.ubuntuSnapshot !== lock.toolchain.ubuntuSnapshot) {
61+
throw new Error("V86 release source or toolchain identity mismatch");
62+
}
63+
}
64+
65+
async function assertSbom(directory) {
66+
const sbom = JSON.parse(await readFile(resolve(directory, "v86-assets.cyclonedx.json"), "utf8"));
67+
const licenses = new Map((sbom.components || []).map((entry) => [
68+
entry.name, entry.licenses?.[0]?.license?.id,
69+
]));
70+
const required = new Map([
71+
["v86", "BSD-2-Clause"],
72+
["v86 QEMU floppy portions", "MIT"],
73+
["Berkeley SoftFloat", "BSD-3-Clause"],
74+
["Zstandard single-file decompressor", "BSD-3-Clause"],
75+
["SeaBIOS", "LGPL-3.0-only"],
76+
]);
77+
if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6"
78+
|| [...required].some(([name, license]) => licenses.get(name) !== license)) {
79+
throw new Error("V86 release SBOM component or license inventory mismatch");
80+
}
81+
}
82+
83+
export async function validateV86VerifiedDirectory({ verifiedDir, targetCommit }) {
84+
const directory = resolve(verifiedDir);
85+
if (!commitPattern.test(targetCommit || "")) throw new TypeError("target commit must be a 40 character Git SHA");
86+
const manifestBytes = await readFile(resolve(directory, "build-manifest.json"));
87+
const receiptBytes = await readFile(resolve(directory, "reproducibility-manifest.json"));
88+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
89+
const receipt = JSON.parse(receiptBytes.toString("utf8"));
90+
assertSourceIdentity(manifest);
91+
if (manifest.schemaVersion !== 1 || manifest.recipe !== lock.recipe
92+
|| receipt.schemaVersion !== 1 || receipt.recipe !== manifest.recipe
93+
|| receipt.headSha !== targetCommit || !/^\d+$/u.test(receipt.runId || "")
94+
|| receipt.independentBuilds?.join(",") !== "a,b" || receipt.byteIdentical !== true
95+
|| JSON.stringify(receipt.artifacts) !== JSON.stringify(manifest.artifacts)) {
96+
throw new Error("V86 build manifest and reproducibility receipt mismatch");
97+
}
98+
const sourceNames = [
99+
`v86-${lock.v86.version}-source.tar`,
100+
`seabios-${lock.seabios.version}-source.tar`,
101+
];
102+
const required = sortPaths([...runtimeNames, ...sourceNames, "v86-assets.cyclonedx.json",
103+
...legalNames, ...inputNames]);
104+
const declared = sortPaths(manifest.artifacts.map((entry) => entry.name));
105+
if (declared.join("\n") !== required.join("\n")
106+
|| new Set(declared).size !== declared.length
107+
|| manifest.artifacts.some((entry) => !Number.isSafeInteger(entry.byteLength)
108+
|| entry.byteLength < 1 || !sha256Pattern.test(entry.sha256 || ""))) {
109+
throw new Error("V86 release artifact inventory mismatch");
110+
}
111+
const actual = await filesBelow(directory);
112+
const expectedActual = sortPaths([...declared, "build-manifest.json", "reproducibility-manifest.json"]);
113+
if (actual.join("\n") !== expectedActual.join("\n")) throw new Error("V86 verified directory inventory drifted");
114+
for (const entry of manifest.artifacts) {
115+
const path = resolve(directory, ...entry.name.split("/"));
116+
if ((await stat(path)).size !== entry.byteLength || await sha256(path) !== entry.sha256) {
117+
throw new Error(`V86 verified artifact integrity mismatch: ${entry.name}`);
118+
}
119+
}
120+
await assertSbom(directory);
121+
return Object.freeze({ directory, manifest, receipt, sourceNames });
122+
}
123+
124+
function assertOutput(path) {
125+
const output = resolve(path);
126+
if (output === cacheRoot || !output.startsWith(`${cacheRoot}${sep}`)) {
127+
throw new TypeError("V86 release output must be below repository .cache");
128+
}
129+
if (existsSync(output)) throw new Error("V86 release output already exists");
130+
return output;
131+
}
132+
133+
export async function assembleV86Release({ verifiedDir, releaseTag, targetCommit, outputDir }) {
134+
if (!/^pyproc-v86-assets-v\d+$/u.test(releaseTag || "")) throw new TypeError("V86 release tag is invalid");
135+
const output = assertOutput(outputDir);
136+
const verified = await validateV86VerifiedDirectory({ verifiedDir, targetCommit });
137+
await mkdir(dirname(output), { recursive: true });
138+
const workspace = await mkdtemp(join(dirname(output), ".v86Release-"));
139+
const staged = resolve(workspace, "release");
140+
try {
141+
await mkdir(staged);
142+
const copied = [
143+
...runtimeNames,
144+
...verified.sourceNames,
145+
"v86-assets.cyclonedx.json",
146+
"build-manifest.json",
147+
"reproducibility-manifest.json",
148+
];
149+
for (const name of copied) await copyFile(resolve(verified.directory, name), resolve(staged, name));
150+
await createDeterministicZip({
151+
sourceDirectory: verified.directory,
152+
target: resolve(staged, "v86-assets-legal.zip"),
153+
files: legalNames,
154+
sourceDateEpoch: lock.v86.sourceDateEpoch,
155+
});
156+
await createDeterministicZip({
157+
sourceDirectory: verified.directory,
158+
target: resolve(staged, "v86-assets-inputs.zip"),
159+
files: inputNames,
160+
sourceDateEpoch: lock.v86.sourceDateEpoch,
161+
});
162+
const assetNames = sortPaths(await readdir(staged));
163+
const assets = await Promise.all(assetNames.map((name) => descriptor(resolve(staged, name), name)));
164+
const release = {
165+
schemaVersion: 1,
166+
releaseTag,
167+
targetCommit,
168+
githubRunId: verified.receipt.runId,
169+
sources: verified.manifest.sources,
170+
assets,
171+
legalEntries: legalNames.length,
172+
inputEntries: inputNames.length,
173+
};
174+
await writeFile(resolve(staged, "releaseAssets.json"), `${JSON.stringify(release, null, 2)}\n`);
175+
await rename(staged, output);
176+
return Object.freeze(release);
177+
} finally {
178+
await rm(workspace, { recursive: true, force: true });
179+
}
180+
}
181+
182+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
183+
const value = (name) => {
184+
const index = process.argv.indexOf(name);
185+
return index >= 0 ? process.argv[index + 1] : null;
186+
};
187+
const options = {
188+
verifiedDir: value("--verified-dir"),
189+
releaseTag: value("--tag"),
190+
targetCommit: value("--target-commit"),
191+
outputDir: value("--out"),
192+
};
193+
if (Object.values(options).some((entry) => !entry)) {
194+
throw new TypeError("usage: assembleV86Release.mjs --verified-dir <dir> --tag <tag> --target-commit <sha> --out <dir>");
195+
}
196+
console.log(JSON.stringify(await assembleV86Release(options), null, 2));
197+
}

scripts/v86Builder/buildV86Assets.mjs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,28 @@ const clangPath = output("which", ["clang-18"]);
164164
const clangVersion = output(clangPath, ["--version"]);
165165
const javaVersion = output("java", ["-version"]);
166166
const pythonVersion = output("python3", ["--version"]);
167+
const gccVersion = output("gcc", ["--version"]);
168+
const ldVersion = output("ld", ["--version"]);
169+
const makeVersion = output("make", ["--version"]);
170+
const iaslVersion = output("iasl", ["-v"]);
171+
const packageVersions = output("dpkg-query", [
172+
"-W", "-f=${binary:Package}\t${Version}\n", ...Object.keys(lock.toolchain.ubuntuPackages),
173+
]).split("\n").sort();
174+
const expectedPackageVersions = Object.entries(lock.toolchain.ubuntuPackages)
175+
.map(([name, version]) => `${name}\t${version}`).sort();
167176
assertVersion(nodeVersion, `v${lock.toolchain.node}`, "Node");
168177
assertVersion(rustcVersion, `rustc ${lock.toolchain.rust.version}`, "Rust");
169178
assertVersion(rustcVersion, `commit-hash: ${lock.toolchain.rust.commit}`, "Rust commit");
170179
assertVersion(clangVersion, lock.toolchain.clang, "Clang");
171180
assertVersion(javaVersion, lock.toolchain.java, "Java");
181+
assertVersion(pythonVersion, lock.toolchain.python, "Python");
182+
assertVersion(gccVersion, lock.toolchain.gcc, "GCC");
183+
assertVersion(ldVersion, lock.toolchain.ld, "ld");
184+
assertVersion(makeVersion, lock.toolchain.make, "make");
185+
assertVersion(iaslVersion, lock.toolchain.iasl, "IASL");
186+
if (packageVersions.join("\n") !== expectedPackageVersions.join("\n")) {
187+
throw new Error(`Ubuntu package version mismatch: ${packageVersions.join(", ")}`);
188+
}
172189
await symlink(clangPath, join(toolBin, "clang"));
173190

174191
const closurePath = join(downloads, "closure-compiler-v20210601.jar");
@@ -271,19 +288,17 @@ const manifest = {
271288
toolchain: {
272289
runnerImage: lock.toolchain.runnerImage,
273290
ubuntuSnapshot,
274-
ubuntuPackages: output("dpkg-query", [
275-
"-W", "-f=${binary:Package}\t${Version}\n", ...lock.toolchain.ubuntuPackages,
276-
]).split("\n"),
291+
ubuntuPackages: packageVersions,
277292
node: nodeVersion,
278293
rustc: rustcVersion.split("\n"),
279294
cargo: cargoVersion.split("\n"),
280295
clang: clangVersion.split("\n"),
281296
java: javaVersion.split("\n"),
282297
python: pythonVersion.split("\n"),
283-
gcc: output("gcc", ["--version"]).split("\n"),
284-
ld: output("ld", ["--version"]).split("\n"),
285-
make: output("make", ["--version"]).split("\n"),
286-
iasl: output("iasl", ["-v"]).split("\n"),
298+
gcc: gccVersion.split("\n"),
299+
ld: ldVersion.split("\n"),
300+
make: makeVersion.split("\n"),
301+
iasl: iaslVersion.split("\n"),
287302
closureCompiler: lock.toolchain.closureCompiler,
288303
},
289304
sourceInputs,

scripts/v86Builder/v86BuildLock.json

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@
3333
"toolchain": {
3434
"runnerImage": "ubuntu-24.04",
3535
"ubuntuSnapshot": "20260705T120000Z",
36-
"ubuntuPackages": [
37-
"acpica-tools",
38-
"binutils",
39-
"clang-18",
40-
"gcc",
41-
"make",
42-
"python3"
43-
],
36+
"ubuntuPackages": {
37+
"acpica-tools": "20230628-1",
38+
"binutils": "2.42-4ubuntu2.10",
39+
"clang-18": "1:18.1.3-1ubuntu1",
40+
"gcc": "4:13.2.0-7ubuntu1",
41+
"make": "4.3-4.1build2",
42+
"python3": "3.12.3-0ubuntu2.1"
43+
},
4444
"node": "24.17.0",
4545
"rust": {
4646
"version": "1.96.1",
@@ -49,6 +49,11 @@
4949
},
5050
"clang": "18.1.3",
5151
"java": "17.0.19",
52+
"python": "Python 3.12.3",
53+
"gcc": "gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0",
54+
"ld": "GNU ld (GNU Binutils for Ubuntu) 2.42",
55+
"make": "GNU Make 4.3",
56+
"iasl": "ASL+ Optimizing Compiler/Disassembler version 20230628",
5257
"closureCompiler": {
5358
"version": "v20210601",
5459
"url": "https://repo1.maven.org/maven2/com/google/javascript/closure-compiler/v20210601/closure-compiler-v20210601.jar",

tests/contracts/run.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const EXPECTED_SUITES = Object.freeze([
4949
"storageDurability.mjs",
5050
"v86AssetIntegrity.mjs",
5151
"v86Builder.mjs",
52+
"v86ReleaseAssembler.mjs",
5253
"valueEnvelope.mjs",
5354
"verification.mjs",
5455
"wasmToolLayer.mjs",

tests/contracts/v86Builder.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ export async function assertV86Builder() {
5050
assert(lock.toolchain.rust.version === "1.96.1" && lock.toolchain.node === "24.17.0",
5151
"V86 build toolchain is not exact");
5252
assert(/^\d{8}T\d{6}Z$/u.test(lock.toolchain.ubuntuSnapshot)
53-
&& lock.toolchain.ubuntuPackages.includes("gcc")
54-
&& lock.toolchain.ubuntuPackages.includes("acpica-tools"),
53+
&& lock.toolchain.ubuntuPackages.gcc === "4:13.2.0-7ubuntu1"
54+
&& lock.toolchain.ubuntuPackages["acpica-tools"] === "20230628-1"
55+
&& lock.toolchain.gcc.includes("13.3.0") && lock.toolchain.iasl.includes("20230628"),
5556
"V86 Ubuntu package snapshot is not exact");
5657
assert(Object.values(lock.referenceOutputs).every((entry) => /^[0-9a-f]{64}$/u.test(entry.sha256)
5758
&& Number.isSafeInteger(entry.byteLength)), "V86 reference output descriptors are incomplete");

0 commit comments

Comments
 (0)