import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { verifyChecksummedEnginePack, writeEngineChecksums, } from "./checksummed-engine-pack.mjs"; import { assertNoUnexpectedLocalPaths } from "./release-path-hygiene.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const DOTNET_LOCK = Object.freeze({ sdkVersion: "10.0.302", sdkCommit: "35b593bebf", runtimeVersion: "10.0.10", runtimeCommit: "f7d90799ce", engineVersion: ".NET 10.0.10 System.Text.RegularExpressions", sourceDateEpoch: 1_785_110_400, bridgeAbi: 1, legalFiles: Object.freeze({ "LICENSE-dotnet.txt": "cfc21f5e8bd655ae997eec916138b707b1d290b83272c02a95c9f821b8c87310", "THIRD-PARTY-NOTICES.txt": "2dc8f8c5a39401e928b5784ab564eb8b3ceb99ead3df8f260e0cab7e0bbecc7a", }), }); const SOURCE_FILES = Object.freeze([ "engines/dotnet/Program.cs", "engines/dotnet/README.md", "engines/dotnet/RegexTools.DotNet.csproj", "engines/dotnet/wwwroot/main.js", ]); function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } async function fileRecord(directory, name) { const value = await readFile(path.join(directory, name)); return { path: name, sha256: sha256(value), bytes: value.byteLength }; } async function regularFiles(directory, relative = "") { const entries = ( await readdir(path.join(directory, relative), { withFileTypes: true, }) ).sort((left, right) => compareText(left.name, right.name)); const files = []; for (const entry of entries) { const name = relative ? `${relative}/${entry.name}` : entry.name; const candidate = path.join(directory, name); const details = await lstat(candidate); if (details.isSymbolicLink()) { throw new Error(`.NET engine assets may not be symlinks: ${candidate}`); } if (entry.isDirectory()) { files.push(...(await regularFiles(directory, name))); } else if (entry.isFile()) { files.push(name); } else { throw new Error(`Unsupported .NET engine asset: ${candidate}`); } } return files; } async function normalizeFrameworkPaths( framework, { dotnetRoot, projectRoot, workspace }, ) { const replacements = [ [workspace, "dotnet-build"], [dotnetRoot, `dotnet-sdk-${DOTNET_LOCK.sdkVersion}`], [path.resolve(projectRoot), "regex-tools-source"], ].sort( ([left], [right]) => Buffer.byteLength(right) - Buffer.byteLength(left), ); const javascriptFiles = (await regularFiles(framework)).filter((name) => name.endsWith(".js"), ); for (const name of javascriptFiles) { const filename = path.join(framework, name); let source = await readFile(filename, "utf8"); for (const [localPath, stableName] of replacements) { source = source .replaceAll(localPath, stableName) .replaceAll(localPath.replaceAll("\\", "\\\\"), stableName); } await writeFile(filename, source, "utf8"); } const nativeModules = javascriptFiles.filter((name) => /^dotnet\.native\.[a-z0-9]+\.js$/u.test(name), ); if (nativeModules.length !== 1) { throw new Error( "The .NET publish must produce exactly one fingerprinted native JavaScript module.", ); } const originalName = nativeModules[0]; const nativeSource = await readFile(path.join(framework, originalName)); const stableName = `dotnet.native.${sha256(nativeSource).slice(0, 12)}.js`; if (stableName === originalName) return; for (const name of javascriptFiles) { if (name === originalName) continue; const filename = path.join(framework, name); const source = await readFile(filename, "utf8"); if (source.includes(originalName)) { await writeFile( filename, source.replaceAll(originalName, stableName), "utf8", ); } } await rename( path.join(framework, originalName), path.join(framework, stableName), ); } async function run(command, arguments_, options = {}) { const child = spawn(command, arguments_, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], }); const stdout = []; const stderr = []; child.stdout.on("data", (chunk) => stdout.push(chunk)); child.stderr.on("data", (chunk) => stderr.push(chunk)); const result = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => resolve({ code, signal })); }); const output = Buffer.concat(stdout).toString("utf8"); const errorOutput = Buffer.concat(stderr).toString("utf8"); if (result.code !== 0) { throw new Error( `${command} failed with ${ result.signal ? `signal ${result.signal}` : `exit code ${result.code}` }:\n${[output, errorOutput].filter(Boolean).join("\n").trim()}`, ); } return { stdout: output, stderr: errorOutput }; } async function exactDotNet(candidate) { const details = await lstat(candidate).catch(() => null); if (!details?.isFile() || details.isSymbolicLink()) { throw new Error(`dotnet must be a regular file: ${candidate}`); } const executable = await realpath(candidate); const version = (await run(executable, ["--version"])).stdout.trim(); const information = (await run(executable, ["--info"])).stdout; if ( version !== DOTNET_LOCK.sdkVersion || !information.includes(`Commit: ${DOTNET_LOCK.sdkCommit}`) || !information.includes(`Version: ${DOTNET_LOCK.runtimeVersion}`) || !information.includes(`Commit: ${DOTNET_LOCK.runtimeCommit}`) || !information.includes("[wasm-tools]") ) { throw new Error( `The .NET pack requires SDK ${DOTNET_LOCK.sdkVersion}, runtime ${DOTNET_LOCK.runtimeVersion}, and its wasm-tools workload.`, ); } const sdkRoot = path.dirname(executable); const license = path.join(sdkRoot, "LICENSE.txt"); const notices = path.join(sdkRoot, "ThirdPartyNotices.txt"); for (const file of [license, notices]) { const legalDetails = await lstat(file).catch(() => null); if (!legalDetails?.isFile() || legalDetails.isSymbolicLink()) { throw new Error(`Required .NET legal file is missing: ${file}`); } } for (const [name, file] of [ ["LICENSE-dotnet.txt", license], ["THIRD-PARTY-NOTICES.txt", notices], ]) { if (sha256(await readFile(file)) !== DOTNET_LOCK.legalFiles[name]) { throw new Error(`The pinned .NET SDK legal file drifted: ${name}.`); } } return { executable, license, notices }; } function buildEnvironment(dotnetRoot) { const environment = { ...process.env }; for (const variable of [ "CFLAGS", "CPPFLAGS", "CXXFLAGS", "DOTNET_ROOT", "MSBuildSDKsPath", ]) { delete environment[variable]; } environment.DOTNET_ROOT = dotnetRoot; environment.DOTNET_CLI_TELEMETRY_OPTOUT = "1"; environment.DOTNET_NOLOGO = "1"; environment.LANG = "C"; environment.LC_ALL = "C"; environment.SOURCE_DATE_EPOCH = String(DOTNET_LOCK.sourceDateEpoch); environment.TZ = "UTC"; return environment; } async function sourceRecords(projectRoot) { return Promise.all( SOURCE_FILES.map(async (name) => { const value = await readFile(path.join(projectRoot, name)); return { path: name, sha256: sha256(value), bytes: value.byteLength }; }), ); } async function writeMetadata(pack, projectRoot) { const assetNames = (await regularFiles(pack)) .filter((name) => name !== "SHA256SUMS" && name !== "engine-metadata.json") .sort(compareText); const metadata = { schemaVersion: 1, engine: "dotnet", engineName: "System.Text.RegularExpressions", engineVersion: DOTNET_LOCK.engineVersion, semanticIdentity: ".NET 10.0.10 System.Text.RegularExpressions; invariant culture; UTF-16 offsets", status: "production-native-runtime", bridge: { abiVersion: DOTNET_LOCK.bridgeAbi, nativeOffsetUnit: "UTF-16 code units", requestEncoding: "bounded JSON passed to fixed JSExport methods", userSourceEvaluation: false, replacement: "Fixed streaming parser for .NET replacement tokens ($$, $n, ${name}, $&, $`, $', $+, $_), verified against Match.Result at load", replacementOutputTransport: "bounded UTF-8 bytes encoded as canonical base64 in the JSON response", supportedApplicationFlags: ["g", "i", "m", "s", "n", "x", "r", "c", "b"], matchTimeoutMilliseconds: 9_000, limits: { maximumPatternUtf16: 65_536, maximumSubjectUtf16: 16_777_216, maximumReplacementUtf16: 65_536, maximumMatches: 10_000, maximumCaptureRows: 100_000, maximumCaptureGroups: 1_000, maximumOutputBytes: 67_108_864, }, sourceFiles: await sourceRecords(projectRoot), }, source: { project: ".NET", repository: "https://github.com/dotnet/runtime", license: "MIT", runtimeVersion: DOTNET_LOCK.runtimeVersion, runtimeCommit: DOTNET_LOCK.runtimeCommit, }, toolchain: { sdkVersion: DOTNET_LOCK.sdkVersion, sdkCommit: DOTNET_LOCK.sdkCommit, workload: "wasm-tools", target: "browser-wasm", command: "dotnet publish engines/dotnet/RegexTools.DotNet.csproj -c Release --no-restore -p:PathMap==/regex-tools-source -p:EmccExtraCFlags=-ffile-prefix-map==/dotnet-sdk-10.0.302", }, sourceDateEpoch: DOTNET_LOCK.sourceDateEpoch, files: await Promise.all(assetNames.map((name) => fileRecord(pack, name))), }; await writeFile( path.join(pack, "engine-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8", ); await writeEngineChecksums(pack); return metadata; } export async function verifyDotNetPack(pack, projectRoot = root) { const metadata = await verifyChecksummedEnginePack(pack, "dotnet"); const files = (await regularFiles(pack)).sort(compareText); const framework = files.filter((name) => name.startsWith("_framework/")); const requiredPatterns = [ /^_framework\/RegexTools\.DotNet\.[a-z0-9]+\.wasm$/u, /^_framework\/System\.Text\.RegularExpressions\.[a-z0-9]+\.wasm$/u, /^_framework\/dotnet\.native\.[a-z0-9]+\.wasm$/u, /^_framework\/dotnet\.native\.[a-z0-9]+\.js$/u, /^_framework\/dotnet\.runtime\.[a-z0-9]+\.js$/u, ]; if ( metadata.engineVersion !== DOTNET_LOCK.engineVersion || metadata.source?.runtimeVersion !== DOTNET_LOCK.runtimeVersion || metadata.source?.runtimeCommit !== DOTNET_LOCK.runtimeCommit || metadata.toolchain?.sdkVersion !== DOTNET_LOCK.sdkVersion || metadata.toolchain?.sdkCommit !== DOTNET_LOCK.sdkCommit || metadata.bridge?.abiVersion !== DOTNET_LOCK.bridgeAbi || metadata.bridge?.userSourceEvaluation !== false || !String(metadata.bridge?.replacement).includes("Match.Result") || !String(metadata.bridge?.replacementOutputTransport).includes("base64") || JSON.stringify(metadata.bridge?.sourceFiles) !== JSON.stringify(await sourceRecords(projectRoot)) || Object.entries(DOTNET_LOCK.legalFiles).some( ([name, digest]) => metadata.files?.find((file) => file.path === name)?.sha256 !== digest, ) || !files.includes("_framework/dotnet.js") || !files.includes("LICENSE-dotnet.txt") || !files.includes("THIRD-PARTY-NOTICES.txt") || files.some((name) => /\.(?:br|gz|map|pdb)$/u.test(name)) || requiredPatterns.some( (pattern) => framework.filter((name) => pattern.test(name)).length !== 1, ) ) { throw new Error( ".NET engine pack drifted from its pinned runtime contract.", ); } const mainModule = await readFile( path.join(pack, "_framework/dotnet.js"), "utf8", ); if (!mainModule.includes("dotnet") || !mainModule.includes("./")) { throw new Error(".NET engine entry module has an unexpected shape."); } for (const name of framework) { assertNoUnexpectedLocalPaths( await readFile(path.join(pack, name)), `.NET engine asset ${name}`, { allowedVirtualHomes: /^_framework\/dotnet\.native\.[a-z0-9]+\.js$/u.test(name) ? ["/home/web_user"] : [], }, ); } return metadata; } export async function buildDotNetPack( dotnet, output = path.join(root, ".engine-build", "dotnet"), projectRoot = root, ) { const toolchain = await exactDotNet(dotnet); const outputParent = path.dirname(output); await mkdir(outputParent, { recursive: true }); const workspace = await mkdtemp(path.join(outputParent, ".dotnet-build-")); const staging = path.join(workspace, "pack"); const publish = path.join(workspace, "publish"); await mkdir(staging); try { const dotnetRoot = path.dirname(toolchain.executable); const projectPath = path.join( projectRoot, "engines", "dotnet", "RegexTools.DotNet.csproj", ); await run( toolchain.executable, [ "publish", projectPath, "-c", "Release", "--no-restore", "--output", publish, `-p:PathMap=${path.resolve(projectRoot)}=/regex-tools-source`, `-p:EmccExtraCFlags=-ffile-prefix-map=${dotnetRoot}=/dotnet-sdk-${DOTNET_LOCK.sdkVersion}`, ], { cwd: projectRoot, env: buildEnvironment(dotnetRoot), }, ); const publishedFramework = path.join(publish, "wwwroot", "_framework"); const frameworkDetails = await lstat(publishedFramework).catch(() => null); if (!frameworkDetails?.isDirectory() || frameworkDetails.isSymbolicLink()) { throw new Error( "The .NET publish did not produce a WebAssembly framework.", ); } await cp(publishedFramework, path.join(staging, "_framework"), { recursive: true, filter: (source) => !/\.(?:br|gz)$/u.test(source), }); for (const name of await regularFiles(path.join(staging, "_framework"))) { if (/\.(?:map|pdb)$/u.test(name)) { await rm(path.join(staging, "_framework", name), { force: true }); } } await normalizeFrameworkPaths(path.join(staging, "_framework"), { dotnetRoot: path.dirname(toolchain.executable), projectRoot, workspace, }); await copyFile(toolchain.license, path.join(staging, "LICENSE-dotnet.txt")); await copyFile( toolchain.notices, path.join(staging, "THIRD-PARTY-NOTICES.txt"), ); await writeMetadata(staging, projectRoot); await verifyDotNetPack(staging, projectRoot); await rm(output, { recursive: true, force: true }); await rename(staging, output); await rm(workspace, { recursive: true, force: true }); return { output, metadata: await verifyDotNetPack(output, projectRoot) }; } catch (error) { await rm(workspace, { recursive: true, force: true }); throw error; } } export async function installDotNetPack(source, projectRoot = root) { await verifyDotNetPack(source, projectRoot); const destination = path.join(projectRoot, "public", "engines", "dotnet"); const parent = path.dirname(destination); const staging = await mkdtemp(path.join(parent, ".dotnet-install-")); try { await cp(source, staging, { recursive: true }); await verifyDotNetPack(staging, projectRoot); await rm(destination, { recursive: true, force: true }); await rename(staging, destination); } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; } return { output: destination, metadata: await verifyDotNetPack(destination, projectRoot), }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { const arguments_ = process.argv.slice(2); if (arguments_.length !== 2 || arguments_[0] !== "--dotnet") { throw new Error( "Usage: node scripts/dotnet-engine-pack.mjs --dotnet /absolute/path/to/dotnet", ); } const result = await buildDotNetPack(arguments_[1]); console.log(`Built verified .NET engine pack at ${result.output}.`); }