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:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -274,8 +274,10 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long itemUnits = safeSize(header.getUncompressedSize());
|
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) {
|
if (output == null) {
|
||||||
|
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
|
||||||
progress.advance(itemUnits);
|
progress.advance(itemUnits);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -323,12 +325,16 @@ public final class JBindExtractorMain {
|
|||||||
output.setLastModified(modified);
|
output.setLastModified(modified);
|
||||||
}
|
}
|
||||||
extractionSuccess = true;
|
extractionSuccess = true;
|
||||||
|
emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition);
|
||||||
} catch (ZipException error) {
|
} catch (ZipException error) {
|
||||||
if (isWrongPassword(error, encrypted)) {
|
if (isWrongPassword(error, encrypted)) {
|
||||||
throw new WrongPasswordException(error);
|
throw new WrongPasswordException(error);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!extractionSuccess && output.exists()) {
|
||||||
|
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
|
||||||
|
}
|
||||||
if (!extractionSuccess && output.exists()) {
|
if (!extractionSuccess && output.exists()) {
|
||||||
try {
|
try {
|
||||||
output.delete();
|
output.delete();
|
||||||
@@ -371,6 +377,8 @@ public final class JBindExtractorMain {
|
|||||||
List<Integer> fileIndices = new ArrayList<Integer>();
|
List<Integer> fileIndices = new ArrayList<Integer>();
|
||||||
List<File> outputFiles = new ArrayList<File>();
|
List<File> outputFiles = new ArrayList<File>();
|
||||||
List<Long> fileSizes = new ArrayList<Long>();
|
List<Long> fileSizes = new ArrayList<Long>();
|
||||||
|
List<String> entryNames = new ArrayList<String>();
|
||||||
|
List<String> dispositions = new ArrayList<String>();
|
||||||
Set<String> reserved = new HashSet<String>();
|
Set<String> reserved = new HashSet<String>();
|
||||||
|
|
||||||
for (int i = 0; i < itemCount; i++) {
|
for (int i = 0; i < itemCount; i++) {
|
||||||
@@ -396,10 +404,16 @@ public final class JBindExtractorMain {
|
|||||||
long itemSize = safeSize(rawSize);
|
long itemSize = safeSize(rawSize);
|
||||||
totalUnits += itemSize;
|
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);
|
fileIndices.add(i);
|
||||||
outputFiles.add(output);
|
outputFiles.add(output);
|
||||||
fileSizes.add(itemSize);
|
fileSizes.add(itemSize);
|
||||||
|
entryNames.add(entryName);
|
||||||
|
dispositions.add(outputTarget.disposition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileIndices.isEmpty()) {
|
if (fileIndices.isEmpty()) {
|
||||||
@@ -434,7 +448,7 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
archive.extract(indices, false, new BulkExtractCallback(
|
archive.extract(indices, false, new BulkExtractCallback(
|
||||||
archive, indexToPos, fileIndices, outputFiles, fileSizes,
|
archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
|
||||||
progress, encryptedFinal, effectivePassword, currentOutput,
|
progress, encryptedFinal, effectivePassword, currentOutput,
|
||||||
currentStream, currentSuccess, currentRemaining, currentPos, firstError
|
currentStream, currentSuccess, currentRemaining, currentPos, firstError
|
||||||
));
|
));
|
||||||
@@ -566,18 +580,18 @@ public final class JBindExtractorMain {
|
|||||||
return directory;
|
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);
|
File base = secureResolve(targetDir, entryName);
|
||||||
String key = pathKey(base);
|
String key = pathKey(base);
|
||||||
boolean exists = base.exists() || reserved.contains(key);
|
boolean exists = base.exists() || reserved.contains(key);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
reserved.add(key);
|
reserved.add(key);
|
||||||
return base;
|
return new OutputTarget(base, base, "written");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictMode == ConflictMode.SKIP) {
|
if (conflictMode == ConflictMode.SKIP) {
|
||||||
return null;
|
return new OutputTarget(null, base, "skipped");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictMode == ConflictMode.OVERWRITE) {
|
if (conflictMode == ConflictMode.OVERWRITE) {
|
||||||
@@ -585,7 +599,7 @@ public final class JBindExtractorMain {
|
|||||||
deleteRecursively(base);
|
deleteRecursively(base);
|
||||||
}
|
}
|
||||||
reserved.add(key);
|
reserved.add(key);
|
||||||
return base;
|
return new OutputTarget(base, base, "overwritten");
|
||||||
}
|
}
|
||||||
|
|
||||||
File parent = base.getParentFile();
|
File parent = base.getParentFile();
|
||||||
@@ -601,7 +615,7 @@ public final class JBindExtractorMain {
|
|||||||
String candidateKey = pathKey(candidate);
|
String candidateKey = pathKey(candidate);
|
||||||
if (!candidate.exists() && !reserved.contains(candidateKey)) {
|
if (!candidate.exists() && !reserved.contains(candidateKey)) {
|
||||||
reserved.add(candidateKey);
|
reserved.add(candidateKey);
|
||||||
return candidate;
|
return new OutputTarget(candidate, candidate, "renamed");
|
||||||
}
|
}
|
||||||
counter += 1;
|
counter += 1;
|
||||||
}
|
}
|
||||||
@@ -820,6 +834,20 @@ public final class JBindExtractorMain {
|
|||||||
System.err.println("RD_ERROR " + message);
|
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 {
|
private enum Backend {
|
||||||
AUTO("auto"),
|
AUTO("auto"),
|
||||||
SEVENZIPJBIND("7zjbinding"),
|
SEVENZIPJBIND("7zjbinding"),
|
||||||
@@ -874,12 +902,27 @@ public final class JBindExtractorMain {
|
|||||||
private final List<String> passwords = new ArrayList<String>();
|
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 static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
|
||||||
private final IInArchive archive;
|
private final IInArchive archive;
|
||||||
|
private final File archiveFile;
|
||||||
private final Map<Integer, Integer> indexToPos;
|
private final Map<Integer, Integer> indexToPos;
|
||||||
private final List<Integer> fileIndices;
|
private final List<Integer> fileIndices;
|
||||||
private final List<File> outputFiles;
|
private final List<File> outputFiles;
|
||||||
private final List<Long> fileSizes;
|
private final List<Long> fileSizes;
|
||||||
|
private final List<String> entryNames;
|
||||||
|
private final List<String> dispositions;
|
||||||
private final ProgressTracker progress;
|
private final ProgressTracker progress;
|
||||||
private final boolean encrypted;
|
private final boolean encrypted;
|
||||||
private final String password;
|
private final String password;
|
||||||
@@ -890,17 +933,21 @@ public final class JBindExtractorMain {
|
|||||||
private final int[] currentPos;
|
private final int[] currentPos;
|
||||||
private final Throwable[] firstError;
|
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<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
|
||||||
|
List<String> entryNames, List<String> dispositions,
|
||||||
ProgressTracker progress, boolean encrypted, String password,
|
ProgressTracker progress, boolean encrypted, String password,
|
||||||
File[] currentOutput, FileOutputStream[] currentStream,
|
File[] currentOutput, FileOutputStream[] currentStream,
|
||||||
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
||||||
Throwable[] firstError) {
|
Throwable[] firstError) {
|
||||||
this.archive = archive;
|
this.archive = archive;
|
||||||
|
this.archiveFile = archiveFile;
|
||||||
this.indexToPos = indexToPos;
|
this.indexToPos = indexToPos;
|
||||||
this.fileIndices = fileIndices;
|
this.fileIndices = fileIndices;
|
||||||
this.outputFiles = outputFiles;
|
this.outputFiles = outputFiles;
|
||||||
this.fileSizes = fileSizes;
|
this.fileSizes = fileSizes;
|
||||||
|
this.entryNames = entryNames;
|
||||||
|
this.dispositions = dispositions;
|
||||||
this.progress = progress;
|
this.progress = progress;
|
||||||
this.encrypted = encrypted;
|
this.encrypted = encrypted;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
@@ -1002,10 +1049,12 @@ public final class JBindExtractorMain {
|
|||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "complete", dispositions.get(currentPos[0]));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
closeCurrentStream();
|
closeCurrentStream();
|
||||||
if (currentOutput[0] != null && currentOutput[0].exists()) {
|
if (currentOutput[0] != null && currentOutput[0].exists()) {
|
||||||
|
emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "partial", dispositions.get(currentPos[0]));
|
||||||
try {
|
try {
|
||||||
currentOutput[0].delete();
|
currentOutput[0].delete();
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
|
|||||||
+401
-73
@@ -6,6 +6,9 @@ import AdmZip from "adm-zip";
|
|||||||
import { CleanupMode, ConflictMode } from "../shared/types";
|
import { CleanupMode, ConflictMode } from "../shared/types";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
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";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ export interface ExtractOptions {
|
|||||||
extractCpuPriority?: string;
|
extractCpuPriority?: string;
|
||||||
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
|
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
|
||||||
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
|
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractProgressUpdate {
|
export interface ExtractProgressUpdate {
|
||||||
@@ -142,6 +146,13 @@ type JvmExtractResult = {
|
|||||||
backend: string;
|
backend: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ExtractResult {
|
||||||
|
extracted: number;
|
||||||
|
failed: number;
|
||||||
|
lastError: string;
|
||||||
|
outputFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
type ExtractSpawnResult = {
|
type ExtractSpawnResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
missingCommand: boolean;
|
missingCommand: boolean;
|
||||||
@@ -150,8 +161,27 @@ type ExtractSpawnResult = {
|
|||||||
errorText: string;
|
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 = {
|
type ExtractResumeState = {
|
||||||
completedArchives: string[];
|
version: 2;
|
||||||
|
archives: ExtractResumeArchive[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExtractorCommandKind = "rar_native" | "seven_zip" | "other";
|
type ExtractorCommandKind = "rar_native" | "seven_zip" | "other";
|
||||||
@@ -167,6 +197,7 @@ interface DaemonRequest {
|
|||||||
archiveName: string;
|
archiveName: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
passwordCount: number;
|
passwordCount: number;
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSubstDrives = new Set<string>();
|
const activeSubstDrives = new Set<string>();
|
||||||
@@ -247,10 +278,6 @@ export function pathSetKey(filePath: string): string {
|
|||||||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
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 {
|
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
|
||||||
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
||||||
}
|
}
|
||||||
@@ -327,12 +354,28 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
|||||||
}
|
}
|
||||||
|
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
|
const stack = [packageDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
files = (await fs.promises.readdir(packageDir, { withFileTypes: true }))
|
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||||
.filter((entry) => entry.isFile())
|
|
||||||
.map((entry) => path.join(packageDir, entry.name));
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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 fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase()));
|
||||||
@@ -1513,7 +1556,8 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
|
|||||||
function parseJvmLine(
|
function parseJvmLine(
|
||||||
line: string,
|
line: string,
|
||||||
onArchiveProgress: ((percent: number) => void) | undefined,
|
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 {
|
): void {
|
||||||
const trimmed = String(line || "").trim();
|
const trimmed = String(line || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -1547,6 +1591,27 @@ function parseJvmLine(
|
|||||||
return;
|
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 ")) {
|
if (trimmed.startsWith("RD_ERROR ")) {
|
||||||
state.reportedError = trimmed.slice("RD_ERROR ".length).trim();
|
state.reportedError = trimmed.slice("RD_ERROR ".length).trim();
|
||||||
}
|
}
|
||||||
@@ -1601,11 +1666,11 @@ function flushDaemonParseBuffers(req: DaemonRequest | null): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (daemonStdoutBuffer.trim()) {
|
if (daemonStdoutBuffer.trim()) {
|
||||||
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState);
|
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||||
daemonStdoutBuffer = "";
|
daemonStdoutBuffer = "";
|
||||||
}
|
}
|
||||||
if (daemonStderrBuffer.trim()) {
|
if (daemonStderrBuffer.trim()) {
|
||||||
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState);
|
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||||
daemonStderrBuffer = "";
|
daemonStderrBuffer = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1663,7 +1728,7 @@ function handleDaemonLine(line: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (daemonCurrentRequest) {
|
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() || "";
|
daemonStderrBuffer = lines.pop() || "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (daemonCurrentRequest) {
|
if (daemonCurrentRequest) {
|
||||||
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
|
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1785,7 +1850,8 @@ function sendDaemonRequest(
|
|||||||
passwordCandidates: string[],
|
passwordCandidates: string[],
|
||||||
onArchiveProgress?: (percent: number) => void,
|
onArchiveProgress?: (percent: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
timeoutMs?: number
|
timeoutMs?: number,
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void
|
||||||
): Promise<JvmExtractResult> {
|
): Promise<JvmExtractResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const mode = effectiveConflictMode(conflictMode);
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
@@ -1802,7 +1868,8 @@ function sendDaemonRequest(
|
|||||||
parseState,
|
parseState,
|
||||||
archiveName,
|
archiveName,
|
||||||
startedAt: Date.now(),
|
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}`);
|
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[],
|
passwordCandidates: string[],
|
||||||
onArchiveProgress?: (percent: number) => void,
|
onArchiveProgress?: (percent: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
timeoutMs?: number
|
timeoutMs?: number,
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void
|
||||||
): Promise<JvmExtractResult> {
|
): Promise<JvmExtractResult> {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -1879,7 +1947,7 @@ async function runJvmExtractCommand(
|
|||||||
if (isDaemonAvailable(layout)) {
|
if (isDaemonAvailable(layout)) {
|
||||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
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) {
|
if (daemonProcess) {
|
||||||
@@ -1891,7 +1959,7 @@ async function runJvmExtractCommand(
|
|||||||
if (ready) {
|
if (ready) {
|
||||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||||
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
|
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)}`);
|
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 lines = nextBuffer.split(/\r?\n/);
|
||||||
const keep = lines.pop() || "";
|
const keep = lines.pop() || "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
parseJvmLine(line, onArchiveProgress, parseState);
|
parseJvmLine(line, onArchiveProgress, parseState, onOutput);
|
||||||
}
|
}
|
||||||
if (fromStdErr) {
|
if (fromStdErr) {
|
||||||
stderrBuffer = keep;
|
stderrBuffer = keep;
|
||||||
@@ -2022,8 +2090,8 @@ async function runJvmExtractCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState);
|
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput);
|
||||||
parseJvmLine(stderrBuffer, onArchiveProgress, parseState);
|
parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput);
|
||||||
|
|
||||||
if (abortedBySignal) {
|
if (abortedBySignal) {
|
||||||
finish({
|
finish({
|
||||||
@@ -2105,7 +2173,8 @@ async function runExternalExtractInner(
|
|||||||
onPasswordAttempt?: (attempt: number, total: number) => void,
|
onPasswordAttempt?: (attempt: number, total: number) => void,
|
||||||
forceFlatMode = false,
|
forceFlatMode = false,
|
||||||
flatModeResult?: { needed: boolean },
|
flatModeResult?: { needed: boolean },
|
||||||
onLog?: ExtractOptions["onLog"]
|
onLog?: ExtractOptions["onLog"],
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const passwords = passwordCandidates;
|
const passwords = passwordCandidates;
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
@@ -2284,7 +2353,8 @@ async function runExternalExtract(
|
|||||||
onPasswordAttempt?: (attempt: number, total: number) => void,
|
onPasswordAttempt?: (attempt: number, total: number) => void,
|
||||||
forceFlatMode = false,
|
forceFlatMode = false,
|
||||||
flatModeResult?: { needed: boolean },
|
flatModeResult?: { needed: boolean },
|
||||||
onLog?: ExtractOptions["onLog"]
|
onLog?: ExtractOptions["onLog"],
|
||||||
|
onOutput?: (event: ExtractOutputEvent) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
||||||
const configuredBackendMode = extractorBackendMode();
|
const configuredBackendMode = extractorBackendMode();
|
||||||
@@ -2317,7 +2387,7 @@ async function runExternalExtract(
|
|||||||
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
|
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
|
||||||
const jvmResult = await runJvmExtractCommand(
|
const jvmResult = await runJvmExtractCommand(
|
||||||
layout, archivePath, targetDir, conflictMode, passwordCandidates,
|
layout, archivePath, targetDir, conflictMode, passwordCandidates,
|
||||||
onArchiveProgress, signal, timeoutMs
|
onArchiveProgress, signal, timeoutMs, onOutput
|
||||||
);
|
);
|
||||||
const jvmMs = Date.now() - jvmStartedAt;
|
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"}`);
|
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,
|
passwordCandidates,
|
||||||
onArchiveProgress,
|
onArchiveProgress,
|
||||||
signal,
|
signal,
|
||||||
timeoutMs
|
timeoutMs,
|
||||||
|
onOutput
|
||||||
);
|
);
|
||||||
const jvmMs = Date.now() - jvmStartedAt;
|
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"}`);
|
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;
|
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 mode = effectiveConflictMode(conflictMode);
|
||||||
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
||||||
const zip = new AdmZip(archivePath);
|
const zip = new AdmZip(archivePath);
|
||||||
@@ -2633,11 +2710,20 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
|
|
||||||
let outputPath = baseOutputPath;
|
let outputPath = baseOutputPath;
|
||||||
let outputKey = pathSetKey(outputPath);
|
let outputKey = pathSetKey(outputPath);
|
||||||
|
let disposition: ExtractOutputEvent["disposition"] = "written";
|
||||||
|
|
||||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
||||||
if (outputExists) {
|
if (outputExists) {
|
||||||
if (mode === "skip") {
|
if (mode === "skip") {
|
||||||
|
onOutput?.({
|
||||||
|
version: 1,
|
||||||
|
archivePath: path.resolve(archivePath),
|
||||||
|
entryPath: entry.entryName.replace(/\\/g, "/"),
|
||||||
|
outputPath: baseOutputPath,
|
||||||
|
state: "complete",
|
||||||
|
disposition: "skipped"
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (mode === "rename") {
|
if (mode === "rename") {
|
||||||
@@ -2663,6 +2749,9 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
}
|
}
|
||||||
outputPath = candidate;
|
outputPath = candidate;
|
||||||
outputKey = candidateKey;
|
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) {
|
if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) {
|
||||||
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
|
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await fs.promises.writeFile(outputPath, data);
|
await fs.promises.writeFile(outputPath, data);
|
||||||
usedOutputs.add(outputKey);
|
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);
|
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);
|
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(progressPath);
|
await fs.promises.access(progressPath);
|
||||||
} catch {
|
} catch {
|
||||||
return new Set<string>();
|
return new Map<string, ExtractResumeArchive>();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>;
|
const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>;
|
||||||
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
|
if (payload.version !== 2 || !Array.isArray(payload.archives)) {
|
||||||
return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean));
|
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 {
|
} 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 {
|
try {
|
||||||
await fs.promises.mkdir(packageDir, { recursive: true });
|
await fs.promises.mkdir(packageDir, { recursive: true });
|
||||||
const progressPath = extractProgressFilePath(packageDir, packageId);
|
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||||||
const payload: ExtractResumeState = {
|
const payload: ExtractResumeState = {
|
||||||
completedArchives: Array.from(completedArchives)
|
version: 2,
|
||||||
.map((name) => archiveNameKey(name))
|
archives: [...completedArchives.values()].sort((left, right) => left.archivePath.localeCompare(right.archivePath))
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
};
|
};
|
||||||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
||||||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
@@ -2841,10 +3057,15 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
|
|||||||
return "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) {
|
if (options.signal?.aborted) {
|
||||||
throw new Error("aborted:extract");
|
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" });
|
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
|
||||||
const allCandidates = await findArchiveCandidates(options.packageDir);
|
const allCandidates = await findArchiveCandidates(options.packageDir);
|
||||||
const candidates = options.onlyArchives
|
const candidates = options.onlyArchives
|
||||||
@@ -2866,8 +3087,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
if (!options.onlyArchives) {
|
if (!options.onlyArchives) {
|
||||||
const existingResume = await readExtractResumeState(options.packageDir, options.packageId);
|
const existingResume = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId);
|
||||||
if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) {
|
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);
|
await clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
|
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
|
||||||
options.onProgress?.({
|
options.onProgress?.({
|
||||||
@@ -2877,12 +3115,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
archiveName: "",
|
archiveName: "",
|
||||||
phase: "done"
|
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);
|
await clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
}
|
}
|
||||||
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
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);
|
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`);
|
logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||||||
options.onLog?.("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 resumeCompletedAtStart = resumeCompleted.size;
|
||||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
const resumedArchivePaths = new Set<string>();
|
||||||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
for (const archivePath of candidates) {
|
||||||
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
|
const identity = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir);
|
||||||
// disk to validate against, so it must NOT be pruned here — otherwise every
|
const saved = identity ? resumeCompleted.get(identity.toLocaleLowerCase("en-US")) : undefined;
|
||||||
// extractPackageArchives call wiped it and nested archives were re-extracted on
|
if (!identity || !saved) {
|
||||||
// resume. It is cleared together with the rest once the package fully completes.
|
|
||||||
if (archiveName.startsWith("nested:")) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!allCandidateNames.has(archiveName)) {
|
const currentFingerprint = await buildArchiveFingerprint(archivePath, options.packageDir, options.targetDir);
|
||||||
resumeCompleted.delete(archiveName);
|
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) {
|
if (resumeCompleted.size > 0) {
|
||||||
@@ -2919,7 +3171,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
await clearExtractResumeState(options.packageDir, options.packageId);
|
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 extracted = candidates.length - pendingCandidates.length;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
@@ -2929,7 +3181,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const skippedNonArchives = new Set<string>();
|
const skippedNonArchives = new Set<string>();
|
||||||
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
if (resumedArchivePaths.has(pathSetKey(archivePath))) {
|
||||||
const resumedName = path.basename(archivePath);
|
const resumedName = path.basename(archivePath);
|
||||||
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
|
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
|
||||||
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
|
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
|
||||||
@@ -2999,7 +3251,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
emitProgress(extracted, "", "extracting");
|
emitProgress(extracted, "", "extracting");
|
||||||
|
|
||||||
for (const archivePath of candidates) {
|
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);
|
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");
|
throw new Error("noextractor:skipped");
|
||||||
}
|
}
|
||||||
const archiveName = path.basename(archivePath);
|
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 archiveStartedAt = Date.now();
|
||||||
const startedCurrent = extracted + failed;
|
const startedCurrent = extracted + failed;
|
||||||
if (lastArchiveFinishedAt !== null) {
|
if (lastArchiveFinishedAt !== null) {
|
||||||
@@ -3053,9 +3305,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (!sig) {
|
if (!sig) {
|
||||||
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
resumeCompleted.add(archiveResumeKey);
|
|
||||||
skippedNonArchives.add(pathSetKey(archivePath));
|
skippedNonArchives.add(pathSetKey(archivePath));
|
||||||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
|
||||||
clearInterval(pulseTimer);
|
clearInterval(pulseTimer);
|
||||||
archiveOutcome = "skipped";
|
archiveOutcome = "skipped";
|
||||||
const skippedAt = Date.now();
|
const skippedAt = Date.now();
|
||||||
@@ -3089,18 +3339,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
try {
|
try {
|
||||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||||
reportArchiveProgress(value);
|
reportArchiveProgress(value);
|
||||||
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
|
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
|
||||||
rememberLearnedPassword(usedPassword);
|
rememberLearnedPassword(usedPassword);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNoExtractorError(String(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 {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
|
||||||
archivePercent = 100;
|
archivePercent = 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!shouldFallbackToExternalZip(error)) {
|
if (!shouldFallbackToExternalZip(error)) {
|
||||||
@@ -3109,7 +3359,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
try {
|
try {
|
||||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||||
reportArchiveProgress(value);
|
reportArchiveProgress(value);
|
||||||
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
|
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
|
||||||
rememberLearnedPassword(usedPassword);
|
rememberLearnedPassword(usedPassword);
|
||||||
} catch (externalError) {
|
} catch (externalError) {
|
||||||
throw selectZipFallbackError(error, externalError);
|
throw selectZipFallbackError(error, externalError);
|
||||||
@@ -3120,15 +3370,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const flatResult = { needed: false };
|
const flatResult = { needed: false };
|
||||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||||
reportArchiveProgress(value);
|
reportArchiveProgress(value);
|
||||||
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog);
|
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput);
|
||||||
rememberLearnedPassword(usedPassword);
|
rememberLearnedPassword(usedPassword);
|
||||||
if (flatResult.needed) packageNeedsFlatMode = true;
|
if (flatResult.needed) packageNeedsFlatMode = true;
|
||||||
}
|
}
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
extractedArchives.add(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
failedArchiveCategories.delete(archivePath);
|
failedArchiveCategories.delete(archivePath);
|
||||||
resumeCompleted.add(archiveResumeKey);
|
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);
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
|
}
|
||||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||||
options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||||
archiveOutcome = "success";
|
archiveOutcome = "success";
|
||||||
@@ -3248,7 +3501,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (abortError) throw new Error("aborted:extract");
|
if (abortError) throw new Error("aborted:extract");
|
||||||
|
|
||||||
if (failed > 0 && extracted === 0) {
|
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");
|
const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown");
|
||||||
if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) {
|
if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) {
|
||||||
const categorySummary = [...new Set(failedCategories)].join(",");
|
const categorySummary = [...new Set(failedCategories)].join(",");
|
||||||
@@ -3275,7 +3528,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failed > 0 && extracted > 0) {
|
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) {
|
if (failedArchives.length > 0) {
|
||||||
logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`);
|
logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`);
|
||||||
let retryRecovered = 0;
|
let retryRecovered = 0;
|
||||||
@@ -3308,7 +3561,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
||||||
try {
|
try {
|
||||||
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
const nestedCandidates = outputScope.archiveFiles()
|
||||||
.filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p));
|
.filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p));
|
||||||
if (nestedCandidates.length > 0) {
|
if (nestedCandidates.length > 0) {
|
||||||
logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`);
|
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) {
|
for (const nestedArchive of nestedCandidates) {
|
||||||
if (options.signal?.aborted) throw new Error("aborted:extract");
|
if (options.signal?.aborted) throw new Error("aborted:extract");
|
||||||
const nestedName = path.basename(nestedArchive);
|
const nestedName = path.basename(nestedArchive);
|
||||||
const nestedKey = archiveNameKey(`nested:${nestedName}`);
|
const nestedIdentity = archiveResumeIdentity(nestedArchive, options.packageDir, options.targetDir);
|
||||||
if (resumeCompleted.has(nestedKey)) {
|
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}`);
|
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3340,22 +3611,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const ext = path.extname(nestedArchive).toLowerCase();
|
const ext = path.extname(nestedArchive).toLowerCase();
|
||||||
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
|
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
|
||||||
try {
|
try {
|
||||||
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal);
|
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput);
|
||||||
nestedPercent = 100;
|
nestedPercent = 100;
|
||||||
} catch (zipErr) {
|
} catch (zipErr) {
|
||||||
if (!shouldFallbackToExternalZip(zipErr)) throw 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);
|
rememberLearnedPassword(usedPw);
|
||||||
}
|
}
|
||||||
} else {
|
} 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);
|
rememberLearnedPassword(usedPw);
|
||||||
}
|
}
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
nestedExtracted += 1;
|
nestedExtracted += 1;
|
||||||
extractedArchives.add(nestedArchive);
|
extractedArchives.add(nestedArchive);
|
||||||
resumeCompleted.add(nestedKey);
|
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);
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
|
}
|
||||||
logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`);
|
logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`);
|
||||||
if (options.cleanupMode !== "none") {
|
if (options.cleanupMode !== "none") {
|
||||||
await cleanupArchives([nestedArchive], options.cleanupMode);
|
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}`);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile("episode.txt", Buffer.from("ok"));
|
zip.addFile("episode.txt", Buffer.from("ok"));
|
||||||
zip.writeZip(zipPath);
|
zip.writeZip(zipPath);
|
||||||
|
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
|
||||||
|
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir,
|
packageDir,
|
||||||
@@ -57,12 +58,64 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
cleanupMode: "none",
|
cleanupMode: "none",
|
||||||
conflictMode: "overwrite",
|
conflictMode: "overwrite",
|
||||||
removeLinks: false,
|
removeLinks: false,
|
||||||
removeSamples: false
|
removeSamples: false,
|
||||||
|
onOutput: (event) => events.push(event)
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.extracted).toBe(1);
|
expect(result.extracted).toBe(1);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
|
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 () => {
|
it("emits progress callbacks with archiveName and percent", async () => {
|
||||||
|
|||||||
+187
-3
@@ -610,7 +610,7 @@ describe("extractor", () => {
|
|||||||
expect(selectZipFallbackError(internalError, externalError)).toBe(externalError);
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const packageDir = path.join(root, "pkg");
|
const packageDir = path.join(root, "pkg");
|
||||||
@@ -630,8 +630,8 @@ describe("extractor", () => {
|
|||||||
removeSamples: false
|
removeSamples: false
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.extracted).toBe(1);
|
expect(result.extracted).toBe(0);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("disk space check", () => {
|
describe("disk space check", () => {
|
||||||
@@ -1284,4 +1284,188 @@ describe("extractor", () => {
|
|||||||
expect(ordered[1]).toBe("UnRAR.exe");
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user