241 lines
7.5 KiB
JavaScript
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 });
|
|
}
|