309 lines
8.7 KiB
JavaScript
309 lines
8.7 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;
|
|
};
|
|
|
|
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-or-exe>
|
|
|
|
Creates a Windows XP-compatible ZIP next to the source file.
|
|
|
|
-o, --output <path> write to a specific ZIP path
|
|
--replace delete the source after successful verification
|
|
--help show this help message`);
|
|
}
|
|
|
|
function parseArguments(args: string[]): Options {
|
|
let output: string | undefined;
|
|
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.startsWith("-")) {
|
|
throw new Error(`Unknown option: ${argument}`);
|
|
} else {
|
|
positional.push(argument);
|
|
}
|
|
}
|
|
|
|
if (positional.length !== 1) {
|
|
throw new Error("Expected one archive or EXE path");
|
|
}
|
|
|
|
const source = path.resolve(positional[0]);
|
|
const extension = path.extname(source).toLowerCase();
|
|
if (!supportedExtensions.has(extension)) {
|
|
throw new Error(`Unsupported source type: ${extension || "no extension"}`);
|
|
}
|
|
|
|
const destination = path.resolve(
|
|
output ?? path.join(path.dirname(source), `${path.basename(source, extension)}.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 };
|
|
}
|
|
|
|
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()) {
|
|
throw new Error(`Source is not a regular file: ${options.source}`);
|
|
}
|
|
await ensureOutputDoesNotExist(options.output);
|
|
|
|
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "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 (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;
|
|
}
|