Files
soft/scripts/repackArchive.ts

332 lines
9.6 KiB
JavaScript

#!/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] <archive-exe-or-directory>
Creates a Windows XP-compatible ZIP next to the source.
-o, --output <path> write to a specific ZIP path
--temp-dir <path> 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<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk);
}
return hash.digest("hex");
}
async function collectTree(root: string): Promise<Map<string, TreeEntry>> {
const result = new Map<string, TreeEntry>();
async function visit(directory: string): Promise<void> {
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<string, TreeEntry>,
verified: Map<string, TreeEntry>,
): 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<void> {
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<void> {
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<void> {
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;
}