#!/usr/bin/env node import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { createReadStream } from "node:fs"; import { constants, copyFile, lstat, mkdir, mkdtemp, readdir, rm, stat, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; type Options = { source: string; output: string; replace: boolean; temporaryDirectory: string; }; type TreeEntry = { type: "directory" | "file"; mtime100ns: bigint; size?: bigint; sha256?: string; }; const execFileAsync = promisify(execFile); const supportedExtensions = new Set([".7z", ".exe", ".rar", ".zip"]); function usage(): void { console.log(`Usage: pnpm repack-archive [options] Creates a Windows XP-compatible ZIP next to the source. -o, --output write to a specific ZIP path --temp-dir use a specific directory for temporary files --replace delete the source after successful verification --help show this help message`); } function parseArguments(args: string[]): Options { let output: string | undefined; let temporaryDirectory = tmpdir(); let replace = false; const positional: string[] = []; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } else if (argument === "--replace") { replace = true; } else if (argument === "--output" || argument === "-o") { output = args[index + 1]; if (output === undefined) { throw new Error(`${argument} requires a path`); } index += 1; } else if (argument === "--temp-dir") { const value = args[index + 1]; if (value === undefined) { throw new Error(`${argument} requires a path`); } temporaryDirectory = path.resolve(value); index += 1; } else if (argument.startsWith("-")) { throw new Error(`Unknown option: ${argument}`); } else { positional.push(argument); } } if (positional.length !== 1) { throw new Error("Expected one archive, EXE, or directory path"); } const source = path.resolve(positional[0]); const extension = path.extname(source).toLowerCase(); const sourceBasename = supportedExtensions.has(extension) ? path.basename(source, extension) : path.basename(source); const destination = path.resolve( output ?? path.join(path.dirname(source), `${sourceBasename}.zip`), ); if (path.extname(destination).toLowerCase() !== ".zip") { throw new Error("Output path must have the .zip extension"); } if (source === destination) { throw new Error("Source and output paths are the same; use --output for ZIP input"); } return { source, output: destination, replace, temporaryDirectory }; } async function run( command: string, args: string[], cwd?: string, ): Promise<{ stdout: string; stderr: string }> { return execFileAsync(command, args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, }); } async function sha256(filePath: string): Promise { const hash = createHash("sha256"); for await (const chunk of createReadStream(filePath)) { hash.update(chunk); } return hash.digest("hex"); } async function collectTree(root: string): Promise> { const result = new Map(); async function visit(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name, "en")); for (const entry of entries) { const absolutePath = path.join(directory, entry.name); const relativePath = path.relative(root, absolutePath).split(path.sep).join("/"); const fileStat = await lstat(absolutePath, { bigint: true }); const mtime100ns = fileStat.mtimeNs / 100n; if (fileStat.isDirectory()) { result.set(relativePath, { type: "directory", mtime100ns }); await visit(absolutePath); } else if (fileStat.isFile()) { result.set(relativePath, { type: "file", mtime100ns, size: fileStat.size, sha256: await sha256(absolutePath), }); } else { throw new Error(`Unsupported special file: ${relativePath}`); } } } await visit(root); return result; } function compareTrees( original: Map, verified: Map, ): void { if (original.size === 0) { throw new Error("Source contains no files or directories"); } if (original.size !== verified.size) { throw new Error("ZIP contents differ from the source"); } for (const [entryPath, expected] of original) { const actual = verified.get(entryPath); if (actual === undefined) { throw new Error(`Missing entry after repacking: ${entryPath}`); } if ( actual.type !== expected.type || actual.mtime100ns !== expected.mtime100ns || actual.size !== expected.size || actual.sha256 !== expected.sha256 ) { throw new Error(`Entry differs after repacking: ${entryPath}`); } } } async function validateCompatibility(zipPath: string): Promise { const { stdout } = await run("zipinfo", ["-v", zipPath]); if (/zip64/i.test(stdout)) { throw new Error("Zip64 is not compatible with Windows XP Explorer"); } const versions = [...stdout.matchAll( /minimum software version required to extract:\s*([0-9.]+)/g, )]; if (versions.some((match) => Number(match[1]) > 2)) { throw new Error("ZIP requires an extractor newer than version 2.0"); } const methods = [...stdout.matchAll(/compression method:\s*([^\r\n]+)/g)]; if ( methods.length === 0 || methods.some((match) => !/^(deflated|none \(stored\))$/.test(match[1].trim())) ) { throw new Error("ZIP uses a method other than Store or Deflate"); } const security = [...stdout.matchAll(/file security status:\s*([^\r\n]+)/g)]; if ( security.length === 0 || security.some((match) => match[1].trim() !== "not encrypted") ) { throw new Error("Encrypted ZIP entries are not allowed"); } } async function ensureOutputDoesNotExist(output: string): Promise { try { await lstat(output); throw new Error(`Output already exists: ${output}`); } catch (error) { const code = error instanceof Error && "code" in error ? error.code : undefined; if (code !== "ENOENT") { throw error; } } } async function main(): Promise { const options = parseArguments(process.argv.slice(2)); const sourceStat = await stat(options.source); if (!sourceStat.isFile() && !sourceStat.isDirectory()) { throw new Error(`Unsupported source type: ${options.source}`); } if (sourceStat.isFile()) { const extension = path.extname(options.source).toLowerCase(); if (!supportedExtensions.has(extension)) { throw new Error(`Unsupported source type: ${extension || "no extension"}`); } } else if (options.replace) { throw new Error("--replace is not supported for directory sources"); } await ensureOutputDoesNotExist(options.output); await mkdir(options.temporaryDirectory, { recursive: true }); const temporaryRoot = await mkdtemp( path.join(options.temporaryDirectory, "repack-archive-"), ); const originalDirectory = path.join(temporaryRoot, "original"); const verifiedDirectory = path.join(temporaryRoot, "verified"); const temporaryZip = path.join(temporaryRoot, "output.zip"); await mkdir(originalDirectory); await mkdir(verifiedDirectory); try { if (sourceStat.isDirectory()) { await run("cp", ["-a", "--", path.join(options.source, "."), originalDirectory]); } else if (path.extname(options.source).toLowerCase() === ".exe") { await run("cp", ["-p", "--", options.source, originalDirectory]); } else { await run("7z", ["t", options.source, "-bso0", "-bsp0"]); await run("7z", [ "x", options.source, "-y", `-o${originalDirectory}`, "-bso0", "-bsp0", ]); } const originalTree = await collectTree(originalDirectory); await run( "7z", [ "a", "-tzip", "-mm=Deflate", "-mx=9", "-mtc=on", "-mtm=on", "-mta=on", temporaryZip, ".", "-bso0", "-bsp0", ], originalDirectory, ); await run("touch", ["-r", options.source, temporaryZip]); await run("7z", ["t", temporaryZip, "-bso0", "-bsp0"]); await validateCompatibility(temporaryZip); await run("7z", [ "x", temporaryZip, "-y", `-o${verifiedDirectory}`, "-bso0", "-bsp0", ]); compareTrees(originalTree, await collectTree(verifiedDirectory)); await mkdir(path.dirname(options.output), { recursive: true }); try { await copyFile(temporaryZip, options.output, constants.COPYFILE_EXCL); await run("touch", ["-r", options.source, options.output]); } catch (error) { await rm(options.output, { force: true }); throw error; } if (options.replace) { await rm(options.source); } console.log(`Created ${options.output}`); console.log("Verified contents, SHA-256, timestamps, and Windows XP compatibility."); if (options.replace) { console.log(`Removed ${options.source}`); } } finally { await rm(temporaryRoot, { recursive: true, force: true }); } } try { await main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exitCode = 1; }