Files
regex-tools/scripts/package-release.mjs

550 lines
17 KiB
JavaScript

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";
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.2.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}`);
}
if (!/\.(?:css|html|js|json|md|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}`);
}
if (
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/mu.test(
text,
)
) {
throw new Error(`Local absolute path 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: <https://git.add-ideas.de/zemion/regex-tools>",
)
) {
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/pcre2/LICENSE.txt",
"engines/pcre2/SHA256SUMS",
"engines/pcre2/engine-metadata.json",
"engines/pcre2/pcre2.mjs",
"engines/pcre2/pcre2.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.");
}
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();
}