#!/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 }); }