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

241 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { deflateRawSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const input = path.join(root, "dist");
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
const output = path.resolve(
root,
argument("--output", `release/svg-tools-${packageJson.version}.zip`),
);
const checksumOutput = `${output}.sha256`;
const force = process.argv.includes("--force");
if (path.extname(output).toLowerCase() !== ".zip") {
throw new Error("Release output must use a .zip extension");
}
if (output === path.parse(output).root || output === root) {
throw new Error("Release output is not a safe file target");
}
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && (await exists(output))) {
throw new Error(`Release already exists (use --force): ${output}`);
}
if (!force && (await exists(checksumOutput))) {
throw new Error(`Checksum already exists (use --force): ${checksumOutput}`);
}
const required = [
"index.html",
"toolbox-app.json",
"favicon.svg",
"canvas-frame-controller.js",
"README.md",
"CHANGELOG.md",
"LICENSE",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
"THIRD_PARTY_LICENSES.txt",
"LICENSES/README.md",
"LICENSES/npm-runtime-licenses.txt",
];
for (const name of required) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink()) {
throw new Error(`Release input is missing a regular file: ${name}`);
}
}
const assets = await lstat(path.join(input, "assets")).catch(() => null);
if (!assets?.isDirectory() || assets.isSymbolicLink()) {
throw new Error("Release input is missing its assets directory");
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
if (
manifest.id !== "de.add-ideas.svg-tools" ||
manifest.version !== packageJson.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg" ||
!manifest.assets?.includes("./canvas-frame-controller.js")
) {
throw new Error("Packaged Toolbox manifest identity/assets are invalid");
}
async function collect(directory, prefix = "") {
const files = [];
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
throw new Error(`Release input contains a symbolic link: ${relative}`);
}
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
else if (entry.isFile()) files.push({ absolute, relative });
else throw new Error(`Unsupported release entry: ${relative}`);
}
return files;
}
const files = await collect(input);
if (files.length > 65_535) throw new Error("Release contains too many files");
for (const { relative } of files) {
if (
relative.endsWith(".map") ||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
relative,
)
) {
throw new Error(`Forbidden release entry: ${relative}`);
}
if (relative.startsWith("/") || relative.split("/").includes("..")) {
throw new Error(`Unsafe release entry: ${relative}`);
}
}
const indexHtml = await readFile(path.join(input, "index.html"), "utf8");
if (/\b(?:src|href)=["']\//iu.test(indexHtml)) {
throw new Error("index.html contains a root-absolute asset reference");
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
function crc32(bytes) {
let value = 0xffffffff;
for (const byte of bytes)
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function header(length) {
return Buffer.alloc(length);
}
const localParts = [];
const centralParts = [];
let offset = 0;
for (const file of files) {
const source = await readFile(file.absolute);
const compressed = deflateRawSync(source, { level: 9 });
const name = Buffer.from(file.relative, "utf8");
const checksum = crc32(source);
if (
source.byteLength > 0xffffffff ||
compressed.byteLength > 0xffffffff ||
offset > 0xffffffff
) {
throw new Error("ZIP64 releases are not supported");
}
const local = header(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0x0021, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.byteLength, 18);
local.writeUInt32LE(source.byteLength, 22);
local.writeUInt16LE(name.byteLength, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, compressed);
const central = header(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0x0021, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.byteLength, 20);
central.writeUInt32LE(source.byteLength, 24);
central.writeUInt16LE(name.byteLength, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.byteLength + name.byteLength + compressed.byteLength;
}
const centralOffset = offset;
const centralSize = centralParts.reduce(
(sum, part) => sum + part.byteLength,
0,
);
if (centralOffset + centralSize > 0xffffffff) {
throw new Error("ZIP64 releases are not supported");
}
const end = header(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralSize, 12);
end.writeUInt32LE(centralOffset, 16);
end.writeUInt16LE(0, 20);
const archive = Buffer.concat([...localParts, ...centralParts, end]);
await mkdir(path.dirname(output), { recursive: true });
const staging = await mkdtemp(path.join(path.dirname(output), ".svg-release-"));
const stagedArchive = path.join(staging, path.basename(output));
const stagedChecksum = `${stagedArchive}.sha256`;
try {
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
const digest = createHash("sha256").update(archive).digest("hex");
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
flag: "wx",
mode: 0o644,
});
if (force) {
await rm(output, { force: true });
await rm(checksumOutput, { force: true });
}
await rename(stagedArchive, output);
await rename(stagedChecksum, checksumOutput);
process.stdout.write(
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${files.length} files)\nSHA-256 ${digest}\n`,
);
} finally {
await rm(staging, { recursive: true, force: true });
}