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
@@ -274,8 +274,10 @@ public final class JBindExtractorMain {
}
long itemUnits = safeSize(header.getUncompressedSize());
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
File output = outputTarget.file;
if (output == null) {
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
progress.advance(itemUnits);
continue;
}
@@ -323,12 +325,16 @@ public final class JBindExtractorMain {
output.setLastModified(modified);
}
extractionSuccess = true;
emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition);
} catch (ZipException error) {
if (isWrongPassword(error, encrypted)) {
throw new WrongPasswordException(error);
}
throw error;
} finally {
if (!extractionSuccess && output.exists()) {
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
}
if (!extractionSuccess && output.exists()) {
try {
output.delete();
@@ -371,6 +377,8 @@ public final class JBindExtractorMain {
List<Integer> fileIndices = new ArrayList<Integer>();
List<File> outputFiles = new ArrayList<File>();
List<Long> fileSizes = new ArrayList<Long>();
List<String> entryNames = new ArrayList<String>();
List<String> dispositions = new ArrayList<String>();
Set<String> reserved = new HashSet<String>();
for (int i = 0; i < itemCount; i++) {
@@ -396,10 +404,16 @@ public final class JBindExtractorMain {
long itemSize = safeSize(rawSize);
totalUnits += itemSize;
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
File output = outputTarget.file;
if (output == null) {
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
}
fileIndices.add(i);
outputFiles.add(output);
fileSizes.add(itemSize);
entryNames.add(entryName);
dispositions.add(outputTarget.disposition);
}
if (fileIndices.isEmpty()) {
@@ -434,7 +448,7 @@ public final class JBindExtractorMain {
try {
archive.extract(indices, false, new BulkExtractCallback(
archive, indexToPos, fileIndices, outputFiles, fileSizes,
archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
progress, encryptedFinal, effectivePassword, currentOutput,
currentStream, currentSuccess, currentRemaining, currentPos, firstError
));
@@ -566,18 +580,18 @@ public final class JBindExtractorMain {
return directory;
}
private static File resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set<String> reserved) throws IOException {
private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set<String> reserved) throws IOException {
File base = secureResolve(targetDir, entryName);
String key = pathKey(base);
boolean exists = base.exists() || reserved.contains(key);
if (!exists) {
reserved.add(key);
return base;
return new OutputTarget(base, base, "written");
}
if (conflictMode == ConflictMode.SKIP) {
return null;
return new OutputTarget(null, base, "skipped");
}
if (conflictMode == ConflictMode.OVERWRITE) {
@@ -585,7 +599,7 @@ public final class JBindExtractorMain {
deleteRecursively(base);
}
reserved.add(key);
return base;
return new OutputTarget(base, base, "overwritten");
}
File parent = base.getParentFile();
@@ -601,7 +615,7 @@ public final class JBindExtractorMain {
String candidateKey = pathKey(candidate);
if (!candidate.exists() && !reserved.contains(candidateKey)) {
reserved.add(candidateKey);
return candidate;
return new OutputTarget(candidate, candidate, "renamed");
}
counter += 1;
}
@@ -820,6 +834,20 @@ public final class JBindExtractorMain {
System.err.println("RD_ERROR " + message);
}
private static String encodeField(String value) {
return Base64.getEncoder().encodeToString((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
}
private static void emitOutput(File archiveFile, String entryPath, File outputFile, String state, String disposition) {
if (archiveFile == null || outputFile == null) {
return;
}
System.out.println("RD_OUTPUT 1 " + state + " " + disposition + " "
+ encodeField(archiveFile.getAbsolutePath()) + " "
+ encodeField(entryPath == null ? "" : entryPath.replace('\\', '/')) + " "
+ encodeField(outputFile.getAbsolutePath()));
}
private enum Backend {
AUTO("auto"),
SEVENZIPJBIND("7zjbinding"),
@@ -874,12 +902,27 @@ public final class JBindExtractorMain {
private final List<String> passwords = new ArrayList<String>();
}
private static final class OutputTarget {
private final File file;
private final File reportedFile;
private final String disposition;
OutputTarget(File file, File reportedFile, String disposition) {
this.file = file;
this.reportedFile = reportedFile;
this.disposition = disposition;
}
}
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive;
private final File archiveFile;
private final Map<Integer, Integer> indexToPos;
private final List<Integer> fileIndices;
private final List<File> outputFiles;
private final List<Long> fileSizes;
private final List<String> entryNames;
private final List<String> dispositions;
private final ProgressTracker progress;
private final boolean encrypted;
private final String password;
@@ -890,17 +933,21 @@ public final class JBindExtractorMain {
private final int[] currentPos;
private final Throwable[] firstError;
BulkExtractCallback(IInArchive archive, Map<Integer, Integer> indexToPos,
BulkExtractCallback(IInArchive archive, File archiveFile, Map<Integer, Integer> indexToPos,
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
List<String> entryNames, List<String> dispositions,
ProgressTracker progress, boolean encrypted, String password,
File[] currentOutput, FileOutputStream[] currentStream,
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
Throwable[] firstError) {
this.archive = archive;
this.archiveFile = archiveFile;
this.indexToPos = indexToPos;
this.fileIndices = fileIndices;
this.outputFiles = outputFiles;
this.fileSizes = fileSizes;
this.entryNames = entryNames;
this.dispositions = dispositions;
this.progress = progress;
this.encrypted = encrypted;
this.password = password;
@@ -1002,10 +1049,12 @@ public final class JBindExtractorMain {
} catch (Throwable ignored) {
}
emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "complete", dispositions.get(currentPos[0]));
}
} else {
closeCurrentStream();
if (currentOutput[0] != null && currentOutput[0].exists()) {
emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "partial", dispositions.get(currentPos[0]));
try {
currentOutput[0].delete();
} catch (Throwable ignored) {
+407 -79
View File
@@ -6,6 +6,9 @@ import AdmZip from "adm-zip";
import { CleanupMode, ConflictMode } from "../shared/types";
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";
@@ -61,6 +64,7 @@ export interface ExtractOptions {
extractCpuPriority?: string;
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
onOutput?: (event: ExtractOutputEvent) => void;
}
export interface ExtractProgressUpdate {
@@ -142,6 +146,13 @@ type JvmExtractResult = {
backend: string;
};
export interface ExtractResult {
extracted: number;
failed: number;
lastError: string;
outputFiles: string[];
}
type ExtractSpawnResult = {
ok: boolean;
missingCommand: boolean;
@@ -150,8 +161,27 @@ type ExtractSpawnResult = {
errorText: 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 = {
completedArchives: string[];
version: 2;
archives: ExtractResumeArchive[];
};
type ExtractorCommandKind = "rar_native" | "seven_zip" | "other";
@@ -167,6 +197,7 @@ interface DaemonRequest {
archiveName: string;
startedAt: 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+\)(?=\.[^.]+$)/, "");
}
@@ -327,12 +354,28 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
}
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 [];
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()));
@@ -1513,7 +1556,8 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
function parseJvmLine(
line: string,
onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string },
onOutput?: (event: ExtractOutputEvent) => void
): void {
const trimmed = String(line || "").trim();
if (!trimmed) {
@@ -1547,6 +1591,27 @@ function parseJvmLine(
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);
}
}
});
@@ -1785,7 +1850,8 @@ function sendDaemonRequest(
passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number
timeoutMs?: number,
onOutput?: (event: ExtractOutputEvent) => void
): Promise<JvmExtractResult> {
return new Promise((resolve) => {
const mode = effectiveConflictMode(conflictMode);
@@ -1802,7 +1868,8 @@ function sendDaemonRequest(
parseState,
archiveName,
startedAt: Date.now(),
passwordCount: passwordCandidates.length
passwordCount: passwordCandidates.length,
onOutput
};
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
@@ -1866,7 +1933,8 @@ async function runJvmExtractCommand(
passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number
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({
@@ -2105,7 +2173,8 @@ async function runExternalExtractInner(
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"]
onLog?: ExtractOptions["onLog"],
onOutput?: (event: ExtractOutputEvent) => void
): Promise<string> {
const passwords = passwordCandidates;
let lastError = "";
@@ -2284,7 +2353,8 @@ async function runExternalExtract(
onPasswordAttempt?: (attempt: number, total: number) => void,
forceFlatMode = false,
flatModeResult?: { needed: boolean },
onLog?: ExtractOptions["onLog"]
onLog?: ExtractOptions["onLog"],
onOutput?: (event: ExtractOutputEvent) => void
): Promise<string> {
const timeoutMs = await computeExtractTimeoutMs(archivePath);
const configuredBackendMode = extractorBackendMode();
@@ -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);
@@ -2633,11 +2710,20 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
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") {
@@ -2663,6 +2749,9 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
}
outputPath = candidate;
outputKey = candidateKey;
disposition = "renamed";
} else {
disposition = "overwritten";
}
}
@@ -2679,8 +2768,30 @@ 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;
}
}
}
@@ -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>> {
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>();
return new Map<string, ExtractResumeArchive>();
}
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));
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 Set<string>();
return new Map<string, ExtractResumeArchive>();
}
}
async function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, packageId?: string): Promise<void> {
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))
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 }> {
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
@@ -2866,8 +3087,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (candidates.length === 0) {
if (!options.onlyArchives) {
const existingResume = await readExtractResumeState(options.packageDir, options.packageId);
if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) {
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?.({
@@ -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,19 +3137,32 @@ 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 resumeCompleted = await readExtractResumeState(options.packageDir, options.targetDir, 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:")) {
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;
}
if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName);
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) {
@@ -2919,7 +3171,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
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))) {
@@ -2999,7 +3251,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted, "", "extracting");
for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
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);
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,7 +3561,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
try {
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
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`);
@@ -3323,8 +3576,26 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
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)) {
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;
}
@@ -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;
}
}
+54 -1
View File
@@ -50,6 +50,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const zip = new AdmZip();
zip.addFile("episode.txt", Buffer.from("ok"));
zip.writeZip(zipPath);
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const result = await extractPackageArchives({
packageDir,
@@ -57,12 +58,64 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
removeSamples: false,
onOutput: (event) => events.push(event)
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
expect(events).toEqual([
expect.objectContaining({
version: 1,
archivePath: path.resolve(zipPath),
entryPath: "episode.txt",
outputPath: path.join(targetDir, "episode.txt"),
state: "complete",
disposition: "written"
})
]);
expect(result.outputFiles).toEqual([path.join(targetDir, "episode.txt")]);
});
it.each(["7zjbinding", "zip4j"])("emits versioned Base64 output lines from %s", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-output-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "release.zip");
const zip = new AdmZip();
zip.addFile("folder/episode.txt", Buffer.from("ok"));
zip.writeZip(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).toBe(0);
const outputLine = String(run.stdout).split(/\r?\n/).find((line) => line.startsWith("RD_OUTPUT "));
expect(outputLine).toBeTruthy();
const fields = String(outputLine).split(" ");
expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]);
expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath));
expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt");
expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt"));
});
it("emits progress callbacks with archiveName and percent", async () => {
+187 -3
View File
@@ -610,7 +610,7 @@ describe("extractor", () => {
expect(selectZipFallbackError(internalError, externalError)).toBe(externalError);
});
it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => {
it.skipIf(process.platform !== "win32")("invalidates legacy basename-only resume state on Windows", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
@@ -630,8 +630,8 @@ describe("extractor", () => {
removeSamples: false
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
});
describe("disk space check", () => {
@@ -1284,4 +1284,188 @@ describe("extractor", () => {
expect(ordered[1]).toBe("UnRAR.exe");
});
});
describe("direct output scope", () => {
it.each([
["overwrite", "new", "overwritten", ["episode.mkv"]],
["ask", "old", "skipped", []],
["rename", "old", "renamed", ["episode (1).mkv"]]
] as const)("emits exact internal ZIP outputs for %s conflicts", async (conflictMode, originalContent, disposition, outputNames) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-direct-${conflictMode}-`));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "episode.mkv"), "old");
const archivePath = path.join(packageDir, "release.zip");
const zip = new AdmZip();
zip.addFile("episode.mkv", Buffer.from("new"));
zip.writeZip(archivePath);
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode,
removeLinks: false,
removeSamples: false,
onOutput: (event) => events.push(event)
});
expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent);
expect(events).toEqual([expect.objectContaining({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: "episode.mkv",
outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"),
state: "complete",
disposition
})]);
expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]);
});
it("extracts only nested archives produced by the package in a shared root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-nested-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "shared");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const foreign = new AdmZip();
foreign.addFile("foreign.txt", Buffer.from("foreign"));
foreign.writeZip(path.join(targetDir, "foreign.zip"));
const nested = new AdmZip();
nested.addFile("owned.txt", Buffer.from("owned"));
const outer = new AdmZip();
outer.addFile("owned.zip", nested.toBuffer());
outer.writeZip(path.join(packageDir, "outer.zip"));
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(2);
expect(fs.existsSync(path.join(targetDir, "owned.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false);
});
it("resumes same-basename archives by relative path and invalidates changed multipart fingerprints", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-v2-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
const firstDir = path.join(packageDir, "first");
const secondDir = path.join(packageDir, "second");
fs.mkdirSync(firstDir, { recursive: true });
fs.mkdirSync(secondDir, { recursive: true });
const firstArchive = path.join(firstDir, "release.zip");
const secondArchive = path.join(secondDir, "release.zip");
const companionPath = path.join(firstDir, "release.sfv");
const firstZip = new AdmZip();
firstZip.addFile("first.txt", Buffer.from("first"));
firstZip.writeZip(firstArchive);
fs.writeFileSync(companionPath, "part-a");
const secondZip = new AdmZip();
secondZip.addFile("second.txt", Buffer.from("second"));
secondZip.writeZip(secondArchive);
const firstResult = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onlyArchives: new Set([path.resolve(firstArchive).toLowerCase()])
});
expect(firstResult.extracted).toBe(1);
expect(firstResult.failed).toBe(0);
expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(true);
fs.writeFileSync(companionPath, "part-b-changed");
const emittedArchives: string[] = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: (event) => emittedArchives.push(event.archivePath)
});
expect(result.extracted).toBe(2);
expect(emittedArchives).toContain(path.resolve(firstArchive));
expect(emittedArchives).toContain(path.resolve(secondArchive));
expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(true);
});
it("fails closed for an unknown resume-state version", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-unknown-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip");
fs.writeFileSync(path.join(targetDir, "foreign.txt"), "foreign");
fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({
version: 99,
completedArchives: ["broken.zip"]
}));
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
});
it("retains concrete completed outputs when a later entry aborts", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const archivePath = path.join(packageDir, "release.zip");
const zip = new AdmZip();
zip.addFile("first.txt", Buffer.from("first"));
zip.addFile("second.txt", Buffer.from("second"));
zip.writeZip(archivePath);
const controller = new AbortController();
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
await expect(extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
signal: controller.signal,
onOutput: (event) => {
events.push(event);
controller.abort();
}
})).rejects.toThrow("aborted:extract");
expect(events).toHaveLength(1);
expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") }));
expect(fs.existsSync(path.join(targetDir, "first.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false);
});
});
});
+159
View File
@@ -0,0 +1,159 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PackageOutputScope } from "../src/main/package-output-scope";
const tempDirs: string[] = [];
function createRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-scope-"));
tempDirs.push(root);
return root;
}
afterEach(() => {
for (const directory of tempDirs.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe("PackageOutputScope", () => {
it("normalizes and deduplicates complete Windows paths case-insensitively", () => {
const root = createRoot();
const outputPath = path.join(root, "Season", "Episode.mkv");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, "video");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive-a.rar"),
entryPath: "Season/Episode.mkv",
outputPath,
state: "complete",
disposition: "written"
});
scope.add({
version: 1,
archivePath: path.join(root, "archive-b.rar"),
entryPath: "season/episode.mkv",
outputPath: outputPath.toUpperCase(),
state: "complete",
disposition: "overwritten"
});
expect(scope.completeFiles()).toEqual([path.resolve(outputPath)]);
expect(scope.partialFiles()).toEqual([]);
expect(scope.records()).toHaveLength(1);
});
it.each([
"../foreign.mkv",
"folder/../../foreign.mkv",
"/absolute.mkv",
"C:\\absolute.mkv"
])("rejects unsafe archive entry path %s", (entryPath) => {
const root = createRoot();
const outputPath = path.join(root, "safe.mkv");
fs.writeFileSync(outputPath, "video");
const scope = new PackageOutputScope([root]);
expect(() => scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath,
outputPath,
state: "complete",
disposition: "written"
})).toThrow(/Ausgabepfad|entry/i);
});
it("rejects outputs outside authorized roots and unknown event versions", () => {
const root = createRoot();
const foreignRoot = createRoot();
const foreignPath = path.join(foreignRoot, "foreign.mkv");
fs.writeFileSync(foreignPath, "foreign");
const scope = new PackageOutputScope([root]);
const event = {
version: 1 as const,
archivePath: path.join(root, "archive.rar"),
entryPath: "foreign.mkv",
outputPath: foreignPath,
state: "complete" as const,
disposition: "written" as const
};
expect(() => scope.add(event)).toThrow(/autorisiert/i);
expect(() => scope.add({ ...event, outputPath: path.join(root, "safe.mkv"), version: 2 as 1 })).toThrow(/Version/i);
});
it("rejects symlinked parent chains and keeps complete and partial outputs separate", () => {
const root = createRoot();
const foreignRoot = createRoot();
const completePath = path.join(root, "complete.mkv");
const partialPath = path.join(root, "partial.mkv");
fs.writeFileSync(completePath, "complete");
fs.writeFileSync(partialPath, "partial");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "complete.mkv",
outputPath: completePath,
state: "complete",
disposition: "written"
});
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "partial.mkv",
outputPath: partialPath,
state: "partial",
disposition: "written"
});
expect(scope.completeFiles()).toEqual([path.resolve(completePath)]);
expect(scope.partialFiles()).toEqual([path.resolve(partialPath)]);
const link = path.join(root, "linked");
try {
fs.symlinkSync(foreignRoot, link, "junction");
} catch {
return;
}
const linkedOutput = path.join(link, "linked.mkv");
fs.writeFileSync(path.join(foreignRoot, "linked.mkv"), "foreign");
expect(() => scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "linked/linked.mkv",
outputPath: linkedOutput,
state: "complete",
disposition: "written"
})).toThrow(/symbol|reparse/i);
});
it("updates ownership after rename and removal without enumerating the root", () => {
const root = createRoot();
const sourcePath = path.join(root, "source.mkv");
const targetPath = path.join(root, "renamed.mkv");
fs.writeFileSync(sourcePath, "video");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "source.mkv",
outputPath: sourcePath,
state: "complete",
disposition: "written"
});
fs.renameSync(sourcePath, targetPath);
scope.replacePath(sourcePath, targetPath);
expect(scope.completeFiles()).toEqual([path.resolve(targetPath)]);
expect(scope.removePath(targetPath)).toBe(true);
expect(scope.completeFiles()).toEqual([]);
});
});