import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises"; import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; import { zipSync } from "fflate"; import { checksumLine, sha256 } from "./checksum-release.mjs"; import { assertNoUnexpectedLocalPaths } from "./release-path-hygiene.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); const gplVersion3TextSha256 = "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0"; const expectedApplicationVersion = "0.4.0"; const requiredRootFiles = [ "LICENSE", "README.md", "CHANGELOG.md", "SOURCE.md", "THIRD_PARTY_NOTICES.md", ]; function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } export function safeArchivePath(fileName) { if ( typeof fileName !== "string" || fileName.length === 0 || fileName.includes("\0") || fileName.includes("\\") || fileName.startsWith("/") || /^[A-Za-z]:\//u.test(fileName) ) { throw new Error(`Invalid release path: ${String(fileName)}`); } const parts = fileName.split("/"); if ( parts.some( (component) => component === "" || component === "." || component === "..", ) || path.posix.normalize(fileName) !== fileName ) { throw new Error(`Non-canonical release path: ${fileName}`); } return fileName; } async function collectDirectory(directory, prefix = "") { const result = []; const visit = async (current) => { const children = await readdir(current, { withFileTypes: true }); children.sort((left, right) => compareText(left.name, right.name)); for (const child of children) { const absolute = path.join(current, child.name); const details = await lstat(absolute); if (details.isSymbolicLink()) { throw new Error(`Release input contains a symlink: ${absolute}`); } if (details.isDirectory()) { await visit(absolute); } else if (details.isFile()) { const relative = path .relative(directory, absolute) .split(path.sep) .join("/"); result.push({ name: safeArchivePath( prefix ? path.posix.join(prefix, relative) : relative, ), data: await readFile(absolute), }); } else { throw new Error(`Release input contains a special file: ${absolute}`); } } }; await visit(directory); return result; } function safePackageDirectory(name, version) { const normalized = `${name.replace(/^@/u, "").replaceAll("/", "__")}-${ version }`; if (!/^[A-Za-z0-9._@+-]+$/u.test(normalized)) { throw new Error(`Unsafe package identity: ${name}@${version}`); } return normalized; } async function collectRuntimePackages(packageJson) { const packages = new Map(); const visit = async (name, fromDirectory) => { const resolver = createRequire(path.join(fromDirectory, "package.json")); let packagePath; try { packagePath = resolver.resolve(`${name}/package.json`); } catch { const entry = resolver.resolve(name); let directory = path.dirname(entry); while (directory !== path.dirname(directory)) { const candidate = path.join(directory, "package.json"); const details = await lstat(candidate).catch(() => null); if (details?.isFile()) { const candidateJson = JSON.parse(await readFile(candidate, "utf8")); if (candidateJson.name === name) { packagePath = candidate; break; } } directory = path.dirname(directory); } } if (!packagePath) { throw new Error(`Could not resolve runtime package ${name}.`); } const details = JSON.parse(await readFile(packagePath, "utf8")); const key = `${details.name}@${details.version}`; if (packages.has(key)) return; const directory = path.dirname(packagePath); packages.set(key, { directory, details }); for (const dependency of Object.keys(details.dependencies ?? {}).sort()) { await visit(dependency, directory); } }; for (const dependency of Object.keys(packageJson.dependencies ?? {}).sort()) { await visit(dependency, root); } return [...packages.values()].sort((left, right) => compareText( `${left.details.name}@${left.details.version}`, `${right.details.name}@${right.details.version}`, ), ); } async function collectRuntimeLicenceEntries(packageJson) { const packages = await collectRuntimePackages(packageJson); const entries = []; const rows = [ "# Runtime npm dependency licences", "", "| Package | Version | Licence | Repository |", "| --- | --- | --- | --- |", ]; for (const runtimePackage of packages) { const { details, directory } = runtimePackage; const legalFiles = (await readdir(directory, { withFileTypes: true })) .filter( (entry) => entry.isFile() && /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(entry.name), ) .sort((left, right) => compareText(left.name, right.name)); if (legalFiles.length === 0) { throw new Error( `Runtime package ${details.name}@${details.version} has no licence file.`, ); } const packageDirectory = safePackageDirectory( details.name, details.version, ); for (const legalFile of legalFiles) { entries.push({ name: safeArchivePath( `LICENSES/npm/${packageDirectory}/${legalFile.name}`, ), data: await readFile(path.join(directory, legalFile.name)), }); } const repository = typeof details.repository === "string" ? details.repository : (details.repository?.url ?? ""); rows.push( `| \`${details.name}\` | ${details.version} | ${ details.license ?? "See packaged licence" } | ${String(repository).replaceAll("|", "\\|")} |`, ); } entries.push({ name: "LICENSES/npm/DEPENDENCIES.md", data: Buffer.from(`${rows.join("\n")}\n`), }); return entries; } async function collectBuildGeneratedLicenceEntries() { const resolver = createRequire(path.join(root, "package.json")); const dependencies = [ { name: "rolldown", version: "1.1.5", license: "MIT", legalFile: "LICENSE", }, { name: "vite", version: "8.1.5", license: "MIT", legalFile: "LICENSE.md", }, ]; const entries = []; for (const dependency of dependencies) { const packagePath = resolver.resolve(`${dependency.name}/package.json`); const details = JSON.parse(await readFile(packagePath, "utf8")); if ( details.name !== dependency.name || details.version !== dependency.version || details.license !== dependency.license ) { throw new Error( `Build dependency identity drifted: expected ${dependency.name}@${dependency.version} (${dependency.license}).`, ); } const legalPath = path.join( path.dirname(packagePath), dependency.legalFile, ); const legalDetails = await lstat(legalPath); if (legalDetails.isSymbolicLink() || !legalDetails.isFile()) { throw new Error( `Build dependency legal file is not a regular file: ${legalPath}.`, ); } entries.push({ name: safeArchivePath( `LICENSES/build/${safePackageDirectory( details.name, details.version, )}/${dependency.legalFile}`, ), data: await readFile(legalPath), }); } return entries; } function scanEntry(entry) { if (entry.name.endsWith(".map")) { throw new Error(`Source map is not approved for release: ${entry.name}`); } const components = entry.name.toLowerCase().split("/"); if ( components.some((component) => [ ".env", ".npmrc", ".git", "credentials", "id_rsa", "id_ed25519", ].includes(component), ) || /\.(?:key|pem|p12|pfx)$/iu.test(entry.name) ) { throw new Error(`Credential-like release path: ${entry.name}`); } const knownVirtualHomes = /^(?:assets\/php\.worker-[A-Za-z0-9_-]+\.js|engines\/cpp\/cpp-regex\.mjs|engines\/dotnet\/_framework\/dotnet\.native\.[a-z0-9]+\.js|engines\/perl\/emperl\.js|engines\/php\/engine-metadata\.json|engines\/php\/php-loader\.mjs|engines\/python\/pyodide(?:\.asm)?\.mjs)$/u.test( entry.name, ) ? ["/home/web_user", "/home/pyodide"] : []; assertNoUnexpectedLocalPaths(entry.data, entry.name, { allowedVirtualHomes: knownVirtualHomes, }); if (!/\.(?:css|html|js|json|md|mjs|pl|rst|svg|txt)$/iu.test(entry.name)) { return; } const text = entry.data.toString("utf8"); if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(text)) { throw new Error(`Private key material detected in ${entry.name}`); } if (/https?:\/\/[^/@\s]+:[^/@\s]+@/iu.test(text)) { throw new Error(`Credential-bearing URL detected in ${entry.name}`); } } export function createDeterministicZip(inputEntries) { const entries = inputEntries .map((entry) => ({ name: safeArchivePath(entry.name), data: Buffer.from(entry.data), })) .sort((left, right) => compareText(left.name, right.name)); const seen = new Set(); const zippable = Object.create(null); let totalBytes = 0; for (const entry of entries) { if (seen.has(entry.name)) { throw new Error(`Duplicate release path: ${entry.name}`); } seen.add(entry.name); totalBytes += entry.data.byteLength; if (entry.data.byteLength > 128 * 1024 * 1024) { throw new Error(`Release entry is too large: ${entry.name}`); } if (totalBytes > 512 * 1024 * 1024) { throw new Error("Release exceeds the 512 MiB uncompressed limit."); } scanEntry(entry); zippable[entry.name] = [ new Uint8Array(entry.data), { attrs: (0o100644 << 16) >>> 0, level: 9, mtime: zipEpoch, os: 3, }, ]; } return Buffer.from( zipSync(zippable, { attrs: (0o100644 << 16) >>> 0, level: 9, mtime: zipEpoch, os: 3, }), ); } async function collectReleaseEntries() { const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); if ( packageJson.version !== expectedApplicationVersion || packageJson.license !== "GPL-3.0-or-later" || packageJson.repository?.url !== "git+https://git.add-ideas.de/zemion/regex-tools.git" ) { throw new Error("Package version, licence or source identity drifted."); } const sourceIdentity = await readFile(path.join(root, "SOURCE.md"), "utf8"); if ( !sourceIdentity.includes( `- Release version: \`${expectedApplicationVersion}\``, ) || !sourceIdentity.includes( `- Release tag: \`v${expectedApplicationVersion}\``, ) || !sourceIdentity.includes( "- Repository: ", ) ) { throw new Error("SOURCE.md release identity drifted."); } const licence = await readFile(path.join(root, "LICENSE"), "utf8"); if ( !licence.includes("GNU GENERAL PUBLIC LICENSE") || !licence.includes("Version 3, 29 June 2007") || !licence.includes("END OF TERMS AND CONDITIONS") || sha256(Buffer.from(licence)) !== gplVersion3TextSha256 ) { throw new Error( "Root LICENSE is not the exact complete GPL version 3 text.", ); } const entries = await collectDirectory(path.join(root, "dist")); entries.push( ...(await collectDirectory(path.join(root, "LICENSES"), "LICENSES")), ); entries.push(...(await collectRuntimeLicenceEntries(packageJson))); entries.push(...(await collectBuildGeneratedLicenceEntries())); for (const fileName of requiredRootFiles) { entries.push({ name: fileName, data: await readFile(path.join(root, fileName)), }); } const names = new Set(entries.map((entry) => entry.name)); for (const required of [ "index.html", "toolbox-app.json", "favicon.svg", "LICENSE", "CHANGELOG.md", "SOURCE.md", "THIRD_PARTY_NOTICES.md", "engines/README.md", "engines/java/LICENSE.txt", "engines/java/NOTICE.txt", "engines/java/SHA256SUMS", "engines/java/engine-metadata.json", "engines/java/java-regex.mjs", "engines/cpp/LICENSE-Emscripten.txt", "engines/cpp/LICENSE-compiler-rt.txt", "engines/cpp/LICENSE-libcxx.txt", "engines/cpp/LICENSE-libcxxabi.txt", "engines/cpp/LICENSE-libunwind.txt", "engines/cpp/LICENSE-musl.txt", "engines/cpp/SHA256SUMS", "engines/cpp/SOURCE-MANIFEST.json", "engines/cpp/cpp-regex.mjs", "engines/cpp/cpp-regex.wasm", "engines/cpp/engine-metadata.json", "engines/dotnet/LICENSE-dotnet.txt", "engines/dotnet/SHA256SUMS", "engines/dotnet/THIRD-PARTY-NOTICES.txt", "engines/dotnet/_framework/dotnet.js", "engines/dotnet/engine-metadata.json", "engines/go/LICENSE-Go.txt", "engines/go/SHA256SUMS", "engines/go/engine-metadata.json", "engines/go/go-regex.wasm", "engines/go/wasm_exec.mjs", "engines/perl/LICENSE-WebPerl-Artistic.txt", "engines/perl/LICENSE-WebPerl-GPL.txt", "engines/perl/LICENSE-Devel-StackTrace-Artistic-2.0.txt", "engines/perl/LICENSE-Emscripten.txt", "engines/perl/NOTICE.txt", "engines/perl/SHA256SUMS", "engines/perl/emperl.data", "engines/perl/emperl.js", "engines/perl/emperl.wasm", "engines/perl/engine-metadata.json", "engines/perl/perl-runtime.worker.js", "engines/perl/regex_tools_bridge.pl", "engines/php/LICENSE.Emscripten-4.0.19.txt", "engines/php/LICENSE.Oniguruma-6.9.10.txt", "engines/php/LICENSE.OpenSSL-1.1.1t.txt", "engines/php/LICENSE.PCRE2-10.44.txt", "engines/php/LICENSE.PHP-4.0.txt", "engines/php/LICENSE.PHP-CLI-http-parser-MIT.txt", "engines/php/LICENSE.PHP-Lexbor-Apache-2.0.txt", "engines/php/LICENSE.PHP-Zend-2.0.txt", "engines/php/LICENSE.PHP-bcmath-LGPL-2.1.txt", "engines/php/LICENSE.PHP-libavifinfo-BSD-2-Clause.txt", "engines/php/LICENSE.PHP-libmagic-BSD.txt", "engines/php/LICENSE.PHP-libmbfl-LGPL-2.1.txt", "engines/php/LICENSE.PHP-timelib-MIT.txt", "engines/php/LICENSE.PHP-uriparser-BSD-3-Clause.txt", "engines/php/LICENSE.curl-7.69.1.txt", "engines/php/LICENSE.ini-ISC.txt", "engines/php/LICENSE.libaom-3.12.1.txt", "engines/php/LICENSE.libavif-1.3.0.txt", "engines/php/LICENSE.libcxx.txt", "engines/php/LICENSE.libcxxabi.txt", "engines/php/LICENSE.libgd-2.3.3.txt", "engines/php/LICENSE.libiconv-1.17-LGPL-2.1.txt", "engines/php/LICENSE.libjpeg-turbo-3.0.3.txt", "engines/php/LICENSE.libpng-1.6.39.txt", "engines/php/LICENSE.libwebp.txt", "engines/php/LICENSE.libxml2-2.9.10.txt", "engines/php/LICENSE.libyuv.txt", "engines/php/LICENSE.libzip-1.9.2.txt", "engines/php/LICENSE.llvm-compiler-rt.txt", "engines/php/LICENSE.musl.txt", "engines/php/LICENSE.php-wasm-GPL-2.0-or-later.txt", "engines/php/LICENSE.wasm-feature-detect-Apache-2.0.txt", "engines/php/LICENSE.zlib-1.2.13.txt", "engines/php/NOTICE.IANA-TZDATA-2026.1.txt", "engines/php/NOTICE.PHP-Lexbor.txt", "engines/php/NOTICE.PHP-REDIST-BINS.txt", "engines/php/NOTICE.PHP-public-domain-hash-code.txt", "engines/php/NOTICE.SQLite-3.51.0.txt", "engines/php/NOTICE.dlmalloc-2.8.6.txt", "engines/php/PATENTS.PHP-libavifinfo.txt", "engines/php/PATENTS.libaom-3.12.1.txt", "engines/php/PATENTS.libwebp.txt", "engines/php/SHA256SUMS", "engines/php/engine-metadata.json", "engines/php/native-components.json", "engines/php/php-loader.mjs", "engines/php/php.wasm", "engines/pcre2/LICENSE.txt", "engines/pcre2/SHA256SUMS", "engines/pcre2/engine-metadata.json", "engines/pcre2/pcre2.mjs", "engines/pcre2/pcre2.wasm", "engines/python/LICENSE.cpython.txt", "engines/python/LICENSE.bzip2.txt", "engines/python/LICENSE.emscripten-mini-lz4.txt", "engines/python/LICENSE.emscripten.txt", "engines/python/LICENSE.expat.txt", "engines/python/LICENSE.hacl-mit.txt", "engines/python/LICENSE.hiwire.txt", "engines/python/LICENSE.libffi.txt", "engines/python/LICENSE.libmpdec.txt", "engines/python/LICENSE.llvm-compiler-rt.txt", "engines/python/LICENSE.llvm-libcxx.txt", "engines/python/LICENSE.llvm-libcxxabi.txt", "engines/python/LICENSE.llvm-libunwind.txt", "engines/python/LICENSE.pyodide.txt", "engines/python/LICENSE.pyodide-stacktrace-vendors.txt", "engines/python/LICENSE.sqlite.txt", "engines/python/LICENSE.xz.txt", "engines/python/LICENSE.zlib.txt", "engines/python/LICENSE.zstd.txt", "engines/python/NOTICE.cpython-bundled.rst", "engines/python/NOTICE.emscripten-dlmalloc.txt", "engines/python/NOTICE.emscripten-musl.txt", "engines/python/SHA256SUMS", "engines/python/SOURCE-MANIFEST.json", "engines/python/engine-metadata.json", "engines/python/pyodide-lock.json", "engines/python/pyodide.asm.mjs", "engines/python/pyodide.asm.wasm", "engines/python/pyodide.mjs", "engines/python/python_stdlib.zip", "engines/ruby/LICENSE.browser-wasi-shim-Apache-2.0.txt", "engines/ruby/LICENSE.browser-wasi-shim-MIT.txt", "engines/ruby/LICENSE.ruby-wasm.txt", "engines/ruby/LICENSE.tslib.txt", "engines/ruby/NOTICE.txt", "engines/ruby/SHA256SUMS", "engines/ruby/engine-metadata.json", "engines/ruby/ruby.wasm", "engines/rust/LICENSE-Apache-2.0.txt", "engines/rust/LICENSE-MIT.txt", "engines/rust/LICENSE-Unicode-3.0.txt", "engines/rust/RUST-STDLIB-COPYRIGHT.html", "engines/rust/SHA256SUMS", "engines/rust/THIRD_PARTY_LICENSES.txt", "engines/rust/engine-metadata.json", "engines/rust/rust-regex.mjs", "engines/rust/rust-regex_bg.wasm", "LICENSES/Emscripten-MIT.txt", "LICENSES/PCRE2.txt", "LICENSES/build/rolldown-1.1.5/LICENSE", "LICENSES/build/vite-8.1.5/LICENSE.md", ]) { if (!names.has(required)) { throw new Error(`Required release file is missing: ${required}`); } } if (![...names].some((name) => name.startsWith("assets/"))) { throw new Error("Release has no production assets."); } for (const requiredDotNetPattern of [ /^engines\/dotnet\/_framework\/RegexTools\.DotNet\..+\.wasm$/u, /^engines\/dotnet\/_framework\/System\.Text\.RegularExpressions\..+\.wasm$/u, /^engines\/dotnet\/_framework\/dotnet\.native\..+\.wasm$/u, /^engines\/dotnet\/_framework\/dotnet\.runtime\..+\.js$/u, ]) { if (![...names].some((name) => requiredDotNetPattern.test(name))) { throw new Error( `Required .NET runtime asset is missing: ${requiredDotNetPattern}`, ); } } if (![...names].some((name) => name.startsWith("LICENSES/"))) { throw new Error("Release has no third-party licence directory."); } const manifest = JSON.parse( entries .find((entry) => entry.name === "toolbox-app.json") .data.toString("utf8"), ); if ( manifest.id !== "de.add-ideas.regex-tools" || manifest.version !== packageJson.version || manifest.source?.repository !== "https://git.add-ideas.de/zemion/regex-tools" || manifest.source?.license !== "GPL-3.0-or-later" ) { throw new Error("Packaged manifest identity is inconsistent."); } const indexHtml = entries .find((entry) => entry.name === "index.html") .data.toString("utf8"); if (/(?:src|href)\s*=\s*["']\/(?!\/)/iu.test(indexHtml)) { throw new Error("index.html contains a root-absolute asset path."); } return { entries, packageJson }; } export function parseReleaseArguments(argumentsList) { const allowed = new Set(["--force"]); const unknown = argumentsList.filter((argument) => !allowed.has(argument)); if (unknown.length > 0) { throw new Error(`Unknown release argument: ${unknown.join(", ")}`); } return { force: argumentsList.includes("--force") }; } async function assertWritableTarget(target, force) { try { const details = await lstat(target); if (details.isSymbolicLink() || !details.isFile()) { throw new Error(`Release target is not a regular file: ${target}`); } if (!force) { throw new Error( `${path.relative(root, target)} exists; pass --force to replace this exact version.`, ); } return true; } catch (error) { if (error && typeof error === "object" && error.code === "ENOENT") { return false; } throw error; } } async function publishReleasePair({ archive, archivePath, checksum, checksumPath, archiveExists, checksumExists, }) { const releaseDirectory = path.dirname(archivePath); const stage = await mkdtemp( path.join(releaseDirectory, ".regex-tools-release-"), ); const stagedArchive = path.join(stage, path.basename(archivePath)); const stagedChecksum = path.join(stage, path.basename(checksumPath)); const previousArchive = path.join(stage, "previous-archive"); const previousChecksum = path.join(stage, "previous-checksum"); let archiveBackedUp = false; let checksumBackedUp = false; let archivePublished = false; let checksumPublished = false; try { await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 }); await writeFile(stagedChecksum, checksum, { encoding: "utf8", flag: "wx", mode: 0o644, }); if (archiveExists) { await rename(archivePath, previousArchive); archiveBackedUp = true; } if (checksumExists) { await rename(checksumPath, previousChecksum); checksumBackedUp = true; } await rename(stagedArchive, archivePath); archivePublished = true; await rename(stagedChecksum, checksumPath); checksumPublished = true; } catch (error) { if (checksumPublished) await rm(checksumPath, { force: true }); if (archivePublished) await rm(archivePath, { force: true }); if (checksumBackedUp) await rename(previousChecksum, checksumPath); if (archiveBackedUp) await rename(previousArchive, archivePath); throw error; } finally { await rm(stage, { recursive: true, force: true }); } } async function main() { const options = parseReleaseArguments(process.argv.slice(2)); const { entries, packageJson } = await collectReleaseEntries(); const archive = createDeterministicZip(entries); const fileName = `regex-tools-${packageJson.version}.zip`; const digest = sha256(archive); const releaseDirectory = path.join(root, "release"); await mkdir(releaseDirectory, { recursive: true }); const releaseDirectoryDetails = await lstat(releaseDirectory); if ( releaseDirectoryDetails.isSymbolicLink() || !releaseDirectoryDetails.isDirectory() ) { throw new Error("release/ must be a real directory."); } const archivePath = path.join(releaseDirectory, fileName); const checksumPath = `${archivePath}.sha256`; const archiveExists = await assertWritableTarget(archivePath, options.force); const checksumExists = await assertWritableTarget( checksumPath, options.force, ); await publishReleasePair({ archive, archivePath, checksum: checksumLine(digest, fileName), checksumPath, archiveExists, checksumExists, }); console.log( `Created release/${fileName} (${archive.byteLength} bytes, SHA-256 ${digest})`, ); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { await main(); }