265 lines
8.2 KiB
JavaScript
265 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { lstat, mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { promisify } from "node:util";
|
|
|
|
type Options = {
|
|
dryRun: boolean;
|
|
outputDirectory: string;
|
|
sourceDirectory: string;
|
|
temporaryDirectory: string;
|
|
};
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function usage(): void {
|
|
console.log(`Usage: pnpm extract-archives [options] <output-directory>
|
|
|
|
Recursively extracts every ZIP from catalog into the output directory. Category and
|
|
program paths are preserved; each archive is extracted into a directory named after it.
|
|
Existing destination directories and files are skipped.
|
|
|
|
--dry-run list operations without extracting files
|
|
--source <path> read ZIP archives from a directory other than catalog
|
|
--temp-dir <path> use a specific directory for temporary files
|
|
--help show this help message`);
|
|
}
|
|
|
|
function parseArguments(args: string[]): Options {
|
|
const positional: string[] = [];
|
|
let dryRun = false;
|
|
let sourceDirectory = path.resolve("catalog");
|
|
let temporaryDirectory = "/tmp/codex/extract-archives";
|
|
|
|
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 === "--dry-run") {
|
|
dryRun = true;
|
|
} else if (argument === "--source") {
|
|
const value = args[index + 1];
|
|
if (value === undefined) {
|
|
throw new Error("--source requires a path");
|
|
}
|
|
sourceDirectory = path.resolve(value);
|
|
index += 1;
|
|
} else if (argument === "--temp-dir") {
|
|
const value = args[index + 1];
|
|
if (value === undefined) {
|
|
throw new Error("--temp-dir 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 output directory path");
|
|
}
|
|
|
|
return {
|
|
dryRun,
|
|
outputDirectory: path.resolve(positional[0]),
|
|
sourceDirectory,
|
|
temporaryDirectory,
|
|
};
|
|
}
|
|
|
|
async function run(command: string, args: string[]): Promise<string> {
|
|
const { stdout } = await execFileAsync(command, args, {
|
|
encoding: "utf8",
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
return stdout;
|
|
}
|
|
|
|
async function collectArchives(directory: string): Promise<string[]> {
|
|
const archives: string[] = [];
|
|
|
|
async function visit(currentDirectory: string): Promise<void> {
|
|
const entries = await readdir(currentDirectory, { withFileTypes: true });
|
|
entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(currentDirectory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(entryPath);
|
|
} else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".zip") {
|
|
archives.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
await visit(directory);
|
|
return archives;
|
|
}
|
|
|
|
function destinationFor(archive: string, sourceDirectory: string, outputDirectory: string): string {
|
|
const basename = path.basename(archive);
|
|
const stem = basename.slice(0, -path.extname(basename).length);
|
|
if (stem.length === 0) {
|
|
throw new Error(`Archive has no usable name: ${archive}`);
|
|
}
|
|
|
|
const relativeArchive = path.relative(sourceDirectory, archive);
|
|
if (
|
|
relativeArchive === ".." ||
|
|
relativeArchive.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relativeArchive)
|
|
) {
|
|
throw new Error(`Archive is outside the source directory: ${archive}`);
|
|
}
|
|
|
|
let relativeParent = path.dirname(relativeArchive);
|
|
if (path.basename(relativeParent) === "files") {
|
|
relativeParent = path.dirname(relativeParent);
|
|
}
|
|
return path.join(outputDirectory, relativeParent, stem);
|
|
}
|
|
|
|
async function pathExists(target: string): Promise<boolean> {
|
|
try {
|
|
await lstat(target);
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function validateArchivePaths(archive: string, listing: string): void {
|
|
for (const originalEntry of listing.split(/\r?\n/)) {
|
|
if (originalEntry.length === 0) {
|
|
continue;
|
|
}
|
|
const entry = originalEntry.replaceAll("\\", "/");
|
|
const parts = entry.split("/");
|
|
if (
|
|
entry.startsWith("/") ||
|
|
/^[a-z]:($|\/)/i.test(entry) ||
|
|
parts.includes("..")
|
|
) {
|
|
throw new Error(`${archive}: unsafe path in ZIP: ${originalEntry}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function validateExtractedTree(directory: string): Promise<void> {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
const entryStat = await lstat(entryPath);
|
|
if (entryStat.isSymbolicLink()) {
|
|
throw new Error(`ZIP contains a symbolic link: ${entryPath}`);
|
|
}
|
|
if (entryStat.isDirectory()) {
|
|
await validateExtractedTree(entryPath);
|
|
} else if (!entryStat.isFile()) {
|
|
throw new Error(`ZIP contains an unsupported special file: ${entryPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function extractArchive(archive: string, destination: string, temporaryRoot: string): Promise<void> {
|
|
await run("7z", ["t", archive, "-bso0", "-bsp0"]);
|
|
validateArchivePaths(archive, await run("zipinfo", ["-1", archive]));
|
|
|
|
const temporaryDirectory = await mkdtemp(path.join(temporaryRoot, "archive-"));
|
|
let destinationCreated = false;
|
|
try {
|
|
await run("7z", ["x", archive, `-o${temporaryDirectory}`, "-y", "-bso0", "-bsp0"]);
|
|
await validateExtractedTree(temporaryDirectory);
|
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
await mkdir(destination);
|
|
destinationCreated = true;
|
|
await run("cp", ["-a", "--", `${temporaryDirectory}${path.sep}.`, destination]);
|
|
} catch (error) {
|
|
if (destinationCreated) {
|
|
await rm(destination, { recursive: true, force: true });
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const rootStat = await stat(options.sourceDirectory);
|
|
if (!rootStat.isDirectory()) {
|
|
throw new Error(`Expected a source directory: ${options.sourceDirectory}`);
|
|
}
|
|
|
|
const relativeOutput = path.relative(options.sourceDirectory, options.outputDirectory);
|
|
if (
|
|
relativeOutput === "" ||
|
|
(!relativeOutput.startsWith(`..${path.sep}`) && relativeOutput !== ".." && !path.isAbsolute(relativeOutput))
|
|
) {
|
|
throw new Error("Output directory must not be inside the source directory");
|
|
}
|
|
|
|
const archives = await collectArchives(options.sourceDirectory);
|
|
if (archives.length === 0) {
|
|
console.log(`No ZIP archives found in ${options.sourceDirectory}`);
|
|
return;
|
|
}
|
|
|
|
if (!options.dryRun) {
|
|
await mkdir(options.outputDirectory, { recursive: true });
|
|
await mkdir(options.temporaryDirectory, { recursive: true });
|
|
}
|
|
|
|
let extracted = 0;
|
|
let failed = 0;
|
|
let skipped = 0;
|
|
|
|
for (const archive of archives) {
|
|
try {
|
|
const destination = destinationFor(
|
|
archive,
|
|
options.sourceDirectory,
|
|
options.outputDirectory,
|
|
);
|
|
if (await pathExists(destination)) {
|
|
skipped += 1;
|
|
console.log(`Skipped: ${archive} (destination exists: ${destination})`);
|
|
} else if (options.dryRun) {
|
|
console.log(`Would extract: ${archive} -> ${destination}`);
|
|
} else {
|
|
await extractArchive(archive, destination, options.temporaryDirectory);
|
|
extracted += 1;
|
|
console.log(`Extracted: ${archive} -> ${destination}`);
|
|
}
|
|
} catch (error) {
|
|
failed += 1;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Failed: ${archive}: ${message}`);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`Done. Archives: ${archives.length}; extracted: ${extracted}; skipped: ${skipped}; failed: ${failed}`,
|
|
);
|
|
if (failed > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Error: ${message}`);
|
|
process.exitCode = 1;
|
|
}
|