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, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const releaseRoot = path.join(repoRoot, "release"); const sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz")); const binaryArchive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz")); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-source-archive-")); const sourceRoot = path.join(workspace, "source"); const binaryRoot = path.join(workspace, "binary"); const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); function archiveEntries(archive) { const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split("\n").filter(Boolean); assert.ok(entries.length > 0, `${path.basename(archive)} is empty`); for (const entry of entries) { assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe archive path: ${entry}`); } return entries; } function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, maxBuffer: 32 * 1024 * 1024, }); const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); assert.equal(result.status, 0, `${command} ${args.join(" ")} failed\n${output.slice(-8000)}`); return output; } function firstPartyTextFiles(root) { const files = []; const visit = (directory) => { if (!fs.existsSync(directory)) return; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const absolute = path.join(directory, entry.name); const relative = path.relative(root, absolute).replaceAll(path.sep, "/"); if (relative.startsWith("web/app/public/vendor/") || relative.startsWith("web/app/src/vendor/")) continue; if (entry.isDirectory()) visit(absolute); else if (entry.isFile() && fs.statSync(absolute).size <= 2 * 1024 * 1024 && /\.(?:c|cc|cpp|h|hh|hpp|js|mjs|json|md|sh|ts|tsx|txt|yml|yaml)$/.test(entry.name)) files.push(absolute); } }; for (const relative of ["web", "tools/web", "docs/web"]) visit(path.join(root, relative)); for (const relative of ["docs/PROJECT_STATUS_AND_NEXT_WORK.md", "WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md"]) { const file = path.join(root, relative); if (fs.existsSync(file)) files.push(file); } return files; } function distManifest(root) { const files = []; const visit = (directory, base) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { const absolute = path.join(directory, entry.name); if (entry.isDirectory()) visit(absolute, base); else files.push({ path: path.relative(base, absolute).replaceAll(path.sep, "/"), bytes: fs.statSync(absolute).size, sha256: sha256(absolute) }); } }; visit(root, root); return files; } try { assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`); assert.ok(fs.statSync(binaryArchive).isFile(), `binary archive is missing: ${binaryArchive}`); const entries = archiveEntries(sourceArchive); const forbidden = entries.filter((entry) => /(^|\/)(?:node_modules|dist|test-results|playwright-report|__pycache__)(?:\/|$)/.test(entry) || /\.pyc$/.test(entry) || /^(?:release|build_[^/]*|\.emcache)(?:\/|$)/.test(entry), ); assert.deepEqual(forbidden, [], `source archive contains generated output: ${forbidden.join(", ")}`); fs.mkdirSync(sourceRoot); fs.mkdirSync(binaryRoot); execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", sourceArchive, "-C", sourceRoot]); execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", binaryArchive, "-C", binaryRoot]); assert.ok(!fs.realpathSync(sourceRoot).startsWith(`${repoRoot}${path.sep}`), "source archive was not extracted independently"); const required = [ "SOURCE_MANIFEST.json", "blender-5.2.0/COPYING", "blender-5.2.0/CMakeLists.txt", "blender-5.2.0/source/blender/web_engine/CMakeLists.txt", "web/package.json", "web/package-lock.json", "web/app/vite.config.ts", "web/app/src/main.tsx", "web/protocol/manifest.ts", "tools/web/create-offline-release.mjs", ]; for (const relative of required) assert.ok(fs.lstatSync(path.join(sourceRoot, relative)), `source archive omits ${relative}`); const sourceOffer = fs.readFileSync(path.join(binaryRoot, "blender-web-offline/SOURCE_OFFER.txt"), "utf8"); assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/); assert.match(sourceOffer, /Blender 5\.2/); const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); assert.equal(manifest.schemaVersion, 1); assert.equal(manifest.product, "Web Blender Modeler V1"); assert.ok(Array.isArray(manifest.files) && manifest.files.length > 10_000, "source manifest is incomplete"); const actualPaths = entries.filter((entry) => !entry.endsWith("/") && entry !== "SOURCE_MANIFEST.json").sort(); assert.deepEqual(manifest.files.map((entry) => entry.path).sort(), actualPaths, "source manifest path list drifted"); for (const entry of manifest.files) { const file = path.join(sourceRoot, entry.path); if (entry.type === "symlink") { assert.equal(fs.readlinkSync(file), entry.target, `source symlink target drifted: ${entry.path}`); } else { assert.equal(fs.statSync(file).size, entry.bytes, `source byte length drifted: ${entry.path}`); assert.equal(sha256(file), entry.sha256, `source SHA-256 drifted: ${entry.path}`); } } const absoluteDevelopmentPath = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\)/; const credential = /(?:-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})/; for (const file of firstPartyTextFiles(sourceRoot)) { const text = fs.readFileSync(file, "utf8"); assert.doesNotMatch(text, absoluteDevelopmentPath, `source archive contains an absolute development path: ${path.relative(sourceRoot, file)}`); assert.doesNotMatch(text, credential, `source archive contains a credential signature: ${path.relative(sourceRoot, file)}`); } const installOutput = run("npm", ["ci", "--ignore-scripts"], path.join(sourceRoot, "web")); const typecheckOutput = run("npm", ["run", "typecheck"], path.join(sourceRoot, "web")); const buildOutput = run("npm", ["run", "build"], path.join(sourceRoot, "web")); const rebuilt = distManifest(path.join(sourceRoot, "web/dist")); const packaged = distManifest(path.join(binaryRoot, "blender-web-offline/app")); assert.deepEqual(rebuilt, packaged, "source archive build does not reproduce the binary app manifest"); const report = { schemaVersion: 1, task: "M6-12", status: "READY", archive: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) }, binaryArchiveSha256: sha256(binaryArchive), extractedRootOutsideWorkspace: true, filesVerified: manifest.files.length, buildFilesVerified: rebuilt.length, commands: [ { command: "npm ci --ignore-scripts", outputSha256: crypto.createHash("sha256").update(installOutput).digest("hex") }, { command: "npm run typecheck", outputSha256: crypto.createHash("sha256").update(typecheckOutput).digest("hex") }, { command: "npm run build", outputSha256: crypto.createHash("sha256").update(buildOutput).digest("hex") }, ], keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(sourceRoot, relative))])), rebuiltManifestSha256: crypto.createHash("sha256").update(JSON.stringify(rebuilt)).digest("hex"), }; const reportRoot = path.join(releaseRoot, "archive-reports"); fs.mkdirSync(reportRoot, { recursive: true }); fs.writeFileSync(path.join(reportRoot, "source.json"), `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`source-archive-ok files=${report.filesVerified} buildFiles=${report.buildFilesVerified} sha256=${report.archive.sha256}\n`); } finally { fs.rmSync(workspace, { recursive: true, force: true }); }