550 lines
17 KiB
JavaScript
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();
|
|
}
|