import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const releaseRoot = path.join(root, "release"); const reportPath = path.join(root, "tests/golden/M13-05E/supply-chain-report.json"); const manifestPath = path.join(root, "tests/golden/M13-05E/manifest.json"); const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); const fileSha256 = (file) => sha256(fs.readFileSync(file)); const archive = path.join(releaseRoot, "blender-web-offline.tar.gz"); const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"); const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt"); const sbomPath = path.join(root, "docs/web/sbom.spdx.json"); const noticesPath = path.join(root, "docs/web/third-party-notices.json"); const lockPath = path.join(root, "web/package-lock.json"); const packagePath = path.join(root, "web/package.json"); const sbom = JSON.parse(fs.readFileSync(sbomPath, "utf8")); const notices = JSON.parse(fs.readFileSync(noticesPath, "utf8")); const lockBytes = fs.readFileSync(lockPath); const noticesBytes = fs.readFileSync(noticesPath); const lockHash = sha256(lockBytes); const noticesHash = sha256(noticesBytes); assert.equal(sbom.spdxVersion, "SPDX-2.3"); assert.equal(sbom.documentNamespace, `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`); const rootPackage = sbom.packages.find((item) => item.SPDXID === "SPDXRef-Package-blender-web-editor"); assert.ok(rootPackage); assert.equal(rootPackage.checksums?.find((item) => item.algorithm === "SHA256")?.checksumValue, lockHash); assert.ok(notices.packages.length > 0); assert.ok(fs.statSync(archive).isFile()); assert.ok(fs.statSync(sourceArchive).isFile()); const sums = new Map(fs.readFileSync(sumsPath, "utf8").trim().split(/\r?\n/u).map((line) => { const match = line.match(/^([a-f0-9]{64}) (.+)$/u); assert.ok(match, `invalid checksum line ${line}`); return [match[2], match[1]]; })); assert.equal(sums.get(path.basename(archive)), fileSha256(archive)); assert.equal(sums.get(path.basename(sourceArchive)), fileSha256(sourceArchive)); function archiveEntries(file) { return execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split(/\r?\n/u).filter(Boolean); } function archiveFile(file, entry) { return execFileSync("tar", ["-xOf", file, entry], { maxBuffer: 64 * 1024 * 1024 }); } const binaryEntries = archiveEntries(archive); const sourceEntries = archiveEntries(sourceArchive); for (const entry of ["blender-web-offline/sbom.spdx.json", "blender-web-offline/third-party-notices.json", "blender-web-offline/SOURCE_OFFER.txt", "blender-web-offline/manifest.json"]) assert.ok(binaryEntries.includes(entry), `binary archive omits ${entry}`); for (const entry of ["web/package.json", "web/package-lock.json", "docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "docs/web/DEPLOYMENT.md", "tools/web/create-offline-release.mjs"]) assert.ok(sourceEntries.includes(entry), `source archive omits ${entry}`); const sourceOffer = archiveFile(archive, "blender-web-offline/SOURCE_OFFER.txt").toString("utf8"); assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/u); assert.match(sourceOffer, /SHA256SUMS\.txt/u); const embeddedPackage = archiveFile(sourceArchive, "web/package.json"); const embeddedLock = archiveFile(sourceArchive, "web/package-lock.json"); assert.equal(sha256(embeddedPackage), fileSha256(packagePath)); assert.equal(sha256(embeddedLock), lockHash); const embeddedSbom = archiveFile(archive, "blender-web-offline/sbom.spdx.json"); assert.deepEqual(JSON.parse(embeddedSbom), sbom); const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); assert.match(commit, /^[a-f0-9]{40}$/u); const report = { schemaVersion: 1, task: "M13-05E", operation: "SUPPLY_CHAIN_BINDING", commit, inputs: { packageSha256: fileSha256(packagePath), lockfileSha256: lockHash, sbomSha256: fileSha256(sbomPath), noticesSha256: noticesHash }, archives: { binary: { path: path.relative(root, archive), sha256: fileSha256(archive), entries: binaryEntries.length }, source: { path: path.relative(root, sourceArchive), sha256: fileSha256(sourceArchive), entries: sourceEntries.length } }, sourceOffer: "BOUND_TO_CORRESPONDING_SOURCE_ARCHIVE_AND_SHA256SUMS", checks: { spdx23: true, lockfileBound: true, noticesBound: true, sourceOfferBound: true, archiveChecksumsBound: true, sourcePackageBound: true }, execution: "DISABLED", nextTask: "M13-05F", }; if (process.env.UPDATE_M13_05E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05E", parentTask: "M13-05D", nextTask: "M13-05F" }); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`supply-chain-binding-ok sbom=SPDX-2.3 lockfile=BOUND notices=BOUND sourceOffer=BOUND binarySha256=${report.archives.binary.sha256} sourceSha256=${report.archives.source.sha256} execution=DISABLED next=M13-05F\n`);