|
| 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 | +} |
0 commit comments