feat: track direct extraction outputs

Replace package staging assumptions with validated per-generation output records for internal ZIP and both JVM backends. Persist resume v2 using relative archive identities, complete member fingerprints, and concrete verified outputs so basename collisions, changed members, missing outputs, and unknown versions fail closed.
This commit is contained in:
Sucukdeluxe
2026-08-22 13:37:19 +02:00
parent afa7da11da
commit 9049489e87
17 changed files with 1227 additions and 234 deletions
+490 -162
View File
@@ -4,8 +4,11 @@ import os from "node:os";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import AdmZip from "adm-zip";
import { CleanupMode, ConflictMode } from "../shared/types";
import { logger } from "./logger";
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { logger } from "./logger";
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { PackageOutputScope, type ExtractOutputEvent } from "./package-output-scope";
export type { ExtractOutputEvent } from "./package-output-scope";
import crypto from "node:crypto";
@@ -59,9 +62,10 @@ export interface ExtractOptions {
hybridMode?: boolean;
maxParallel?: number;
extractCpuPriority?: string;
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
}
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
onOutput?: (event: ExtractOutputEvent) => void;
}
export interface ExtractProgressUpdate {
current: number;
@@ -131,7 +135,7 @@ type JvmExtractorLayout = {
rootDir: string;
};
type JvmExtractResult = {
type JvmExtractResult = {
ok: boolean;
missingCommand: boolean;
missingRuntime: boolean;
@@ -139,8 +143,15 @@ type JvmExtractResult = {
timedOut: boolean;
errorText: string;
usedPassword: string;
backend: string;
};
backend: string;
};
export interface ExtractResult {
extracted: number;
failed: number;
lastError: string;
outputFiles: string[];
}
type ExtractSpawnResult = {
ok: boolean;
@@ -150,15 +161,34 @@ type ExtractSpawnResult = {
errorText: string;
};
type ExtractResumeState = {
completedArchives: string[];
};
type ExtractResumeMember = {
path: string;
size: number;
mtimeMs: number;
};
type ExtractResumeOutput = {
entryPath: string;
path: string;
disposition: ExtractOutputEvent["disposition"];
};
type ExtractResumeArchive = {
archivePath: string;
members: ExtractResumeMember[];
outputs: ExtractResumeOutput[];
};
type ExtractResumeState = {
version: 2;
archives: ExtractResumeArchive[];
};
type ExtractorCommandKind = "rar_native" | "seven_zip" | "other";
interface SubstMapping { drive: string; original: string; }
interface DaemonRequest {
interface DaemonRequest {
resolve: (result: JvmExtractResult) => void;
onArchiveProgress?: (percent: number) => void;
signal?: AbortSignal;
@@ -166,8 +196,9 @@ interface DaemonRequest {
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
archiveName: string;
startedAt: number;
passwordCount: number;
}
passwordCount: number;
onOutput?: (event: ExtractOutputEvent) => void;
}
const activeSubstDrives = new Set<string>();
@@ -247,10 +278,6 @@ export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
function archiveNameKey(fileName: string): string {
return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || "");
}
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
}
@@ -326,14 +353,30 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return [];
}
let files: string[] = [];
try {
files = (await fs.promises.readdir(packageDir, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => path.join(packageDir, entry.name));
} catch {
return [];
}
let files: string[] = [];
const stack = [packageDir];
while (stack.length > 0) {
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try {
entries = await fs.promises.readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
if (!/^\.rd-(?:output|replace)-/i.test(entry.name)) {
stack.push(fullPath);
}
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase()));
const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath)));
@@ -1510,11 +1553,12 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
return null;
}
function parseJvmLine(
line: string,
onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }
): void {
function parseJvmLine(
line: string,
onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string },
onOutput?: (event: ExtractOutputEvent) => void
): void {
const trimmed = String(line || "").trim();
if (!trimmed) {
return;
@@ -1542,10 +1586,31 @@ function parseJvmLine(
return;
}
if (trimmed.startsWith("RD_BACKEND ")) {
state.backend = trimmed.slice("RD_BACKEND ".length).trim();
return;
}
if (trimmed.startsWith("RD_BACKEND ")) {
state.backend = trimmed.slice("RD_BACKEND ".length).trim();
return;
}
if (trimmed.startsWith("RD_OUTPUT ")) {
const fields = trimmed.split(" ");
const stateValue = fields[2];
const disposition = fields[3];
if (fields.length !== 7
|| fields[1] !== "1"
|| (stateValue !== "complete" && stateValue !== "partial")
|| !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) {
return;
}
onOutput?.({
version: 1,
archivePath: Buffer.from(fields[4], "base64").toString("utf8"),
entryPath: Buffer.from(fields[5], "base64").toString("utf8"),
outputPath: Buffer.from(fields[6], "base64").toString("utf8"),
state: stateValue,
disposition: disposition as ExtractOutputEvent["disposition"]
});
return;
}
if (trimmed.startsWith("RD_ERROR ")) {
state.reportedError = trimmed.slice("RD_ERROR ".length).trim();
@@ -1601,11 +1666,11 @@ function flushDaemonParseBuffers(req: DaemonRequest | null): void {
return;
}
if (daemonStdoutBuffer.trim()) {
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState);
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
daemonStdoutBuffer = "";
}
if (daemonStderrBuffer.trim()) {
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState);
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
daemonStderrBuffer = "";
}
}
@@ -1663,7 +1728,7 @@ function handleDaemonLine(line: string): void {
}
if (daemonCurrentRequest) {
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
}
@@ -1716,7 +1781,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
if (daemonCurrentRequest) {
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
}
});
@@ -1778,14 +1843,15 @@ function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<bo
});
}
function sendDaemonRequest(
function sendDaemonRequest(
archivePath: string,
targetDir: string,
conflictMode: ConflictMode,
passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number,
onOutput?: (event: ExtractOutputEvent) => void
): Promise<JvmExtractResult> {
return new Promise((resolve) => {
const mode = effectiveConflictMode(conflictMode);
@@ -1801,8 +1867,9 @@ function sendDaemonRequest(
timeoutMs,
parseState,
archiveName,
startedAt: Date.now(),
passwordCount: passwordCandidates.length
startedAt: Date.now(),
passwordCount: passwordCandidates.length,
onOutput
};
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
@@ -1858,15 +1925,16 @@ function sendDaemonRequest(
});
}
async function runJvmExtractCommand(
async function runJvmExtractCommand(
layout: JvmExtractorLayout,
archivePath: string,
targetDir: string,
conflictMode: ConflictMode,
passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number,
onOutput?: (event: ExtractOutputEvent) => void
): Promise<JvmExtractResult> {
if (signal?.aborted) {
return Promise.resolve({
@@ -1879,7 +1947,7 @@ async function runJvmExtractCommand(
if (isDaemonAvailable(layout)) {
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput);
}
if (daemonProcess) {
@@ -1891,7 +1959,7 @@ async function runJvmExtractCommand(
if (ready) {
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput);
}
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
}
@@ -1947,7 +2015,7 @@ async function runJvmExtractCommand(
const lines = nextBuffer.split(/\r?\n/);
const keep = lines.pop() || "";
for (const line of lines) {
parseJvmLine(line, onArchiveProgress, parseState);
parseJvmLine(line, onArchiveProgress, parseState, onOutput);
}
if (fromStdErr) {
stderrBuffer = keep;
@@ -2022,8 +2090,8 @@ async function runJvmExtractCommand(
});
child.on("close", (code) => {
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState);
parseJvmLine(stderrBuffer, onArchiveProgress, parseState);
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput);
parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput);
if (abortedBySignal) {
finish({
@@ -2102,10 +2170,11 @@ async function runExternalExtractInner(
signal: AbortSignal | undefined,
timeoutMs: number,
hybridMode = false,
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"]
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"],
onOutput?: (event: ExtractOutputEvent) => void
): Promise<string> {
const passwords = passwordCandidates;
let lastError = "";
@@ -2281,11 +2350,12 @@ async function runExternalExtract(
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
hybridMode = false,
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"]
): Promise<string> {
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"],
onOutput?: (event: ExtractOutputEvent) => void
): Promise<string> {
const timeoutMs = await computeExtractTimeoutMs(archivePath);
const configuredBackendMode = extractorBackendMode();
const backendMode = extractorBackendModeForArchive(archivePath);
@@ -2317,7 +2387,7 @@ async function runExternalExtract(
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
const jvmResult = await runJvmExtractCommand(
layout, archivePath, targetDir, conflictMode, passwordCandidates,
onArchiveProgress, signal, timeoutMs
onArchiveProgress, signal, timeoutMs, onOutput
);
const jvmMs = Date.now() - jvmStartedAt;
onLog?.("INFO", `JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
@@ -2497,7 +2567,8 @@ async function runExternalExtract(
passwordCandidates,
onArchiveProgress,
signal,
timeoutMs
timeoutMs,
onOutput
);
const jvmMs = Date.now() - jvmStartedAt;
logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
@@ -2575,7 +2646,13 @@ export function selectZipFallbackError(internalError: unknown, externalError: un
return externalError;
}
async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> {
async function extractZipArchive(
archivePath: string,
targetDir: string,
conflictMode: ConflictMode,
signal?: AbortSignal,
onOutput?: (event: ExtractOutputEvent) => void
): Promise<void> {
const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
const zip = new AdmZip(archivePath);
@@ -2631,16 +2708,25 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
}
let outputPath = baseOutputPath;
let outputKey = pathSetKey(outputPath);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) {
if (mode === "skip") {
continue;
}
if (mode === "rename") {
let outputPath = baseOutputPath;
let outputKey = pathSetKey(outputPath);
let disposition: ExtractOutputEvent["disposition"] = "written";
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) {
if (mode === "skip") {
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath: baseOutputPath,
state: "complete",
disposition: "skipped"
});
continue;
}
if (mode === "rename") {
const parsed = path.parse(baseOutputPath);
const counterKey = pathSetKey(baseOutputPath);
let n = renameCounters.get(counterKey) || 1;
@@ -2661,9 +2747,12 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
if (signal?.aborted) {
throw new Error("aborted:extract");
}
outputPath = candidate;
outputKey = candidateKey;
}
outputPath = candidate;
outputKey = candidateKey;
disposition = "renamed";
} else {
disposition = "overwritten";
}
}
if (signal?.aborted) {
@@ -2679,10 +2768,32 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) {
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
}
await fs.promises.writeFile(outputPath, data);
usedOutputs.add(outputKey);
}
}
try {
await fs.promises.writeFile(outputPath, data);
usedOutputs.add(outputKey);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath,
state: "complete",
disposition
});
} catch (error) {
if (await fs.promises.access(outputPath).then(() => true, () => false)) {
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath,
state: "partial",
disposition
});
}
throw error;
}
}
}
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
let total = 0;
@@ -2774,30 +2885,135 @@ function extractProgressFilePath(packageDir: string, packageId?: string): string
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
}
async function readExtractResumeState(packageDir: string, packageId?: string): Promise<Set<string>> {
const progressPath = extractProgressFilePath(packageDir, packageId);
function relativeResumePath(rootDir: string, filePath: string): string | null {
const relativePath = path.relative(path.resolve(rootDir), path.resolve(filePath));
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
return null;
}
return relativePath.replace(/\\/g, "/");
}
function archiveResumeIdentity(archivePath: string, packageDir: string, targetDir: string): string | null {
const packageRelative = relativeResumePath(packageDir, archivePath);
if (packageRelative) {
return `package:${packageRelative}`;
}
const targetRelative = relativeResumePath(targetDir, archivePath);
return targetRelative ? `target:${targetRelative}` : null;
}
function resolveResumePath(identity: string, packageDir: string, targetDir: string): string | null {
const separator = identity.indexOf(":");
if (separator <= 0) {
return null;
}
const prefix = identity.slice(0, separator);
const relativePath = identity.slice(separator + 1).replace(/\\/g, "/");
const segments = relativePath.split("/");
if (!relativePath || segments.some((segment) => !segment || segment === "..") || path.posix.isAbsolute(relativePath) || /^[a-zA-Z]:/.test(relativePath)) {
return null;
}
const rootDir = prefix === "package" ? packageDir : prefix === "target" ? targetDir : "";
if (!rootDir) {
return null;
}
const resolved = path.resolve(rootDir, ...segments);
return relativeResumePath(rootDir, resolved) ? resolved : null;
}
async function buildArchiveFingerprint(archivePath: string, packageDir: string, targetDir: string): Promise<ExtractResumeMember[]> {
const members: ExtractResumeMember[] = [];
const candidates = collectArchiveCleanupTargets(archivePath);
for (const memberPath of candidates) {
const identity = archiveResumeIdentity(memberPath, packageDir, targetDir);
if (!identity) {
return [];
}
try {
const stat = await fs.promises.stat(memberPath);
if (!stat.isFile()) {
return [];
}
members.push({ path: identity, size: stat.size, mtimeMs: stat.mtimeMs });
} catch {
return [];
}
}
return members.sort((left, right) => left.path.localeCompare(right.path));
}
function sameArchiveFingerprint(left: readonly ExtractResumeMember[], right: readonly ExtractResumeMember[]): boolean {
return left.length > 0
&& left.length === right.length
&& left.every((member, index) => member.path === right[index]?.path
&& member.size === right[index]?.size
&& member.mtimeMs === right[index]?.mtimeMs);
}
async function readExtractResumeState(packageDir: string, targetDir: string, packageId?: string): Promise<Map<string, ExtractResumeArchive>> {
const progressPath = extractProgressFilePath(packageDir, packageId);
try {
await fs.promises.access(progressPath);
} catch {
return new Set<string>();
}
try {
const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>;
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean));
} catch {
return new Set<string>();
}
}
async function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, packageId?: string): Promise<void> {
return new Map<string, ExtractResumeArchive>();
}
try {
const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>;
if (payload.version !== 2 || !Array.isArray(payload.archives)) {
return new Map<string, ExtractResumeArchive>();
}
const archives = new Map<string, ExtractResumeArchive>();
for (const rawArchive of payload.archives) {
if (!rawArchive || typeof rawArchive !== "object") {
continue;
}
const archivePath = String(rawArchive.archivePath || "").trim();
if (!resolveResumePath(archivePath, packageDir, targetDir)) {
continue;
}
const members = Array.isArray(rawArchive.members)
? rawArchive.members.flatMap((member) => {
const memberPath = String(member?.path || "").trim();
const size = Number(member?.size);
const mtimeMs = Number(member?.mtimeMs);
return resolveResumePath(memberPath, packageDir, targetDir)
&& Number.isFinite(size) && size >= 0
&& Number.isFinite(mtimeMs) && mtimeMs >= 0
? [{ path: memberPath, size, mtimeMs }]
: [];
}).sort((left, right) => left.path.localeCompare(right.path))
: [];
const outputs = Array.isArray(rawArchive.outputs)
? rawArchive.outputs.flatMap((output) => {
const outputPath = String(output?.path || "").trim().replace(/\\/g, "/");
const entryPath = String(output?.entryPath || "").trim().replace(/\\/g, "/");
const disposition = output?.disposition;
const resolvedOutput = resolveResumePath(`target:${outputPath}`, packageDir, targetDir);
return resolvedOutput
&& entryPath
&& !entryPath.split("/").some((segment) => segment === "..")
&& (["written", "overwritten", "renamed"] as const).includes(disposition as ExtractResumeOutput["disposition"])
? [{ entryPath, path: outputPath, disposition: disposition as ExtractResumeOutput["disposition"] }]
: [];
})
: [];
if (members.length > 0 && outputs.length > 0) {
archives.set(archivePath.toLocaleLowerCase("en-US"), { archivePath, members, outputs });
}
}
return archives;
} catch {
return new Map<string, ExtractResumeArchive>();
}
}
async function writeExtractResumeState(packageDir: string, completedArchives: ReadonlyMap<string, ExtractResumeArchive>, packageId?: string): Promise<void> {
try {
await fs.promises.mkdir(packageDir, { recursive: true });
const progressPath = extractProgressFilePath(packageDir, packageId);
const payload: ExtractResumeState = {
completedArchives: Array.from(completedArchives)
.map((name) => archiveNameKey(name))
.sort((a, b) => a.localeCompare(b))
const progressPath = extractProgressFilePath(packageDir, packageId);
const payload: ExtractResumeState = {
version: 2,
archives: [...completedArchives.values()].sort((left, right) => left.archivePath.localeCompare(right.archivePath))
};
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
@@ -2841,10 +3057,15 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
return "skip";
}
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
export async function extractPackageArchives(options: ExtractOptions): Promise<ExtractResult> {
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
const outputScope = new PackageOutputScope([options.targetDir]);
const emitOutput = (event: ExtractOutputEvent): void => {
outputScope.add(event);
options.onOutput?.(event);
};
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
const allCandidates = await findArchiveCandidates(options.packageDir);
const candidates = options.onlyArchives
@@ -2864,12 +3085,29 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
await checkDiskSpaceForExtraction(options.targetDir, candidates);
}
if (candidates.length === 0) {
if (!options.onlyArchives) {
const existingResume = await readExtractResumeState(options.packageDir, options.packageId);
if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) {
await clearExtractResumeState(options.packageDir, options.packageId);
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
if (candidates.length === 0) {
if (!options.onlyArchives) {
const existingResume = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId);
const validResume = [...existingResume.values()].filter((archive) => archive.outputs.length > 0);
const allOutputsExist = validResume.length > 0
&& (await Promise.all(validResume.map((archive) => resumeOutputsExist(archive, options.packageDir, options.targetDir)))).every(Boolean);
if (allOutputsExist) {
for (const archive of validResume) {
const archivePath = resolveResumePath(archive.archivePath, options.packageDir, options.targetDir) as string;
for (const output of archive.outputs) {
const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string;
outputScope.add({
version: 1,
archivePath,
entryPath: output.entryPath,
outputPath,
state: "complete",
disposition: output.disposition
});
}
}
await clearExtractResumeState(options.packageDir, options.packageId);
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
options.onProgress?.({
current: existingResume.size,
total: existingResume.size,
@@ -2877,12 +3115,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
archiveName: "",
phase: "done"
});
return { extracted: existingResume.size, failed: 0, lastError: "" };
outputScope.pruneMissing();
return { extracted: existingResume.size, failed: 0, lastError: "", outputFiles: outputScope.completeFiles() };
}
await clearExtractResumeState(options.packageDir, options.packageId);
}
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
return { extracted: 0, failed: 0, lastError: "" };
return { extracted: 0, failed: 0, lastError: "", outputFiles: [] };
}
const conflictMode = effectiveConflictMode(options.conflictMode);
@@ -2898,28 +3137,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
}
const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId);
const resumeCompletedAtStart = resumeCompleted.size;
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
for (const archiveName of Array.from(resumeCompleted.values())) {
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
// disk to validate against, so it must NOT be pruned here — otherwise every
// extractPackageArchives call wiped it and nested archives were re-extracted on
// resume. It is cleared together with the rest once the package fully completes.
if (archiveName.startsWith("nested:")) {
continue;
}
if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName);
}
}
const resumeCompleted = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId);
const resumeCompletedAtStart = resumeCompleted.size;
const resumedArchivePaths = new Set<string>();
for (const archivePath of candidates) {
const identity = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir);
const saved = identity ? resumeCompleted.get(identity.toLocaleLowerCase("en-US")) : undefined;
if (!identity || !saved) {
continue;
}
const currentFingerprint = await buildArchiveFingerprint(archivePath, options.packageDir, options.targetDir);
if (!sameArchiveFingerprint(saved.members, currentFingerprint)
|| !(await resumeOutputsExist(saved, options.packageDir, options.targetDir))) {
resumeCompleted.delete(identity.toLocaleLowerCase("en-US"));
continue;
}
resumedArchivePaths.add(pathSetKey(archivePath));
for (const output of saved.outputs) {
const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string;
outputScope.add({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: output.entryPath,
outputPath,
state: "complete",
disposition: output.disposition
});
}
}
if (resumeCompleted.size > 0) {
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
} else {
await clearExtractResumeState(options.packageDir, options.packageId);
}
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath))));
const pendingCandidates = candidates.filter((archivePath) => !resumedArchivePaths.has(pathSetKey(archivePath)));
let extracted = candidates.length - pendingCandidates.length;
let failed = 0;
let lastError = "";
@@ -2929,7 +3181,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const skippedNonArchives = new Set<string>();
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
if (resumedArchivePaths.has(pathSetKey(archivePath))) {
const resumedName = path.basename(archivePath);
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
@@ -2998,8 +3250,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted, "", "extracting");
for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
for (const archivePath of candidates) {
if (resumedArchivePaths.has(pathSetKey(archivePath))) {
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }, archivePath);
}
}
@@ -3016,7 +3268,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
throw new Error("noextractor:skipped");
}
const archiveName = path.basename(archivePath);
const archiveResumeKey = archiveNameKey(archiveName);
const archiveResumeKey = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir)?.toLocaleLowerCase("en-US") || "";
const archiveStartedAt = Date.now();
const startedCurrent = extracted + failed;
if (lastArchiveFinishedAt !== null) {
@@ -3053,9 +3305,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (!sig) {
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
extracted += 1;
resumeCompleted.add(archiveResumeKey);
skippedNonArchives.add(pathSetKey(archivePath));
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
skippedNonArchives.add(pathSetKey(archivePath));
clearInterval(pulseTimer);
archiveOutcome = "skipped";
const skippedAt = Date.now();
@@ -3089,18 +3339,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
} catch (error) {
if (isNoExtractorError(String(error))) {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
} else {
throw error;
}
}
} else {
try {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
archivePercent = 100;
} catch (error) {
if (!shouldFallbackToExternalZip(error)) {
@@ -3109,7 +3359,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
try {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
} catch (externalError) {
throw selectZipFallbackError(error, externalError);
@@ -3120,15 +3370,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const flatResult = { needed: false };
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
reportArchiveProgress(value);
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog);
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput);
rememberLearnedPassword(usedPassword);
if (flatResult.needed) packageNeedsFlatMode = true;
}
extracted += 1;
extractedArchives.add(archivePath);
failedArchiveCategories.delete(archivePath);
resumeCompleted.add(archiveResumeKey);
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
const resumeArchive = await buildResumeArchive(archivePath, options.packageDir, options.targetDir, outputScope.records());
if (archiveResumeKey && resumeArchive) {
resumeCompleted.set(archiveResumeKey, resumeArchive);
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
}
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
archiveOutcome = "success";
@@ -3248,7 +3501,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (abortError) throw new Error("aborted:extract");
if (failed > 0 && extracted === 0) {
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap)));
const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown");
if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) {
const categorySummary = [...new Set(failedCategories)].join(",");
@@ -3275,7 +3528,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
if (failed > 0 && extracted > 0) {
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap)));
if (failedArchives.length > 0) {
logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`);
let retryRecovered = 0;
@@ -3308,8 +3561,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
try {
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
.filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p));
const nestedCandidates = outputScope.archiveFiles()
.filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p));
if (nestedCandidates.length > 0) {
logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`);
let nestedExtracted = 0;
@@ -3321,13 +3574,31 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
nestedCandidates.length = 0;
}
for (const nestedArchive of nestedCandidates) {
if (options.signal?.aborted) throw new Error("aborted:extract");
const nestedName = path.basename(nestedArchive);
const nestedKey = archiveNameKey(`nested:${nestedName}`);
if (resumeCompleted.has(nestedKey)) {
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
continue;
}
if (options.signal?.aborted) throw new Error("aborted:extract");
const nestedName = path.basename(nestedArchive);
const nestedIdentity = archiveResumeIdentity(nestedArchive, options.packageDir, options.targetDir);
const nestedKey = nestedIdentity?.toLocaleLowerCase("en-US") || "";
const savedNested = nestedKey ? resumeCompleted.get(nestedKey) : undefined;
const nestedFingerprint = savedNested
? await buildArchiveFingerprint(nestedArchive, options.packageDir, options.targetDir)
: [];
if (savedNested
&& sameArchiveFingerprint(savedNested.members, nestedFingerprint)
&& await resumeOutputsExist(savedNested, options.packageDir, options.targetDir)) {
for (const output of savedNested.outputs) {
const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string;
outputScope.add({
version: 1,
archivePath: nestedArchive,
entryPath: output.entryPath,
outputPath,
state: "complete",
disposition: output.disposition
});
}
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
continue;
}
const nestedStartedAt = Date.now();
let nestedPercent = 0;
emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0, undefined, undefined, nestedArchive);
@@ -3340,22 +3611,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal);
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput);
nestedPercent = 100;
} catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog);
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
} else {
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog);
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
rememberLearnedPassword(usedPw);
}
extracted += 1;
nestedExtracted += 1;
extractedArchives.add(nestedArchive);
resumeCompleted.add(nestedKey);
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
const nestedResume = await buildResumeArchive(nestedArchive, options.packageDir, options.targetDir, outputScope.records());
if (nestedKey && nestedResume) {
resumeCompleted.set(nestedKey, nestedResume);
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
}
logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`);
if (options.cleanupMode !== "none") {
await cleanupArchives([nestedArchive], options.cleanupMode);
@@ -3450,5 +3724,59 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
return { extracted, failed, lastError };
}
outputScope.pruneMissing();
return { extracted, failed, lastError, outputFiles: outputScope.completeFiles() };
}
async function resumeOutputsExist(
archive: ExtractResumeArchive,
packageDir: string,
targetDir: string
): Promise<boolean> {
const archivePath = resolveResumePath(archive.archivePath, packageDir, targetDir);
if (!archivePath || archive.outputs.length === 0) {
return false;
}
const scope = new PackageOutputScope([targetDir]);
try {
for (const output of archive.outputs) {
const outputPath = resolveResumePath(`target:${output.path}`, packageDir, targetDir);
if (!outputPath) {
return false;
}
scope.add({
version: 1,
archivePath,
entryPath: output.entryPath,
outputPath,
state: "complete",
disposition: output.disposition
});
}
} catch {
return false;
}
return scope.completeFiles().length === archive.outputs.length;
}
async function buildResumeArchive(
archivePath: string,
packageDir: string,
targetDir: string,
outputRecords: readonly ExtractOutputEvent[]
): Promise<ExtractResumeArchive | null> {
const archiveIdentity = archiveResumeIdentity(archivePath, packageDir, targetDir);
const members = await buildArchiveFingerprint(archivePath, packageDir, targetDir);
if (!archiveIdentity || members.length === 0) {
return null;
}
const archiveKey = pathSetKey(path.resolve(archivePath));
const outputs = outputRecords.flatMap((record) => {
if (record.state !== "complete" || record.disposition === "skipped" || pathSetKey(record.archivePath) !== archiveKey) {
return [];
}
const outputPath = relativeResumePath(targetDir, record.outputPath);
return outputPath ? [{ entryPath: record.entryPath, path: outputPath, disposition: record.disposition }] : [];
});
return outputs.length > 0 ? { archivePath: archiveIdentity, members, outputs } : null;
}
+220
View File
@@ -0,0 +1,220 @@
import fs from "node:fs";
import path from "node:path";
export type ExtractOutputState = "complete" | "partial";
export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped";
export interface ExtractOutputEvent {
version: 1;
archivePath: string;
entryPath: string;
outputPath: string;
state: ExtractOutputState;
disposition: ExtractOutputDisposition;
}
export class PackageOutputScope {
private readonly authorizedRoots: string[];
private readonly outputRecords = new Map<string, ExtractOutputEvent>();
public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) {
this.authorizedRoots = [...new Map(
authorizedRoots
.map((root) => path.resolve(String(root || "").trim()))
.filter(Boolean)
.map((root) => [this.pathKey(root), root])
).values()];
if (this.authorizedRoots.length === 0) {
throw new Error("PackageOutputScope benötigt mindestens einen autorisierten Root");
}
this.addMany(records);
}
private pathKey(value: string): string {
return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US");
}
private validateEntryPath(entryPath: string): string {
const normalized = String(entryPath || "").trim().replace(/\\/g, "/");
const segments = normalized.split("/");
if (!normalized
|| normalized.startsWith("/")
|| /^[a-zA-Z]:/.test(normalized)
|| path.posix.isAbsolute(normalized)
|| segments.some((segment) => segment === ".." || segment === "")) {
throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`);
}
return segments.filter((segment) => segment !== ".").join("/");
}
private findAuthorizedRoot(outputPath: string): string {
const resolvedOutput = path.resolve(outputPath);
const outputKey = this.pathKey(resolvedOutput);
const root = this.authorizedRoots.find((candidate) => {
const rootKey = this.pathKey(candidate);
return outputKey === rootKey || outputKey.startsWith(`${rootKey}${path.sep.toLocaleLowerCase("en-US")}`);
});
if (!root || this.pathKey(root) === outputKey) {
throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`);
}
return root;
}
private rejectLinkedPath(outputPath: string, authorizedRoot: string): void {
let current = path.resolve(outputPath);
const rootKey = this.pathKey(authorizedRoot);
while (true) {
try {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) {
throw new Error(`Symbolischer Link oder Reparse Point im Ausgabepfad: ${outputPath}`);
}
} catch (error) {
const code = String((error as NodeJS.ErrnoException)?.code || "");
if (code !== "ENOENT") {
throw error;
}
}
if (this.pathKey(current) === rootKey) {
break;
}
const parent = path.dirname(current);
if (parent === current) {
throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`);
}
current = parent;
}
}
private normalizeEvent(event: ExtractOutputEvent): ExtractOutputEvent {
if (Number(event.version) !== 1) {
throw new Error(`Nicht unterstützte Extract-Output-Version: ${String(event.version)}`);
}
if (!path.isAbsolute(String(event.archivePath || ""))) {
throw new Error(`Archivpfad muss absolut sein: ${event.archivePath}`);
}
if (event.state !== "complete" && event.state !== "partial") {
throw new Error(`Ungültiger Extract-Output-Status: ${String(event.state)}`);
}
if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) {
throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`);
}
const entryPath = this.validateEntryPath(event.entryPath);
if (!path.isAbsolute(String(event.outputPath || ""))) {
throw new Error(`Finaler Ausgabepfad muss absolut sein: ${event.outputPath}`);
}
const outputPath = path.resolve(event.outputPath);
const authorizedRoot = this.findAuthorizedRoot(outputPath);
this.rejectLinkedPath(outputPath, authorizedRoot);
if (event.disposition !== "skipped") {
let stat: fs.Stats;
try {
stat = fs.lstatSync(outputPath);
} catch {
throw new Error(`Gemeldete Extract-Ausgabe existiert nicht: ${outputPath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(`Gemeldete Extract-Ausgabe ist keine reguläre Datei: ${outputPath}`);
}
}
return {
version: 1,
archivePath: path.resolve(event.archivePath),
entryPath,
outputPath,
state: event.state,
disposition: event.disposition
};
}
public add(event: ExtractOutputEvent): boolean {
const normalized = this.normalizeEvent(event);
if (normalized.disposition === "skipped") {
return false;
}
const key = this.pathKey(normalized.outputPath);
const current = this.outputRecords.get(key);
if (current) {
if (current.state === "partial" && normalized.state === "complete") {
this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath });
}
return false;
}
this.outputRecords.set(key, normalized);
return true;
}
public addMany(events: readonly ExtractOutputEvent[]): number {
let added = 0;
for (const event of events) {
if (this.add(event)) {
added += 1;
}
}
return added;
}
public records(): ExtractOutputEvent[] {
return [...this.outputRecords.values()];
}
public completeFiles(): string[] {
return this.records().filter((record) => record.state === "complete").map((record) => record.outputPath);
}
public partialFiles(): string[] {
return this.records().filter((record) => record.state === "partial").map((record) => record.outputPath);
}
public files(): string[] {
return this.records().map((record) => record.outputPath);
}
public archiveFiles(): string[] {
return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|001)$/i.test(filePath));
}
public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean {
const sourceKey = this.pathKey(sourcePath);
const current = this.outputRecords.get(sourceKey);
if (!current) {
return false;
}
const next = this.normalizeEvent({
...current,
outputPath: targetPath,
entryPath: path.basename(targetPath),
state: state || current.state,
disposition: targetPath === current.outputPath ? current.disposition : "renamed"
});
this.outputRecords.delete(sourceKey);
this.outputRecords.set(this.pathKey(next.outputPath), next);
return true;
}
public removePath(outputPath: string): boolean {
return this.outputRecords.delete(this.pathKey(outputPath));
}
public has(outputPath: string): boolean {
return this.outputRecords.has(this.pathKey(outputPath));
}
public pruneMissing(): number {
let removed = 0;
for (const record of this.records()) {
try {
const stat = fs.lstatSync(record.outputPath);
if (stat.isFile() && !stat.isSymbolicLink()) {
continue;
}
} catch {
}
if (this.removePath(record.outputPath)) {
removed += 1;
}
}
return removed;
}
}