release: harden archive recovery and lifecycle for v2.0.63
Corroborate CRC failures across extraction backends, retry only implicated multipart volumes, and distinguish corruption, missing volumes, I/O failures, and wrong passwords across native, Zip4j, and JBinding paths. Make disk retries generation-safe, preserve selective run scopes and cooldowns, protect shared output files during cleanup, validate manual extraction batches atomically, and restore interrupted integrity work safely. Prioritize active package operations in the UI, strengthen extraction IPC validation, compile the JVM sidecar before release builds, and verify shipped JVM resources byte-for-byte in every Windows artifact.
This commit is contained in:
@@ -2,6 +2,50 @@
|
||||
|
||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [2.0.63] - 2026-08-23
|
||||
|
||||
### Archive recovery and password handling
|
||||
|
||||
- Detect English, German, and misdecoded CRC and checksum failures from native and JVM extractors.
|
||||
- Corroborate archive corruption across independent extraction backends and re-download only the named damaged multipart volume once per package generation.
|
||||
- Keep encrypted archives and genuine wrong-password results out of destructive re-download recovery.
|
||||
- Try every real RAR5 password candidate even when archive metadata does not reliably report encryption.
|
||||
- Distinguish corrupted encrypted RAR and ZIP data, missing volumes, locked files, and open failures from incorrect passwords.
|
||||
- Remove redundant full password passes and bound package recovery to one deduplicated retry set.
|
||||
- Report password-attempt progress without exposing candidate values and isolate failing progress observers from the extraction process.
|
||||
- Parse JVM daemon requests safely for passwords containing JSON metacharacters and Unicode.
|
||||
- Remove successful-password payloads from the JVM protocol and redact diagnostic buffers before any error can reach logs.
|
||||
|
||||
### Extraction lifecycle and data safety
|
||||
|
||||
- Resume extraction automatically after temporary disk-capacity waits, including manual archive selections, with generation-, owner-, and timer-safe retry plans.
|
||||
- Keep Stop, shutdown, disabled packages, stale callbacks, and changed run owners from restarting old extraction work.
|
||||
- Preserve retry, pacing, disk, and provider cooldowns across Pause and Resume.
|
||||
- Make standalone manual extraction stoppable through the normal toolbar lifecycle.
|
||||
- Reject missing, incomplete, ambiguous, or non-archive manual targets before starting any selected batch.
|
||||
- Resolve opaque archive files by signature without mutating files during preflight and commit valid batch plans atomically.
|
||||
- Keep full-package cleanup enabled for complete manual extraction while protecting partial selections and packages with open downloads.
|
||||
- Convert unexpected post-processing exceptions into deterministic extraction failures instead of successful package results.
|
||||
- Requeue interrupted integrity checks after restart and clear stale transient post-processing labels.
|
||||
|
||||
### Cleanup and queue isolation
|
||||
|
||||
- Preserve independent downloads that happen to share the same package filename during startup recovery.
|
||||
- Honor recycle-bin cleanup for previously extracted archives instead of deleting them permanently.
|
||||
- Protect files owned by other packages in shared output folders, including archive companions, and verify file identity immediately before cleanup.
|
||||
- Reset a corrupted download only after its old file was successfully removed or quarantined.
|
||||
- Keep selected download runs scoped when packages are disabled, added later, or waiting for unrelated extraction work.
|
||||
- Prevent selective starts from launching or stopping post-processing for other packages.
|
||||
|
||||
### Status, IPC, and release integrity
|
||||
|
||||
- Prioritize active downloads, integrity checks, extraction, password search, finalization, cleanup, and disk waits over historical sibling errors while retaining full details in tooltips.
|
||||
- Keep pending, waiting-for-parts, active extraction, and historical failure states visually distinct after restart.
|
||||
- Translate the complete dynamic extraction status set in German and English.
|
||||
- Validate manual extraction IPC requests in a side-effect-free trusted handler and propagate stale or invalid selections as actionable errors.
|
||||
- Compile the bundled JVM extractor before every main and Windows release build and reject stale source/class combinations.
|
||||
- Verify every shipped JVM class, source digest, and library byte-for-byte in unpacked, Setup, and Portable release artifacts.
|
||||
|
||||
## [2.0.62] - 2026-08-23
|
||||
|
||||
### Manual extraction
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.62",
|
||||
"version": "2.0.63",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.62",
|
||||
"version": "2.0.63",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-debrid-downloader",
|
||||
"version": "2.0.62",
|
||||
"version": "2.0.63",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
@@ -12,7 +12,8 @@
|
||||
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
||||
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||
"build:extractor-jvm": "node scripts/build-extractor-jvm.mjs",
|
||||
"build:main": "npm run build:extractor-jvm && tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||
"build:renderer": "vite build",
|
||||
"start": "cross-env NODE_ENV=production electron .",
|
||||
"test": "npm run test:client && npm run test:backup-api",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
77818e292a3c70c1cf1cf8ab12f02e8e05e0e9a5e4140108465d62cf18ecf30f
|
||||
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.
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.
@@ -3,6 +3,7 @@ package com.sucukdeluxe.extractor;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.exception.ZipException;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
import net.lingala.zip4j.model.enums.EncryptionMethod;
|
||||
import net.sf.sevenzipjbinding.ExtractAskMode;
|
||||
import net.sf.sevenzipjbinding.ExtractOperationResult;
|
||||
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
|
||||
@@ -107,34 +108,28 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
|
||||
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
|
||||
|
||||
Map<String, Object> values = new DaemonJsonParser(jsonLine).parseObject();
|
||||
ExtractionRequest request = new ExtractionRequest();
|
||||
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
|
||||
request.targetDir = new File(extractJsonString(jsonLine, "target"));
|
||||
String conflict = extractJsonString(jsonLine, "conflict");
|
||||
request.archiveFile = new File(requireJsonString(values, "archive"));
|
||||
request.targetDir = new File(requireJsonString(values, "target"));
|
||||
String conflict = optionalJsonString(values, "conflict");
|
||||
if (conflict.length() > 0) {
|
||||
request.conflictMode = ConflictMode.fromValue(conflict);
|
||||
}
|
||||
String backend = extractJsonString(jsonLine, "backend");
|
||||
String backend = optionalJsonString(values, "backend");
|
||||
if (backend.length() > 0) {
|
||||
request.backend = Backend.fromValue(backend);
|
||||
}
|
||||
|
||||
int pwStart = jsonLine.indexOf("\"passwords\"");
|
||||
if (pwStart >= 0) {
|
||||
int arrStart = jsonLine.indexOf('[', pwStart);
|
||||
int arrEnd = jsonLine.indexOf(']', arrStart);
|
||||
if (arrStart >= 0 && arrEnd > arrStart) {
|
||||
String arrContent = jsonLine.substring(arrStart + 1, arrEnd);
|
||||
int idx = 0;
|
||||
while (idx < arrContent.length()) {
|
||||
int qStart = arrContent.indexOf('"', idx);
|
||||
if (qStart < 0) break;
|
||||
int qEnd = findClosingQuote(arrContent, qStart + 1);
|
||||
if (qEnd < 0) break;
|
||||
request.passwords.add(unescapeJsonString(arrContent.substring(qStart + 1, qEnd)));
|
||||
idx = qEnd + 1;
|
||||
Object rawPasswords = values.get("passwords");
|
||||
if (rawPasswords != null) {
|
||||
if (!(rawPasswords instanceof List<?>)) {
|
||||
throw new IllegalArgumentException("Daemon-Feld passwords muss ein String-Array sein");
|
||||
}
|
||||
for (Object value : (List<?>) rawPasswords) {
|
||||
if (!(value instanceof String)) {
|
||||
throw new IllegalArgumentException("Daemon-Feld passwords muss nur Strings enthalten");
|
||||
}
|
||||
request.passwords.add((String) value);
|
||||
}
|
||||
}
|
||||
if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) {
|
||||
@@ -147,73 +142,198 @@ public final class JBindExtractorMain {
|
||||
return request;
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
String search = "\"" + key + "\"";
|
||||
int keyIdx = json.indexOf(search);
|
||||
if (keyIdx < 0) return "";
|
||||
int colonIdx = json.indexOf(':', keyIdx + search.length());
|
||||
if (colonIdx < 0) return "";
|
||||
int qStart = json.indexOf('"', colonIdx + 1);
|
||||
if (qStart < 0) return "";
|
||||
int qEnd = findClosingQuote(json, qStart + 1);
|
||||
if (qEnd < 0) return "";
|
||||
return unescapeJsonString(json.substring(qStart + 1, qEnd));
|
||||
}
|
||||
|
||||
private static int findClosingQuote(String s, int from) {
|
||||
for (int i = from; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\\') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') return i;
|
||||
private static String requireJsonString(Map<String, Object> values, String key) {
|
||||
String value = optionalJsonString(values, key);
|
||||
if (value.length() == 0) {
|
||||
throw new IllegalArgumentException("Daemon-Feld fehlt: " + key);
|
||||
}
|
||||
return -1;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String unescapeJsonString(String s) {
|
||||
if (s.indexOf('\\') < 0) return s;
|
||||
StringBuilder sb = new StringBuilder(s.length());
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\\' && i + 1 < s.length()) {
|
||||
char next = s.charAt(i + 1);
|
||||
switch (next) {
|
||||
case '"': sb.append('"'); i++; break;
|
||||
case '\\': sb.append('\\'); i++; break;
|
||||
case '/': sb.append('/'); i++; break;
|
||||
case 'n': sb.append('\n'); i++; break;
|
||||
case 'r': sb.append('\r'); i++; break;
|
||||
case 't': sb.append('\t'); i++; break;
|
||||
default: sb.append(c); break;
|
||||
private static String optionalJsonString(Map<String, Object> values, String key) {
|
||||
Object value = values.get(key);
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (!(value instanceof String)) {
|
||||
throw new IllegalArgumentException("Daemon-Feld muss ein String sein: " + key);
|
||||
}
|
||||
return (String) value;
|
||||
}
|
||||
|
||||
private static final class DaemonJsonParser {
|
||||
private final String input;
|
||||
private int index;
|
||||
|
||||
DaemonJsonParser(String input) {
|
||||
this.input = input == null ? "" : input;
|
||||
}
|
||||
|
||||
Map<String, Object> parseObject() {
|
||||
skipWhitespace();
|
||||
expect('{');
|
||||
Map<String, Object> values = new HashMap<String, Object>();
|
||||
skipWhitespace();
|
||||
if (consume('}')) {
|
||||
requireEnd();
|
||||
return values;
|
||||
}
|
||||
while (true) {
|
||||
String key = parseString();
|
||||
skipWhitespace();
|
||||
expect(':');
|
||||
skipWhitespace();
|
||||
Object value = peek('"') ? parseString() : parseStringArray();
|
||||
if (values.containsKey(key)) {
|
||||
throw malformed();
|
||||
}
|
||||
} else {
|
||||
sb.append(c);
|
||||
values.put(key, value);
|
||||
skipWhitespace();
|
||||
if (consume('}')) {
|
||||
requireEnd();
|
||||
return values;
|
||||
}
|
||||
expect(',');
|
||||
skipWhitespace();
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
|
||||
private List<String> parseStringArray() {
|
||||
expect('[');
|
||||
List<String> values = new ArrayList<String>();
|
||||
skipWhitespace();
|
||||
if (consume(']')) {
|
||||
return values;
|
||||
}
|
||||
while (true) {
|
||||
values.add(parseString());
|
||||
skipWhitespace();
|
||||
if (consume(']')) {
|
||||
return values;
|
||||
}
|
||||
expect(',');
|
||||
skipWhitespace();
|
||||
}
|
||||
}
|
||||
|
||||
private String parseString() {
|
||||
expect('"');
|
||||
StringBuilder value = new StringBuilder();
|
||||
while (index < input.length()) {
|
||||
char current = input.charAt(index++);
|
||||
if (current == '"') {
|
||||
return value.toString();
|
||||
}
|
||||
if (current < 0x20) {
|
||||
throw malformed();
|
||||
}
|
||||
if (current != '\\') {
|
||||
value.append(current);
|
||||
continue;
|
||||
}
|
||||
if (index >= input.length()) {
|
||||
throw malformed();
|
||||
}
|
||||
char escaped = input.charAt(index++);
|
||||
switch (escaped) {
|
||||
case '"': value.append('"'); break;
|
||||
case '\\': value.append('\\'); break;
|
||||
case '/': value.append('/'); break;
|
||||
case 'b': value.append('\b'); break;
|
||||
case 'f': value.append('\f'); break;
|
||||
case 'n': value.append('\n'); break;
|
||||
case 'r': value.append('\r'); break;
|
||||
case 't': value.append('\t'); break;
|
||||
case 'u': value.append(parseUnicodeEscape()); break;
|
||||
default: throw malformed();
|
||||
}
|
||||
}
|
||||
throw malformed();
|
||||
}
|
||||
|
||||
private char parseUnicodeEscape() {
|
||||
if (index + 4 > input.length()) {
|
||||
throw malformed();
|
||||
}
|
||||
int value = 0;
|
||||
for (int offset = 0; offset < 4; offset += 1) {
|
||||
int digit = Character.digit(input.charAt(index++), 16);
|
||||
if (digit < 0) {
|
||||
throw malformed();
|
||||
}
|
||||
value = (value << 4) | digit;
|
||||
}
|
||||
return (char) value;
|
||||
}
|
||||
|
||||
private void requireEnd() {
|
||||
skipWhitespace();
|
||||
if (index != input.length()) {
|
||||
throw malformed();
|
||||
}
|
||||
}
|
||||
|
||||
private void skipWhitespace() {
|
||||
while (index < input.length()) {
|
||||
char current = input.charAt(index);
|
||||
if (current != ' ' && current != '\t' && current != '\r' && current != '\n') {
|
||||
return;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean peek(char expected) {
|
||||
return index < input.length() && input.charAt(index) == expected;
|
||||
}
|
||||
|
||||
private boolean consume(char expected) {
|
||||
if (!peek(expected)) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void expect(char expected) {
|
||||
if (!consume(expected)) {
|
||||
throw malformed();
|
||||
}
|
||||
}
|
||||
|
||||
private IllegalArgumentException malformed() {
|
||||
return new IllegalArgumentException("Ungueltige Daemon-JSON-Anfrage");
|
||||
}
|
||||
}
|
||||
|
||||
private static int runExtraction(ExtractionRequest request) throws Exception {
|
||||
List<String> passwords = normalizePasswords(request.passwords);
|
||||
Exception lastError = null;
|
||||
Exception integrityError = null;
|
||||
boolean hadWrongPassword = false;
|
||||
for (String password : passwords) {
|
||||
for (int passwordIndex = 0; passwordIndex < passwords.size(); passwordIndex++) {
|
||||
String password = passwords.get(passwordIndex);
|
||||
emitPasswordAttempt(passwordIndex + 1, passwords.size());
|
||||
try {
|
||||
extractSingle(request, password);
|
||||
emitPassword(password);
|
||||
emitDone();
|
||||
return 0;
|
||||
} catch (AmbiguousPasswordOrIntegrityException ambiguous) {
|
||||
integrityError = ambiguous;
|
||||
lastError = ambiguous;
|
||||
} catch (WrongPasswordException wrongPassword) {
|
||||
hadWrongPassword = true;
|
||||
lastError = wrongPassword;
|
||||
} catch (Exception error) {
|
||||
integrityError = null;
|
||||
lastError = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (integrityError != null) {
|
||||
throw integrityError;
|
||||
}
|
||||
if (hadWrongPassword && (lastError instanceof WrongPasswordException)) {
|
||||
emitError("Falsches Archiv-Passwort");
|
||||
return 1;
|
||||
@@ -274,6 +394,29 @@ public final class JBindExtractorMain {
|
||||
encrypted = encrypted || header.isEncrypted();
|
||||
totalUnits += safeSize(header.getUncompressedSize());
|
||||
}
|
||||
if (encrypted) {
|
||||
for (FileHeader header : fileHeaders) {
|
||||
if (header == null || header.isDirectory() || !header.isEncrypted()) {
|
||||
continue;
|
||||
}
|
||||
InputStream passwordProbe = null;
|
||||
try {
|
||||
passwordProbe = zipFile.getInputStream(header);
|
||||
} catch (ZipException error) {
|
||||
if (isWrongPassword(error, true, false)) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (passwordProbe != null) {
|
||||
try {
|
||||
passwordProbe.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> reserved = new HashSet<String>();
|
||||
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
|
||||
Map<FileHeader, String> plannedEntryNames = new IdentityHashMap<FileHeader, String>();
|
||||
@@ -328,6 +471,7 @@ public final class JBindExtractorMain {
|
||||
rejectLinkedPath(request.targetDir, output);
|
||||
long[] remaining = new long[] { itemUnits };
|
||||
boolean extractionSuccess = false;
|
||||
boolean outputProduced = false;
|
||||
try {
|
||||
InputStream in = zipFile.getInputStream(header);
|
||||
try {
|
||||
@@ -343,6 +487,7 @@ public final class JBindExtractorMain {
|
||||
continue;
|
||||
}
|
||||
out.write(buffer, 0, read);
|
||||
outputProduced = true;
|
||||
long accounted = Math.min(remaining[0], (long) read);
|
||||
remaining[0] -= accounted;
|
||||
progress.advance(accounted);
|
||||
@@ -369,9 +514,17 @@ public final class JBindExtractorMain {
|
||||
extractionSuccess = true;
|
||||
emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition);
|
||||
} catch (ZipException error) {
|
||||
if (isWrongPassword(error, encrypted)) {
|
||||
if (isWrongPassword(error, encrypted, outputProduced)) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
if (isZipIntegrityFailure(error, encrypted, outputProduced)) {
|
||||
throw zipIntegrityFailure(header, error);
|
||||
}
|
||||
throw error;
|
||||
} catch (IOException error) {
|
||||
if (isZipIntegrityFailure(error, encrypted, outputProduced)) {
|
||||
throw zipIntegrityFailure(header, error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!extractionSuccess && output.exists()) {
|
||||
@@ -407,6 +560,11 @@ public final class JBindExtractorMain {
|
||||
try {
|
||||
context = openSevenZipArchive(request.archiveFile, password);
|
||||
IInArchive archive = context.archive;
|
||||
Object rawArchiveError = archive.getArchiveProperty(PropID.ERROR);
|
||||
String archiveError = rawArchiveError == null ? "" : String.valueOf(rawArchiveError).trim();
|
||||
if (archiveError.length() > 0) {
|
||||
throw new IOException(archiveError);
|
||||
}
|
||||
int itemCount = archive.getNumberOfItems();
|
||||
if (itemCount <= 0) {
|
||||
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
||||
@@ -515,16 +673,18 @@ public final class JBindExtractorMain {
|
||||
final long[] currentRemaining = new long[1];
|
||||
final Throwable[] firstError = new Throwable[1];
|
||||
final int[] currentPos = new int[] { -1 };
|
||||
final boolean[] passwordRequested = new boolean[1];
|
||||
final boolean[] outputProduced = new boolean[1];
|
||||
|
||||
BulkExtractCallback extractCallback = new BulkExtractCallback(
|
||||
archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
|
||||
progress, encryptedFinal, effectivePassword, currentOutput,
|
||||
currentStream, currentSuccess, currentRemaining, currentPos, firstError
|
||||
currentStream, currentSuccess, currentRemaining, currentPos, firstError, passwordRequested, outputProduced
|
||||
);
|
||||
try {
|
||||
archive.extract(indices, false, extractCallback);
|
||||
} catch (SevenZipException error) {
|
||||
if (looksLikeWrongPassword(error, encryptedFinal)) {
|
||||
if (!outputProduced[0] && looksLikeWrongPassword(error, encryptedFinal || passwordRequested[0])) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
throw error;
|
||||
@@ -558,7 +718,14 @@ public final class JBindExtractorMain {
|
||||
IInArchive archive = SevenZip.openInArchive(null, volumed, callback);
|
||||
return new SevenZipArchiveContext(archive, null, volumed, callback);
|
||||
} catch (Exception error) {
|
||||
SevenZipException volumeAccessError = callback.getVolumeAccessError();
|
||||
callback.close();
|
||||
if (volumeAccessError != null) {
|
||||
throw volumeAccessError;
|
||||
}
|
||||
if (callback.wasPasswordRequested()) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -569,6 +736,7 @@ public final class JBindExtractorMain {
|
||||
IInArchive archive = SevenZip.openInArchive(null, stream, callback);
|
||||
return new SevenZipArchiveContext(archive, stream, null, callback);
|
||||
} catch (Exception error) {
|
||||
SevenZipException volumeAccessError = callback.getVolumeAccessError();
|
||||
try {
|
||||
stream.close();
|
||||
} catch (Throwable ignored) {
|
||||
@@ -577,14 +745,24 @@ public final class JBindExtractorMain {
|
||||
raf.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
callback.close();
|
||||
if (volumeAccessError != null) {
|
||||
throw volumeAccessError;
|
||||
}
|
||||
if (callback.wasPasswordRequested()) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isWrongPassword(ZipException error, boolean encrypted) {
|
||||
private static boolean isWrongPassword(ZipException error, boolean encrypted, boolean outputProduced) {
|
||||
if (error == null) {
|
||||
return false;
|
||||
}
|
||||
if (outputProduced) {
|
||||
return false;
|
||||
}
|
||||
if (error.getType() == ZipException.Type.WRONG_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
@@ -592,11 +770,29 @@ public final class JBindExtractorMain {
|
||||
if (text.contains("wrong password") || text.contains("falsches passwort")) {
|
||||
return true;
|
||||
}
|
||||
return encrypted && (text.contains("checksum") || text.contains("crc") || text.contains("password"));
|
||||
return encrypted && text.contains("password");
|
||||
}
|
||||
|
||||
private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted) {
|
||||
if (!encrypted || result == null) {
|
||||
private static boolean isZipIntegrityFailure(Throwable error, boolean encrypted, boolean outputProduced) {
|
||||
if (!encrypted || error == null) {
|
||||
return false;
|
||||
}
|
||||
String text = safeMessage(error).toLowerCase(Locale.ROOT);
|
||||
return outputProduced || text.contains("aes verification failed") || text.contains("checksum") || text.contains("crc");
|
||||
}
|
||||
|
||||
private static Exception zipIntegrityFailure(FileHeader header, Throwable error) {
|
||||
if (header != null && header.getEncryptionMethod() == EncryptionMethod.ZIP_STANDARD) {
|
||||
return new AmbiguousPasswordOrIntegrityException(error);
|
||||
}
|
||||
return new IOException("zip4j-Fehler: CRCERROR", error);
|
||||
}
|
||||
|
||||
private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted, boolean outputProduced) {
|
||||
if (result == ExtractOperationResult.WRONG_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
if (!encrypted || outputProduced || result == null) {
|
||||
return false;
|
||||
}
|
||||
return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR;
|
||||
@@ -1031,9 +1227,8 @@ public final class JBindExtractorMain {
|
||||
System.out.println("RD_BACKEND " + backend.value);
|
||||
}
|
||||
|
||||
private static void emitPassword(String password) {
|
||||
String encoded = Base64.getEncoder().encodeToString((password == null ? "" : password).getBytes(StandardCharsets.UTF_8));
|
||||
System.out.println("RD_PASSWORD " + encoded);
|
||||
private static void emitPasswordAttempt(int attempt, int total) {
|
||||
System.out.println("RD_PASSWORD_ATTEMPT " + attempt + " " + total);
|
||||
}
|
||||
|
||||
private static void emitDone() {
|
||||
@@ -1143,6 +1338,8 @@ public final class JBindExtractorMain {
|
||||
private final long[] currentRemaining;
|
||||
private final int[] currentPos;
|
||||
private final Throwable[] firstError;
|
||||
private final boolean[] passwordRequested;
|
||||
private final boolean[] outputProduced;
|
||||
|
||||
BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map<Integer, Integer> indexToPos,
|
||||
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
|
||||
@@ -1150,7 +1347,7 @@ public final class JBindExtractorMain {
|
||||
ProgressTracker progress, boolean encrypted, String password,
|
||||
File[] currentOutput, FileOutputStream[] currentStream,
|
||||
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
||||
Throwable[] firstError) {
|
||||
Throwable[] firstError, boolean[] passwordRequested, boolean[] outputProduced) {
|
||||
this.archive = archive;
|
||||
this.archiveFile = archiveFile;
|
||||
this.targetDir = targetDir;
|
||||
@@ -1169,10 +1366,13 @@ public final class JBindExtractorMain {
|
||||
this.currentRemaining = currentRemaining;
|
||||
this.currentPos = currentPos;
|
||||
this.firstError = firstError;
|
||||
this.passwordRequested = passwordRequested;
|
||||
this.outputProduced = outputProduced;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cryptoGetTextPassword() {
|
||||
passwordRequested[0] = true;
|
||||
return password;
|
||||
}
|
||||
|
||||
@@ -1227,6 +1427,7 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
try {
|
||||
currentStream[0].write(data);
|
||||
outputProduced[0] = true;
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
|
||||
}
|
||||
@@ -1268,7 +1469,7 @@ public final class JBindExtractorMain {
|
||||
} else {
|
||||
discardCurrentOutput();
|
||||
if (firstError[0] == null) {
|
||||
if (isPasswordFailure(result, encrypted)) {
|
||||
if (isPasswordFailure(result, encrypted || passwordRequested[0], outputProduced[0])) {
|
||||
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
|
||||
} else {
|
||||
firstError[0] = new IOException("7z-Fehler: " + result.name());
|
||||
@@ -1318,6 +1519,14 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AmbiguousPasswordOrIntegrityException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
AmbiguousPasswordOrIntegrityException(Throwable cause) {
|
||||
super("zip4j-Fehler: CRCERROR", cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ProgressTracker {
|
||||
private final long total;
|
||||
private long completed;
|
||||
@@ -1402,6 +1611,8 @@ public final class JBindExtractorMain {
|
||||
private final File archiveDir;
|
||||
private final String firstFileName;
|
||||
private final String password;
|
||||
private boolean passwordRequested;
|
||||
private SevenZipException volumeAccessError;
|
||||
private final Map<String, RandomAccessFile> openRafs = new HashMap<String, RandomAccessFile>();
|
||||
|
||||
SevenZipVolumeCallback(File archiveFile, String password) {
|
||||
@@ -1434,7 +1645,8 @@ public final class JBindExtractorMain {
|
||||
raf.seek(0L);
|
||||
return new RandomAccessFileInStream(raf);
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error);
|
||||
volumeAccessError = new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error);
|
||||
throw volumeAccessError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,9 +1662,18 @@ public final class JBindExtractorMain {
|
||||
|
||||
@Override
|
||||
public String cryptoGetTextPassword() {
|
||||
passwordRequested = true;
|
||||
return password;
|
||||
}
|
||||
|
||||
boolean wasPasswordRequested() {
|
||||
return passwordRequested;
|
||||
}
|
||||
|
||||
SevenZipException getVolumeAccessError() {
|
||||
return volumeAccessError;
|
||||
}
|
||||
|
||||
private File resolveVolumeFile(String filename) {
|
||||
if (filename == null || filename.trim().length() == 0) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultRuntimeRoot = path.resolve(scriptDir, "..", "resources", "extractor-jvm");
|
||||
const requiredLibNames = [
|
||||
"sevenzipjbinding.jar",
|
||||
"sevenzipjbinding-all-platforms.jar",
|
||||
"zip4j.jar"
|
||||
];
|
||||
const stampName = ".source.sha256";
|
||||
|
||||
function parseArguments(args) {
|
||||
let runtimeRoot = defaultRuntimeRoot;
|
||||
let checkOnly = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const value = args[index];
|
||||
if (value === "--check") {
|
||||
checkOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (value === "--runtime-root") {
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
throw new Error("--runtime-root benötigt einen Pfad");
|
||||
}
|
||||
runtimeRoot = path.resolve(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unbekanntes Argument: ${value}`);
|
||||
}
|
||||
return { runtimeRoot, checkOnly };
|
||||
}
|
||||
|
||||
function requireFile(filePath, label) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(`${label} fehlt: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile() || stat.size === 0 || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} ist keine reguläre, nicht leere Datei: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listJavaSources(sourceRoot) {
|
||||
const sources = [];
|
||||
const pending = [sourceRoot];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".java")) {
|
||||
sources.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.sort((left, right) => left.localeCompare(right, "en"));
|
||||
if (sources.length === 0) {
|
||||
throw new Error(`Keine Java-Quellen gefunden: ${sourceRoot}`);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function updateDigest(hash, root, filePath) {
|
||||
const relativePath = path.relative(root, filePath).replace(/\\/g, "/");
|
||||
const content = fs.readFileSync(filePath);
|
||||
hash.update(relativePath, "utf8");
|
||||
hash.update("\0", "utf8");
|
||||
hash.update(String(content.length), "utf8");
|
||||
hash.update("\0", "utf8");
|
||||
hash.update(content);
|
||||
}
|
||||
|
||||
function sourceDigest(runtimeRoot, sources, libs) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update("javac:-source=8:-target=8:-encoding=UTF-8:-g:none\0", "utf8");
|
||||
for (const filePath of [...sources, ...libs]) {
|
||||
updateDigest(hash, runtimeRoot, filePath);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function verifyClassesDirectory(classesDir, expectedDigest) {
|
||||
const mainClass = path.join(classesDir, "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||
const stampPath = path.join(classesDir, stampName);
|
||||
requireFile(mainClass, "JVM-Extractor-Hauptklasse");
|
||||
requireFile(stampPath, "JVM-Extractor-Quellhash");
|
||||
const mainClassBytes = fs.readFileSync(mainClass);
|
||||
if (mainClassBytes.length < 8 || mainClassBytes.readUInt16BE(6) !== 52) {
|
||||
throw new Error("JVM-Extractor-Hauptklasse muss Java-8-Bytecode verwenden");
|
||||
}
|
||||
const actualDigest = fs.readFileSync(stampPath, "utf8").trim();
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error("JVM-Extractor-Klassen sind veraltet; Java-Quelle oder Bibliotheken wurden seit dem letzten Build geändert");
|
||||
}
|
||||
}
|
||||
|
||||
function verifyClasses(runtimeRoot, expectedDigest) {
|
||||
verifyClassesDirectory(path.join(runtimeRoot, "classes"), expectedDigest);
|
||||
}
|
||||
|
||||
function resolveJavac() {
|
||||
const javaHome = String(process.env.JAVA_HOME || "").trim();
|
||||
const javaHomeCompiler = javaHome
|
||||
? path.join(javaHome, "bin", process.platform === "win32" ? "javac.exe" : "javac")
|
||||
: "";
|
||||
if (javaHomeCompiler && fs.existsSync(javaHomeCompiler)) {
|
||||
return javaHomeCompiler;
|
||||
}
|
||||
return "javac";
|
||||
}
|
||||
|
||||
function requireJavac() {
|
||||
const command = resolveJavac();
|
||||
const probe = spawnSync(command, ["-version"], { encoding: "utf8", windowsHide: true });
|
||||
if (probe.error?.code === "ENOENT" || probe.status === null) {
|
||||
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||
}
|
||||
if (probe.status !== 0) {
|
||||
throw new Error(`JDK-Compiler javac ist nicht verwendbar: ${String(probe.stderr || probe.stdout || `Exit ${probe.status}`).trim()}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function replaceClasses(runtimeRoot, temporaryClasses) {
|
||||
const classesDir = path.join(runtimeRoot, "classes");
|
||||
const backupDir = path.join(runtimeRoot, `.classes-previous-${process.pid}-${Date.now()}`);
|
||||
let previousMoved = false;
|
||||
try {
|
||||
if (fs.existsSync(classesDir)) {
|
||||
fs.renameSync(classesDir, backupDir);
|
||||
previousMoved = true;
|
||||
}
|
||||
fs.renameSync(temporaryClasses, classesDir);
|
||||
if (previousMoved) {
|
||||
fs.rmSync(backupDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!fs.existsSync(classesDir) && previousMoved && fs.existsSync(backupDir)) {
|
||||
fs.renameSync(backupDir, classesDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function compileClasses(runtimeRoot, sources, libs, digest) {
|
||||
const javac = requireJavac();
|
||||
const temporaryClasses = fs.mkdtempSync(path.join(runtimeRoot, ".classes-build-"));
|
||||
try {
|
||||
const result = spawnSync(javac, [
|
||||
"-source", "8",
|
||||
"-target", "8",
|
||||
"-encoding", "UTF-8",
|
||||
"-g:none",
|
||||
"-classpath", libs.join(path.delimiter),
|
||||
"-d", temporaryClasses,
|
||||
...sources
|
||||
], { encoding: "utf8", windowsHide: true });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`javac fehlgeschlagen: ${String(result.stderr || result.stdout || `Exit ${result.status}`).trim()}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(temporaryClasses, stampName), `${digest}\n`, "utf8");
|
||||
verifyClassesDirectory(temporaryClasses, digest);
|
||||
replaceClasses(runtimeRoot, temporaryClasses);
|
||||
} finally {
|
||||
if (fs.existsSync(temporaryClasses)) {
|
||||
fs.rmSync(temporaryClasses, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { runtimeRoot, checkOnly } = parseArguments(process.argv.slice(2));
|
||||
const sourceRoot = path.join(runtimeRoot, "src");
|
||||
const sources = listJavaSources(sourceRoot);
|
||||
const libs = requiredLibNames.map((name) => path.join(runtimeRoot, "lib", name));
|
||||
for (const source of sources) {
|
||||
requireFile(source, "Java-Quelle");
|
||||
}
|
||||
for (const lib of libs) {
|
||||
requireFile(lib, "JVM-Extractor-Bibliothek");
|
||||
}
|
||||
const digest = sourceDigest(runtimeRoot, sources, libs);
|
||||
if (checkOnly) {
|
||||
verifyClasses(runtimeRoot, digest);
|
||||
process.stdout.write(`JVM-Extractor-Klassen aktuell: ${digest}\n`);
|
||||
return;
|
||||
}
|
||||
compileClasses(runtimeRoot, sources, libs, digest);
|
||||
verifyClasses(runtimeRoot, digest);
|
||||
process.stdout.write(`JVM-Extractor-Klassen gebaut: ${digest}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`JVM-Extractor-Build fehlgeschlagen: ${String(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader";
|
||||
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
||||
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
||||
const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh";
|
||||
const EXPECTED_JVM_ASAR_UNPACK = "resources/extractor-jvm/**/*";
|
||||
const REQUIRED_BUILD_FILES = Object.freeze([
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
@@ -158,6 +159,97 @@ function sha256File(filePath) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
}
|
||||
|
||||
function isJvmRuntimeArtifact(relativePath) {
|
||||
const normalized = relativePath.split(path.sep).join("/");
|
||||
return /^classes\/.+\.class$/i.test(normalized)
|
||||
|| /(?:^|\/)\.source\.sha256$/i.test(normalized)
|
||||
|| /^lib\/[^/]+\.jar$/i.test(normalized);
|
||||
}
|
||||
|
||||
function readJvmRuntimeInventory(runtimeRoot, label) {
|
||||
let rootStat;
|
||||
try {
|
||||
rootStat = fs.lstatSync(runtimeRoot);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(`Missing ${label} JVM runtime: ${runtimeRoot}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`${label} JVM runtime must be a regular directory: ${runtimeRoot}`);
|
||||
}
|
||||
const inventory = new Map();
|
||||
for (const filePath of listFiles(runtimeRoot)) {
|
||||
const relativePath = normalizeRelativePath(runtimeRoot, filePath);
|
||||
if (!isJvmRuntimeArtifact(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) {
|
||||
throw new Error(`${label} JVM runtime artifact must be a non-empty regular file: ${relativePath}`);
|
||||
}
|
||||
inventory.set(relativePath, sha256File(filePath));
|
||||
}
|
||||
const paths = [...inventory.keys()];
|
||||
if (!paths.some((relativePath) => /^classes\/.+\.class$/i.test(relativePath))) {
|
||||
throw new Error(`${label} JVM runtime contains no class files`);
|
||||
}
|
||||
if (!paths.some((relativePath) => /(?:^|\/)\.source\.sha256$/i.test(relativePath))) {
|
||||
throw new Error(`${label} JVM runtime omits .source.sha256`);
|
||||
}
|
||||
if (!paths.some((relativePath) => /^lib\/[^/]+\.jar$/i.test(relativePath))) {
|
||||
throw new Error(`${label} JVM runtime contains no library JAR files`);
|
||||
}
|
||||
return inventory;
|
||||
}
|
||||
|
||||
function verifyJvmRuntimeEquality(sourceRoot, packagedRoot, label) {
|
||||
const sourceInventory = readJvmRuntimeInventory(sourceRoot, "source");
|
||||
const packagedInventory = readJvmRuntimeInventory(packagedRoot, label);
|
||||
for (const [relativePath, sourceDigest] of sourceInventory) {
|
||||
if (!packagedInventory.has(relativePath)) {
|
||||
throw new Error(`${label} JVM runtime is missing ${relativePath}`);
|
||||
}
|
||||
if (packagedInventory.get(relativePath) !== sourceDigest) {
|
||||
throw new Error(`${label} JVM runtime SHA256 mismatch for ${relativePath}`);
|
||||
}
|
||||
}
|
||||
for (const relativePath of packagedInventory.keys()) {
|
||||
if (!sourceInventory.has(relativePath)) {
|
||||
throw new Error(`${label} JVM runtime contains unexpected artifact ${relativePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findArchiveJvmRuntimeRoots(extractionRoot) {
|
||||
const roots = new Map();
|
||||
const marker = ["resources", "app.asar.unpacked", "resources", "extractor-jvm"];
|
||||
for (const filePath of listFiles(extractionRoot)) {
|
||||
const parts = normalizeRelativePath(extractionRoot, filePath).split("/");
|
||||
for (let index = 0; index <= parts.length - marker.length; index += 1) {
|
||||
const candidate = parts.slice(index, index + marker.length);
|
||||
if (candidate.every((part, markerIndex) => part.toLowerCase() === marker[markerIndex])) {
|
||||
const relativeRoot = parts.slice(0, index + marker.length).join("/");
|
||||
roots.set(relativeRoot.toLowerCase(), path.join(extractionRoot, ...relativeRoot.split("/")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.values()];
|
||||
}
|
||||
|
||||
function verifyArchiveJvmRuntime(extractionRoot, archiveName, sourceRoot) {
|
||||
const runtimeRoots = findArchiveJvmRuntimeRoots(extractionRoot);
|
||||
if (runtimeRoots.length === 0) {
|
||||
throw new Error(`Missing JVM runtime in archive ${archiveName}`);
|
||||
}
|
||||
const sourceRuntimeRoot = path.join(sourceRoot, "resources", "extractor-jvm");
|
||||
for (const runtimeRoot of runtimeRoots) {
|
||||
verifyJvmRuntimeEquality(sourceRuntimeRoot, runtimeRoot, `${archiveName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPackagedIcon(sourceRoot, packagedRoot, label) {
|
||||
const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||
const packagedPath = path.join(packagedRoot, "resources", "assets", "app_icon.ico");
|
||||
@@ -313,6 +405,10 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
if (missingBuildFiles.length > 0) {
|
||||
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
||||
}
|
||||
const asarUnpack = Array.isArray(build.asarUnpack) ? build.asarUnpack : [build.asarUnpack];
|
||||
if (!asarUnpack.includes(EXPECTED_JVM_ASAR_UNPACK)) {
|
||||
throw new Error(`package.json build.asarUnpack must include ${EXPECTED_JVM_ASAR_UNPACK}`);
|
||||
}
|
||||
if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) {
|
||||
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
||||
}
|
||||
@@ -370,6 +466,11 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
|
||||
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
||||
verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "win-unpacked");
|
||||
verifyJvmRuntimeEquality(
|
||||
path.join(absoluteRoot, "resources", "extractor-jvm"),
|
||||
path.join(releaseDir, "win-unpacked", "resources", "app.asar.unpacked", "resources", "extractor-jvm"),
|
||||
"win-unpacked"
|
||||
);
|
||||
|
||||
return {
|
||||
publish: {
|
||||
@@ -401,6 +502,7 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
||||
try {
|
||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
||||
verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot);
|
||||
verifiedArchives.push(archiveName);
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
|
||||
+826
-265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { ExtractNowRequest } from "../shared/extract-now";
|
||||
import { normalizeExtractNowRequest } from "../shared/extract-now";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
|
||||
export interface ExtractionIpcTarget {
|
||||
retryExtraction(packageId: string): Promise<void>;
|
||||
extractNow(request: ExtractNowRequest): Promise<void>;
|
||||
}
|
||||
|
||||
export type TrustedIpcRegistrar = (
|
||||
channel: string,
|
||||
listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
|
||||
) => void;
|
||||
|
||||
function normalizeRetryPackageId(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error("packageId muss ein nicht-leerer String sein");
|
||||
}
|
||||
const packageId = value.trim();
|
||||
if (packageId.length > 256) {
|
||||
throw new Error("packageId darf höchstens 256 Zeichen lang sein");
|
||||
}
|
||||
return packageId;
|
||||
}
|
||||
|
||||
export function registerExtractionIpcHandlers(registerTrusted: TrustedIpcRegistrar, target: ExtractionIpcTarget): void {
|
||||
registerTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event, ...args) => {
|
||||
return target.retryExtraction(normalizeRetryPackageId(args[0]));
|
||||
});
|
||||
registerTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event, ...args) => {
|
||||
return target.extractNow(normalizeExtractNowRequest(args[0]));
|
||||
});
|
||||
}
|
||||
+312
-170
@@ -128,6 +128,13 @@ class ExtractionOutputCallbackError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class ExtractionPasswordAttemptCallbackError extends Error {
|
||||
public constructor() {
|
||||
super("extract_password_attempt_callback_failed");
|
||||
this.name = "ExtractionPasswordAttemptCallbackError";
|
||||
}
|
||||
}
|
||||
|
||||
type ExtractionErrorWithHints = Error & {
|
||||
suggestRedownload?: boolean;
|
||||
jvmFailureReason?: string;
|
||||
@@ -152,13 +159,21 @@ type JvmExtractResult = {
|
||||
errorText: string;
|
||||
usedPassword: string;
|
||||
backend: string;
|
||||
passwordAttempts: number;
|
||||
passwordAttemptTotal: number;
|
||||
passwordCandidatesExhausted: boolean;
|
||||
};
|
||||
|
||||
type JvmExtractBaseResult = Omit<JvmExtractResult, "passwordAttempts" | "passwordAttemptTotal" | "passwordCandidatesExhausted">;
|
||||
|
||||
type JvmParseState = {
|
||||
bestPercent: number;
|
||||
usedPassword: string;
|
||||
backend: string;
|
||||
reportedError: string;
|
||||
passwordAttempts: number;
|
||||
passwordAttemptTotal: number;
|
||||
passwordCandidates: string[];
|
||||
outputError?: Error;
|
||||
openedOutputs?: Map<string, ExtractOutputEvent>;
|
||||
};
|
||||
@@ -208,6 +223,7 @@ interface SubstMapping { drive: string; original: string; }
|
||||
interface DaemonRequest {
|
||||
resolve: (result: JvmExtractResult) => void;
|
||||
onArchiveProgress?: (percent: number) => void;
|
||||
onPasswordAttempt?: (attempt: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
parseState: JvmParseState;
|
||||
@@ -978,8 +994,17 @@ export function classifyExtractionError(errorText: unknown): ExtractErrorCategor
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
|
||||
if (text.includes("timeout")) return "timeout";
|
||||
if (text.includes("crc failed") || text.includes("checksum error") || text.includes("crc error")) return "crc_error";
|
||||
if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password";
|
||||
if (text.includes("crc failed")
|
||||
|| text.includes("checksum error")
|
||||
|| text.includes("crc error")
|
||||
|| text.includes("crc-fehler")
|
||||
|| text.includes("crc fehler")
|
||||
|| text.includes("crcerror")
|
||||
|| text.includes("dataerror")
|
||||
|| text.includes("fsummenfehler")) return "crc_error";
|
||||
if (text.includes("wrong password")
|
||||
|| text.includes("incorrect password")
|
||||
|| /falsches(?:[\s-]+archiv)?[\s-]+passwort/.test(text)) return "wrong_password";
|
||||
if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts";
|
||||
if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor";
|
||||
if (isUnsupportedArchiveFormatError(text)) return "unsupported_format";
|
||||
@@ -987,21 +1012,24 @@ export function classifyExtractionError(errorText: unknown): ExtractErrorCategor
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function shouldSuggestRedownloadAfterCrossBackendFailure(
|
||||
legacyCategory: ExtractErrorCategory,
|
||||
jvmCategory: ExtractErrorCategory,
|
||||
passwordCandidatesExhausted: boolean
|
||||
): boolean {
|
||||
return passwordCandidatesExhausted
|
||||
&& legacyCategory === "crc_error"
|
||||
&& jvmCategory === "crc_error";
|
||||
}
|
||||
|
||||
export function shouldSerialRetryParallelFailures(
|
||||
extractedCount: number,
|
||||
_extractedCount: number,
|
||||
failedCategories: ExtractErrorCategory[]
|
||||
): boolean {
|
||||
if (failedCategories.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (extractedCount > 0) {
|
||||
return true;
|
||||
}
|
||||
return failedCategories.every((category) =>
|
||||
category === "crc_error"
|
||||
|| category === "wrong_password"
|
||||
|| category === "unknown"
|
||||
);
|
||||
return failedCategories.every((category) => category === "unknown");
|
||||
}
|
||||
|
||||
export function shouldFallbackLegacyRarToJvm(
|
||||
@@ -1139,6 +1167,43 @@ function extractorCommandKind(command: string): ExtractorCommandKind {
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function extractorCommandsShareIdentity(
|
||||
leftCommand: string,
|
||||
rightCommand: string,
|
||||
platform = process.platform
|
||||
): boolean {
|
||||
const leftKind = extractorCommandKind(leftCommand);
|
||||
const rightKind = extractorCommandKind(rightCommand);
|
||||
if (leftKind !== "other" && leftKind === rightKind) {
|
||||
return true;
|
||||
}
|
||||
const normalize = (command: string): string => {
|
||||
let value = String(command || "").trim();
|
||||
if (isAbsoluteCommand(value)) {
|
||||
try {
|
||||
value = fs.realpathSync.native(value);
|
||||
} catch {
|
||||
value = path.resolve(value);
|
||||
}
|
||||
}
|
||||
return String(platform).toLowerCase() === "win32" ? value.toLowerCase() : value;
|
||||
};
|
||||
return normalize(leftCommand) === normalize(rightCommand);
|
||||
}
|
||||
|
||||
export function shouldRunAlternativeNativeExtractor(
|
||||
currentCommand: string,
|
||||
archivePath: string,
|
||||
configuredMode: ExtractBackendMode,
|
||||
_backendMode: ExtractBackendMode,
|
||||
platform = process.platform
|
||||
): boolean {
|
||||
return !(String(platform).toLowerCase() === "win32"
|
||||
&& configuredMode !== "legacy"
|
||||
&& isRarArchivePath(archivePath)
|
||||
&& extractorCommandKind(currentCommand) === "rar_native");
|
||||
}
|
||||
|
||||
function isAbsoluteCommand(command: string): boolean {
|
||||
return path.isAbsolute(command)
|
||||
|| command.includes("\\")
|
||||
@@ -1252,7 +1317,7 @@ function extractorProbeArgs(command: string): string[] {
|
||||
}
|
||||
|
||||
async function resolveExtractorCommandInternal(archivePath = ""): Promise<string> {
|
||||
if (resolvedExtractorCommand) {
|
||||
if (resolvedExtractorCommand && cachedExtractorFitsArchive(resolvedExtractorCommand, archivePath)) {
|
||||
return resolvedExtractorCommand;
|
||||
}
|
||||
if (resolveFailureReason) {
|
||||
@@ -1314,7 +1379,7 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
|
||||
: ["seven_zip", "rar_native"];
|
||||
for (const kind of preferredKinds) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === currentCommand) continue;
|
||||
if (extractorCommandsShareIdentity(candidate, currentCommand)) continue;
|
||||
if (extractorCommandKind(candidate) !== kind) continue;
|
||||
if (isAbsoluteCommand(candidate) && !fs.existsSync(candidate)) continue;
|
||||
const probe = await runExtractCommand(candidate, extractorProbeArgs(candidate), undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
|
||||
@@ -1671,11 +1736,65 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseJvmPasswordAttemptLine(line: string): { attempt: number; total: number } | null {
|
||||
const match = String(line || "").trim().match(/^RD_PASSWORD_ATTEMPT ([1-9]\d*) ([1-9]\d*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const attempt = Number(match[1]);
|
||||
const total = Number(match[2]);
|
||||
if (!Number.isSafeInteger(attempt) || !Number.isSafeInteger(total) || attempt > total || total > 1_000_000) {
|
||||
return null;
|
||||
}
|
||||
return { attempt, total };
|
||||
}
|
||||
|
||||
export function summarizeJvmPasswordAttempts(
|
||||
attempts: number,
|
||||
total: number,
|
||||
extractionSucceeded: boolean
|
||||
): { attempts: number; total: number; exhausted: boolean } {
|
||||
if (!Number.isSafeInteger(attempts)
|
||||
|| !Number.isSafeInteger(total)
|
||||
|| attempts < 1
|
||||
|| total < 1
|
||||
|| attempts > total
|
||||
|| total > 1_000_000) {
|
||||
return { attempts: 0, total: 0, exhausted: false };
|
||||
}
|
||||
return {
|
||||
attempts,
|
||||
total,
|
||||
exhausted: !extractionSucceeded && attempts === total
|
||||
};
|
||||
}
|
||||
|
||||
export function redactJvmDiagnosticLine(line: string): string {
|
||||
const value = String(line || "");
|
||||
return value.trimStart().startsWith("RD_PASSWORD ")
|
||||
? `${value.slice(0, value.length - value.trimStart().length)}RD_PASSWORD <redacted>`
|
||||
: value;
|
||||
}
|
||||
|
||||
function jvmPasswordAttemptResult(state: JvmParseState, extractionSucceeded: boolean): Pick<JvmExtractResult, "passwordAttempts" | "passwordAttemptTotal" | "passwordCandidatesExhausted"> {
|
||||
const summary = summarizeJvmPasswordAttempts(state.passwordAttempts, state.passwordAttemptTotal, extractionSucceeded);
|
||||
return {
|
||||
passwordAttempts: summary.attempts,
|
||||
passwordAttemptTotal: summary.total,
|
||||
passwordCandidatesExhausted: summary.exhausted
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJvmPasswordCandidates(passwordCandidates: string[]): string[] {
|
||||
return Array.from(new Set(["", ...passwordCandidates.map((candidate) => String(candidate || ""))]));
|
||||
}
|
||||
|
||||
function parseJvmLine(
|
||||
line: string,
|
||||
onArchiveProgress: ((percent: number) => void) | undefined,
|
||||
state: JvmParseState,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
onOutput?: (event: ExtractOutputEvent) => void,
|
||||
onPasswordAttempt?: (attempt: number, total: number) => void
|
||||
): void {
|
||||
const trimmed = String(line || "").trim();
|
||||
if (!trimmed) {
|
||||
@@ -1709,6 +1828,28 @@ function parseJvmLine(
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "RD_DONE") {
|
||||
const successfulIndex = state.passwordAttempts - 1;
|
||||
state.usedPassword = successfulIndex >= 0 && successfulIndex < state.passwordCandidates.length
|
||||
? state.passwordCandidates[successfulIndex]
|
||||
: "";
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordAttempt = parseJvmPasswordAttemptLine(trimmed);
|
||||
if (passwordAttempt) {
|
||||
state.passwordAttempts = passwordAttempt.attempt;
|
||||
state.passwordAttemptTotal = passwordAttempt.total;
|
||||
if (onPasswordAttempt) {
|
||||
try {
|
||||
onPasswordAttempt(passwordAttempt.attempt, passwordAttempt.total);
|
||||
} catch {
|
||||
state.outputError ||= new ExtractionPasswordAttemptCallbackError();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("RD_OUTPUT ")) {
|
||||
const fields = trimmed.split(" ");
|
||||
const stateValue = fields[2];
|
||||
@@ -1754,6 +1895,10 @@ let daemonTimeoutId: NodeJS.Timeout | null = null;
|
||||
let daemonAbortHandler: (() => void) | null = null;
|
||||
let daemonLayout: JvmExtractorLayout | null = null;
|
||||
|
||||
function appendDaemonDiagnosticLine(line: string): void {
|
||||
daemonOutput = appendLimited(daemonOutput, `${redactJvmDiagnosticLine(line)}\n`);
|
||||
}
|
||||
|
||||
export function shutdownDaemon(): void {
|
||||
if (daemonProcess) {
|
||||
try { daemonProcess.stdin?.end(); } catch { }
|
||||
@@ -1771,7 +1916,7 @@ export function shutdownDaemon(): void {
|
||||
daemonLayout = null;
|
||||
}
|
||||
|
||||
function finishDaemonRequest(result: JvmExtractResult): void {
|
||||
function finishDaemonRequest(result: JvmExtractBaseResult): void {
|
||||
const req = daemonCurrentRequest;
|
||||
if (!req) return;
|
||||
const openedCount = reconcileJvmOpenedOutputs(req.parseState, req.onOutput, req.targetDir);
|
||||
@@ -1801,7 +1946,10 @@ function finishDaemonRequest(result: JvmExtractResult): void {
|
||||
req.signal.removeEventListener("abort", daemonAbortHandler);
|
||||
daemonAbortHandler = null;
|
||||
}
|
||||
req.resolve(finalResult);
|
||||
req.resolve({
|
||||
...finalResult,
|
||||
...jvmPasswordAttemptResult(req.parseState, finalResult.ok)
|
||||
});
|
||||
}
|
||||
|
||||
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
|
||||
@@ -1809,11 +1957,13 @@ function flushDaemonParseBuffers(req: DaemonRequest | null): void {
|
||||
return;
|
||||
}
|
||||
if (daemonStdoutBuffer.trim()) {
|
||||
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||
appendDaemonDiagnosticLine(daemonStdoutBuffer);
|
||||
parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt);
|
||||
daemonStdoutBuffer = "";
|
||||
}
|
||||
if (daemonStderrBuffer.trim()) {
|
||||
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||
appendDaemonDiagnosticLine(daemonStderrBuffer);
|
||||
parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt);
|
||||
daemonStderrBuffer = "";
|
||||
}
|
||||
}
|
||||
@@ -1879,7 +2029,7 @@ function handleDaemonLine(line: string): void {
|
||||
if (req.terminationStarted) {
|
||||
return;
|
||||
}
|
||||
parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||
parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt);
|
||||
failDaemonOutputCallback(req);
|
||||
}
|
||||
}
|
||||
@@ -1916,28 +2066,28 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
||||
|
||||
child.stdout!.on("data", (chunk) => {
|
||||
const raw = String(chunk || "");
|
||||
daemonOutput = appendLimited(daemonOutput, raw);
|
||||
daemonStdoutBuffer += raw;
|
||||
const lines = daemonStdoutBuffer.split(/\r?\n/);
|
||||
daemonStdoutBuffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
appendDaemonDiagnosticLine(line);
|
||||
handleDaemonLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr!.on("data", (chunk) => {
|
||||
const raw = String(chunk || "");
|
||||
daemonOutput = appendLimited(daemonOutput, raw);
|
||||
daemonStderrBuffer += raw;
|
||||
const lines = daemonStderrBuffer.split(/\r?\n/);
|
||||
daemonStderrBuffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
appendDaemonDiagnosticLine(line);
|
||||
if (daemonCurrentRequest) {
|
||||
const req = daemonCurrentRequest;
|
||||
if (req.terminationStarted) {
|
||||
continue;
|
||||
}
|
||||
parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput);
|
||||
parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt);
|
||||
failDaemonOutputCallback(req);
|
||||
}
|
||||
}
|
||||
@@ -1960,6 +2110,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
||||
child.on("close", () => {
|
||||
if (daemonCurrentRequest) {
|
||||
const req = daemonCurrentRequest;
|
||||
flushDaemonParseBuffers(req);
|
||||
if (req.aborted) {
|
||||
finishDaemonRequest({
|
||||
ok: false, missingCommand: false, missingRuntime: false,
|
||||
@@ -2013,7 +2164,10 @@ function abortedJvmExtractResult(): JvmExtractResult {
|
||||
timedOut: false,
|
||||
errorText: "aborted:extract",
|
||||
usedPassword: "",
|
||||
backend: ""
|
||||
backend: "",
|
||||
passwordAttempts: 0,
|
||||
passwordAttemptTotal: 0,
|
||||
passwordCandidatesExhausted: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2059,14 +2213,23 @@ function sendDaemonRequest(
|
||||
onArchiveProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
onOutput?: (event: ExtractOutputEvent) => void,
|
||||
onPasswordAttempt?: (attempt: number, total: number) => void
|
||||
): Promise<JvmExtractResult> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(abortedJvmExtractResult());
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
||||
const parseState: JvmParseState = {
|
||||
bestPercent: 0,
|
||||
usedPassword: "",
|
||||
backend: "",
|
||||
reportedError: "",
|
||||
passwordAttempts: 0,
|
||||
passwordAttemptTotal: 0,
|
||||
passwordCandidates: normalizeJvmPasswordCandidates(passwordCandidates)
|
||||
};
|
||||
const archiveName = path.basename(archivePath);
|
||||
|
||||
daemonBusy = true;
|
||||
@@ -2074,6 +2237,7 @@ function sendDaemonRequest(
|
||||
daemonCurrentRequest = {
|
||||
resolve,
|
||||
onArchiveProgress,
|
||||
onPasswordAttempt,
|
||||
signal,
|
||||
timeoutMs,
|
||||
parseState,
|
||||
@@ -2148,7 +2312,8 @@ async function runJvmExtractCommand(
|
||||
onArchiveProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number,
|
||||
onOutput?: (event: ExtractOutputEvent) => void
|
||||
onOutput?: (event: ExtractOutputEvent) => void,
|
||||
onPasswordAttempt?: (attempt: number, total: number) => void
|
||||
): Promise<JvmExtractResult> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(abortedJvmExtractResult());
|
||||
@@ -2157,7 +2322,7 @@ async function runJvmExtractCommand(
|
||||
if (isDaemonAvailable(layout)) {
|
||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt);
|
||||
}
|
||||
|
||||
if (daemonProcess) {
|
||||
@@ -2172,7 +2337,7 @@ async function runJvmExtractCommand(
|
||||
if (ready) {
|
||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt);
|
||||
}
|
||||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||
}
|
||||
@@ -2221,7 +2386,15 @@ async function runJvmExtractCommand(
|
||||
let timedOutByWatchdog = false;
|
||||
let abortedBySignal = false;
|
||||
let onAbort: (() => void) | null = null;
|
||||
const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
||||
const parseState: JvmParseState = {
|
||||
bestPercent: 0,
|
||||
usedPassword: "",
|
||||
backend: "",
|
||||
reportedError: "",
|
||||
passwordAttempts: 0,
|
||||
passwordAttemptTotal: 0,
|
||||
passwordCandidates: normalizeJvmPasswordCandidates(passwordCandidates)
|
||||
};
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
let outputCallbackKillStarted = false;
|
||||
@@ -2233,12 +2406,12 @@ async function runJvmExtractCommand(
|
||||
if (!rawChunk) {
|
||||
return;
|
||||
}
|
||||
output = appendLimited(output, rawChunk);
|
||||
const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`;
|
||||
const lines = nextBuffer.split(/\r?\n/);
|
||||
const keep = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
parseJvmLine(line, onArchiveProgress, parseState, onOutput);
|
||||
output = appendLimited(output, `${redactJvmDiagnosticLine(line)}\n`);
|
||||
parseJvmLine(line, onArchiveProgress, parseState, onOutput, onPasswordAttempt);
|
||||
}
|
||||
if (parseState.outputError && !outputCallbackKillStarted) {
|
||||
outputCallbackKillStarted = true;
|
||||
@@ -2255,7 +2428,7 @@ async function runJvmExtractCommand(
|
||||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||||
};
|
||||
|
||||
const finish = (result: JvmExtractResult): void => {
|
||||
const finish = (result: JvmExtractBaseResult): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -2285,7 +2458,10 @@ async function runJvmExtractCommand(
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
cleanupTmpDir();
|
||||
resolve(finalResult);
|
||||
resolve({
|
||||
...finalResult,
|
||||
...jvmPasswordAttemptResult(parseState, finalResult.ok)
|
||||
});
|
||||
};
|
||||
|
||||
if (timeoutMs && timeoutMs > 0) {
|
||||
@@ -2326,8 +2502,16 @@ async function runJvmExtractCommand(
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput);
|
||||
parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput);
|
||||
if (stdoutBuffer) {
|
||||
output = appendLimited(output, redactJvmDiagnosticLine(stdoutBuffer));
|
||||
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput, onPasswordAttempt);
|
||||
stdoutBuffer = "";
|
||||
}
|
||||
if (stderrBuffer) {
|
||||
output = appendLimited(output, redactJvmDiagnosticLine(stderrBuffer));
|
||||
parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput, onPasswordAttempt);
|
||||
stderrBuffer = "";
|
||||
}
|
||||
|
||||
if (abortedBySignal) {
|
||||
finish({
|
||||
@@ -2857,8 +3041,6 @@ function createNativeOutputCollector(
|
||||
};
|
||||
}
|
||||
|
||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function runExternalExtractInner(
|
||||
command: string,
|
||||
archivePath: string,
|
||||
@@ -2877,6 +3059,8 @@ async function runExternalExtractInner(
|
||||
): Promise<string> {
|
||||
const passwords = passwordCandidates;
|
||||
let lastError = "";
|
||||
let integrityError = "";
|
||||
let terminalError = false;
|
||||
const extractorName = path.basename(command).replace(/\.exe$/i, "") || command;
|
||||
|
||||
const emptyPasswordCount = passwords.filter((candidate) => candidate === "").length;
|
||||
@@ -2960,7 +3144,11 @@ async function runExternalExtractInner(
|
||||
onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||
if (passwords.length > 1) {
|
||||
onPasswordAttempt?.(passwordAttempt, passwords.length);
|
||||
try {
|
||||
onPasswordAttempt?.(passwordAttempt, passwords.length);
|
||||
} catch {
|
||||
logger.warn("Legacy-Passwortfortschritt-Callback fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
|
||||
let result = await runNativeAttempt(args, password);
|
||||
@@ -2985,6 +3173,9 @@ async function runExternalExtractInner(
|
||||
logger.info(`Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`);
|
||||
onLog?.("INFO", `Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`);
|
||||
}
|
||||
if (bestPercent > 0 && classifyExtractionError(result.errorText) === "crc_error") {
|
||||
integrityError = result.errorText;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
@@ -3005,6 +3196,7 @@ async function runExternalExtractInner(
|
||||
|
||||
if (result.timedOut) {
|
||||
lastError = result.errorText;
|
||||
terminalError = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3044,7 +3236,8 @@ async function runExternalExtractInner(
|
||||
}
|
||||
}
|
||||
|
||||
throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||
const finalError = terminalError ? lastError : integrityError || lastError;
|
||||
throw withExtractionErrorHints(new Error(finalError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||
}
|
||||
|
||||
async function runExternalExtract(
|
||||
@@ -3067,7 +3260,8 @@ async function runExternalExtract(
|
||||
const archiveName = path.basename(archivePath);
|
||||
const totalStartedAt = Date.now();
|
||||
let jvmFailureReason = "";
|
||||
let jvmCodecError = false;
|
||||
let initialJvmFailureCategory: ExtractErrorCategory | null = null;
|
||||
let initialJvmPasswordCandidatesExhausted = false;
|
||||
let fallbackFromJvm = false;
|
||||
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||||
onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||||
@@ -3092,7 +3286,7 @@ async function runExternalExtract(
|
||||
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
|
||||
const jvmResult = await runJvmExtractCommand(
|
||||
layout, archivePath, targetDir, conflictMode, passwordCandidates,
|
||||
onArchiveProgress, signal, timeoutMs, onOutput
|
||||
onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt
|
||||
);
|
||||
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"}`);
|
||||
@@ -3111,15 +3305,15 @@ async function runExternalExtract(
|
||||
}
|
||||
|
||||
jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen";
|
||||
initialJvmFailureCategory = classifyExtractionError(jvmFailureReason);
|
||||
initialJvmPasswordCandidatesExhausted = jvmResult.passwordCandidatesExhausted;
|
||||
fallbackFromJvm = true;
|
||||
const jvmFailureLower = jvmFailureReason.toLowerCase();
|
||||
const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD");
|
||||
const isCodecError = jvmFailureLower.includes("registered codecs")
|
||||
|| jvmFailureLower.includes("can not open")
|
||||
|| jvmFailureLower.includes("cannot open archive");
|
||||
jvmCodecError = isCodecError;
|
||||
const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD")
|
||||
|| jvmFailureLower.includes("wrong password");
|
||||
const isWrongPassword = initialJvmFailureCategory === "wrong_password";
|
||||
const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword;
|
||||
onLog?.("WARN", `JVM-Extractor Fallback-Analyse: archive=${archiveName}, unsupportedMethod=${isUnsupportedMethod}, codecError=${isCodecError}, wrongPassword=${isWrongPassword}, backendMode=${backendMode}`);
|
||||
if (backendMode === "jvm" && !shouldFallbackToLegacy) {
|
||||
@@ -3163,7 +3357,10 @@ async function runExternalExtract(
|
||||
const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName);
|
||||
const errText = String((primaryError as Error)?.message || primaryError || "");
|
||||
const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText);
|
||||
if (isRar && isPasswordOrCorrupt && !signal?.aborted) {
|
||||
if (isRar
|
||||
&& isPasswordOrCorrupt
|
||||
&& shouldRunAlternativeNativeExtractor(command, archivePath, configuredBackendMode, backendMode)
|
||||
&& !signal?.aborted) {
|
||||
const alt = await findAlternativeExtractor(command, archivePath);
|
||||
if (alt) {
|
||||
const altName = path.basename(alt).replace(/\.exe$/i, "");
|
||||
@@ -3189,72 +3386,14 @@ async function runExternalExtract(
|
||||
const initialLegacyBestPercent = Number.isFinite(initialLegacyHints.legacyBestPercent)
|
||||
? Number(initialLegacyHints.legacyBestPercent || 0)
|
||||
: 0;
|
||||
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
||||
let finalLegacyError: Error;
|
||||
|
||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||
const retryDelayMs = 2500;
|
||||
logger.warn(
|
||||
`Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`
|
||||
);
|
||||
onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`);
|
||||
await extractRetryDelay(retryDelayMs);
|
||||
if (!signal?.aborted) {
|
||||
try {
|
||||
const retryCmd = usedCommand;
|
||||
const retryPassword = await runExternalExtractInner(
|
||||
retryCmd,
|
||||
archivePath,
|
||||
effectiveTargetDir,
|
||||
conflictMode,
|
||||
passwordCandidates,
|
||||
onArchiveProgress,
|
||||
signal,
|
||||
timeoutMs,
|
||||
hybridMode,
|
||||
onPasswordAttempt,
|
||||
forceFlatMode,
|
||||
flatModeResult,
|
||||
onLog,
|
||||
legacyOnOutput
|
||||
);
|
||||
logger.info(`Legacy-Retry erfolgreich: ${archiveName}`);
|
||||
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
||||
password = retryPassword;
|
||||
usedCommand = retryCmd;
|
||||
const retryExtractorName = path.basename(retryCmd).replace(/\.exe$/i, "");
|
||||
const retryLegacyMs = Date.now() - legacyStartedAt;
|
||||
if (jvmFailureReason) {
|
||||
logger.info(`Entpackt via legacy/${retryExtractorName} (nach JVM-Fehler): ${archiveName}`);
|
||||
} else {
|
||||
logger.info(`Entpackt via legacy/${retryExtractorName} (nach Legacy-Retry): ${archiveName}`);
|
||||
}
|
||||
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||||
onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||||
return password;
|
||||
} catch (retryError) {
|
||||
const retryText = String((retryError as Error)?.message || retryError || "");
|
||||
const retryCategory = classifyExtractionError(retryText);
|
||||
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
||||
finalLegacyError = withExtractionErrorHints(retryError, {
|
||||
suggestRedownload,
|
||||
jvmFailureReason: jvmFailureReason || undefined
|
||||
});
|
||||
}
|
||||
} else {
|
||||
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||
jvmFailureReason: jvmFailureReason || undefined
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
||||
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||
suggestRedownload,
|
||||
jvmFailureReason: jvmFailureReason || undefined
|
||||
});
|
||||
}
|
||||
let finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||
suggestRedownload: initialJvmFailureCategory !== null && shouldSuggestRedownloadAfterCrossBackendFailure(
|
||||
initialLegacyCategory,
|
||||
initialJvmFailureCategory,
|
||||
initialJvmPasswordCandidatesExhausted
|
||||
),
|
||||
jvmFailureReason: jvmFailureReason || undefined
|
||||
});
|
||||
|
||||
const finalLegacyHints = finalLegacyError as ExtractionErrorWithHints;
|
||||
const finalLegacyText = String(finalLegacyError?.message || finalLegacyError || "");
|
||||
@@ -3277,7 +3416,8 @@ async function runExternalExtract(
|
||||
onArchiveProgress,
|
||||
signal,
|
||||
timeoutMs,
|
||||
onOutput
|
||||
onOutput,
|
||||
onPasswordAttempt
|
||||
);
|
||||
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"}`);
|
||||
@@ -3291,8 +3431,14 @@ async function runExternalExtract(
|
||||
if (jvmResult.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
const fallbackJvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen";
|
||||
finalLegacyError = withExtractionErrorHints(finalLegacyError, {
|
||||
jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen"
|
||||
suggestRedownload: shouldSuggestRedownloadAfterCrossBackendFailure(
|
||||
classifyExtractionError(finalLegacyText),
|
||||
classifyExtractionError(fallbackJvmFailureReason),
|
||||
jvmResult.passwordCandidatesExhausted
|
||||
),
|
||||
jvmFailureReason: fallbackJvmFailureReason
|
||||
});
|
||||
logger.warn(`Legacy->JVM-Fallback ebenfalls fehlgeschlagen: ${archiveName} (${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")})`);
|
||||
onLog?.("WARN", `Legacy->JVM-Fallback ebenfalls fehlgeschlagen: archive=${archiveName}, error=${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")}`);
|
||||
@@ -3851,6 +3997,15 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
const safeOnLog: ExtractOptions["onLog"] = options.onLog
|
||||
? (level, message) => {
|
||||
try {
|
||||
options.onLog?.(level, message);
|
||||
} catch {
|
||||
logger.warn("Extract-Log-Callback fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const outputScope = new PackageOutputScope([options.targetDir]);
|
||||
const emitOutput = (event: ExtractOutputEvent): void => {
|
||||
outputScope.add(event);
|
||||
@@ -3889,7 +4044,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
candidates.push(...await findArchiveCandidates(options.packageDir));
|
||||
}
|
||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? " (explizite Liste)" : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? " (explizite Liste)" : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||
safeOnLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? " (explizite Liste)" : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
||||
@@ -3949,7 +4104,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
if (cachedPackagePassword) {
|
||||
passwordCandidates = prioritizePassword(passwordCandidates, cachedPackagePassword);
|
||||
logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||||
options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||||
safeOnLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||||
}
|
||||
const resumeCompleted = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId);
|
||||
const resumeCompletedAtStart = resumeCompleted.size;
|
||||
@@ -4017,7 +4172,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
writeCachedPackagePassword(passwordCacheKey, normalized);
|
||||
if (changed) {
|
||||
logger.info(`Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
||||
options.onLog?.("INFO", `Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
||||
safeOnLog?.("INFO", `Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4144,9 +4299,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
}
|
||||
|
||||
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`);
|
||||
options.onLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`);
|
||||
safeOnLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`);
|
||||
const emptyArchivePasswordCount = archivePasswordCandidates.filter((candidate) => candidate === "").length;
|
||||
options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`);
|
||||
safeOnLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`);
|
||||
const hasManyPasswords = archivePasswordCandidates.length > 1;
|
||||
if (hasManyPasswords) {
|
||||
emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }, undefined, archivePath);
|
||||
@@ -4155,7 +4310,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
? (attempt: number, total: number) => {
|
||||
activePasswordProgress = mergeExtractPasswordProgress(activePasswordProgress, { passwordAttempt: attempt, passwordTotal: total });
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath);
|
||||
options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`);
|
||||
safeOnLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`);
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
@@ -4167,7 +4322,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
try {
|
||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||
reportArchiveProgress(value);
|
||||
}, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
|
||||
}, signal, hybrid, onPwAttempt, false, undefined, safeOnLog, emitOutput);
|
||||
rememberLearnedPassword(usedPassword);
|
||||
} catch (error) {
|
||||
if (isNoExtractorError(String(error))) {
|
||||
@@ -4187,7 +4342,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
try {
|
||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||
reportArchiveProgress(value);
|
||||
}, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput);
|
||||
}, signal, hybrid, onPwAttempt, false, undefined, safeOnLog, emitOutput);
|
||||
rememberLearnedPassword(usedPassword);
|
||||
} catch (externalError) {
|
||||
throw selectZipFallbackError(error, externalError);
|
||||
@@ -4198,7 +4353,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
const flatResult = { needed: false };
|
||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||||
reportArchiveProgress(value);
|
||||
}, signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput);
|
||||
}, signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, safeOnLog, emitOutput);
|
||||
rememberLearnedPassword(usedPassword);
|
||||
if (flatResult.needed) packageNeedsFlatMode = true;
|
||||
}
|
||||
@@ -4211,7 +4366,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||
}
|
||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||
options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||
safeOnLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||
archiveOutcome = "success";
|
||||
const successAt = Date.now();
|
||||
const tailAfter99Ms = reached99At ? (successAt - reached99At) : -1;
|
||||
@@ -4242,12 +4397,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
jvmFailureReason: hintedError?.jvmFailureReason
|
||||
});
|
||||
logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
||||
options.onLog?.("ERROR", `Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
||||
safeOnLog?.("ERROR", `Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
||||
if (errorCategory === "wrong_password" && learnedPassword) {
|
||||
learnedPassword = "";
|
||||
clearCachedPackagePassword(passwordCacheKey);
|
||||
logger.warn(`Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
||||
options.onLog?.("WARN", `Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
||||
safeOnLog?.("WARN", `Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
||||
}
|
||||
const failedAt = Date.now();
|
||||
const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1;
|
||||
@@ -4282,7 +4437,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
let parallelQueue = pendingCandidates;
|
||||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||
options.onLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||
safeOnLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||
const first = pendingCandidates[0];
|
||||
try {
|
||||
await scheduleArchive(first, (signal) => extractSingleArchive(first, signal));
|
||||
@@ -4329,52 +4484,39 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
|
||||
if (abortError) throw new Error("aborted:extract");
|
||||
|
||||
if (failed > 0 && extracted === 0) {
|
||||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap)));
|
||||
const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown");
|
||||
if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) {
|
||||
const categorySummary = [...new Set(failedCategories)].join(",");
|
||||
logger.info(
|
||||
`Serielle Wiederholung nach Parallel-Fehlstart: ${failedArchives.length} Archive werden einzeln wiederholt ` +
|
||||
`(categories=${categorySummary || "unknown"})`
|
||||
);
|
||||
let retryRecovered = 0;
|
||||
for (const archivePath of failedArchives) {
|
||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||
try {
|
||||
failed -= 1;
|
||||
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
|
||||
const serialRecoveryByPath = new Map<string, string>();
|
||||
for (const archivePath of parallelQueue) {
|
||||
const archiveKey = pathSetKey(path.resolve(archivePath));
|
||||
if (extractedArchives.has(archivePath)
|
||||
|| resumedArchivePaths.has(archiveKey)
|
||||
|| failedArchiveCategories.get(archivePath) !== "unknown") {
|
||||
continue;
|
||||
}
|
||||
serialRecoveryByPath.set(archiveKey, archivePath);
|
||||
}
|
||||
const serialRecoveryArchives = [...serialRecoveryByPath.values()];
|
||||
if (serialRecoveryArchives.length > 0) {
|
||||
logger.info(`Serielle Wiederholung: ${serialRecoveryArchives.length} unbekannte Parallelfehler werden einmal einzeln wiederholt`);
|
||||
let retryRecovered = 0;
|
||||
for (const archivePath of serialRecoveryArchives) {
|
||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||
const failedBeforeRetry = failed;
|
||||
try {
|
||||
failed -= 1;
|
||||
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
|
||||
if (extractedArchives.has(archivePath)) {
|
||||
retryRecovered += 1;
|
||||
} catch (retryError) {
|
||||
const errText = String(retryError);
|
||||
if (isExtractAbortError(errText)) throw retryError;
|
||||
}
|
||||
}
|
||||
if (retryRecovered > 0) {
|
||||
logger.info(`Serielle Wiederholung nach Parallel-Fehlstart: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`);
|
||||
} catch (retryError) {
|
||||
if (!extractedArchives.has(archivePath)) {
|
||||
failed = failedBeforeRetry;
|
||||
}
|
||||
const errText = String(retryError);
|
||||
if (isExtractAbortError(errText)) throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0 && extracted > 0) {
|
||||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap)));
|
||||
if (failedArchives.length > 0) {
|
||||
logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`);
|
||||
let retryRecovered = 0;
|
||||
for (const archivePath of failedArchives) {
|
||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||
try {
|
||||
failed -= 1;
|
||||
await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal));
|
||||
retryRecovered += 1;
|
||||
} catch (retryError) {
|
||||
const errText = String(retryError);
|
||||
if (isExtractAbortError(errText)) throw retryError;
|
||||
}
|
||||
}
|
||||
if (retryRecovered > 0) {
|
||||
logger.info(`Serielle Wiederholung: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`);
|
||||
}
|
||||
if (retryRecovered > 0) {
|
||||
logger.info(`Serielle Wiederholung: ${retryRecovered}/${serialRecoveryArchives.length} Archive erfolgreich entpackt`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4445,11 +4587,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
nestedPercent = 100;
|
||||
} catch (zipErr) {
|
||||
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
|
||||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
|
||||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, safeOnLog, emitOutput);
|
||||
rememberLearnedPassword(usedPw);
|
||||
}
|
||||
} else {
|
||||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput);
|
||||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, safeOnLog, emitOutput);
|
||||
rememberLearnedPassword(usedPw);
|
||||
}
|
||||
});
|
||||
|
||||
+2
-8
@@ -26,8 +26,8 @@ import { migrateProductUserDataDirectory } from "./storage";
|
||||
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
|
||||
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
||||
import { forceDarkNativeTheme } from "./native-theme";
|
||||
import { normalizeExtractNowRequest } from "../shared/extract-now";
|
||||
import { validateClipboardWriteText } from "./clipboard-write";
|
||||
import { registerExtractionIpcHandlers } from "./extraction-ipc";
|
||||
|
||||
forceDarkNativeTheme(nativeTheme);
|
||||
|
||||
@@ -642,13 +642,7 @@ function registerIpcHandlers(): void {
|
||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.retryExtraction(packageId);
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => {
|
||||
return controller.extractNow(normalizeExtractNowRequest(request));
|
||||
});
|
||||
registerExtractionIpcHandlers((channel, listener) => handleTrusted(channel, listener), controller);
|
||||
handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.resetPackage(packageId);
|
||||
|
||||
+59
-29
@@ -30,7 +30,10 @@ const pairs = [
|
||||
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
||||
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
|
||||
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
||||
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["CRC-Check läuft", "CRC check running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||
["Entpacken - Ausstehend", "Extracting - Pending"], ["Entpacken - Warten auf Parts", "Extracting - Waiting for parts"],
|
||||
["Archive stabilisieren...", "Stabilizing archives..."], ["Entpacken vorbereiten...", "Preparing extraction..."], ["Entpacken wird neu gestartet...", "Restarting extraction..."], ["Nested Entpacken...", "Nested extraction..."], ["Umbenennen...", "Renaming..."],
|
||||
["Tonspur...", "Audio track..."], ["Aufräumen...", "Cleaning up..."], ["Verschiebe Videos...", "Moving videos..."],
|
||||
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Laufzeit", "Runtime"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
||||
["Provider-Laufzeit", "Provider runtime"], ["Account-Laufzeit", "Account runtime"], ["Aktive Downloads", "Active downloads"], ["Erfolgsquote · Diese Sitzung", "Success rate · This session"], ["Zuletzt verwendet", "Last used"], ["Cooldown / Grund", "Cooldown / reason"],
|
||||
["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["gerade eben", "just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"],
|
||||
@@ -224,35 +227,58 @@ export function normalizeLanguage(value: unknown): AppLanguage {
|
||||
}
|
||||
|
||||
function translatePackageStatusParts(value: string, language: AppLanguage): string | null {
|
||||
const parts = value.split(" · ");
|
||||
const parts = value.split(/( · |\n)/);
|
||||
if (parts.length < 2) return null;
|
||||
const translated = parts.map((part): string | null => {
|
||||
if (language === "en") {
|
||||
const exact = deToEn.get(part);
|
||||
if (exact) return exact;
|
||||
const extractionError = part.match(/^(\d+) Entpackfehler$/);
|
||||
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
|
||||
const retry = part.match(/^(\d+) Wiederholung(?:en)?$/);
|
||||
if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`;
|
||||
const error = part.match(/^(\d+) Fehler$/);
|
||||
if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`;
|
||||
const cancelled = part.match(/^(\d+) abgebrochen$/);
|
||||
if (cancelled) return `${cancelled[1]} cancelled`;
|
||||
return null;
|
||||
let changed = false;
|
||||
const translated = parts.map((part): string => {
|
||||
if (part === " · " || part === "\n") return part;
|
||||
const runtime = translateRuntimeStatusPart(part, language);
|
||||
if (runtime !== null) {
|
||||
if (runtime !== part) changed = true;
|
||||
return runtime;
|
||||
}
|
||||
const exact = enToDe.get(part);
|
||||
if (exact) return exact;
|
||||
const extractionError = part.match(/^(\d+) extraction errors?$/);
|
||||
if (extractionError) return `${extractionError[1]} Entpackfehler`;
|
||||
const retry = part.match(/^(\d+) retr(?:y|ies)$/);
|
||||
if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`;
|
||||
const error = part.match(/^(\d+) errors?$/);
|
||||
if (error) return `${error[1]} Fehler`;
|
||||
const cancelled = part.match(/^(\d+) cancelled$/);
|
||||
if (cancelled) return `${cancelled[1]} abgebrochen`;
|
||||
return null;
|
||||
return part;
|
||||
});
|
||||
return translated.every((part): part is string => part !== null) ? translated.join(" · ") : null;
|
||||
return changed ? translated.join("") : null;
|
||||
}
|
||||
|
||||
function translateRuntimeStatusPart(value: string, language: AppLanguage): string | null {
|
||||
const exact = (language === "en" ? deToEn : enToDe).get(value);
|
||||
if (exact) return exact;
|
||||
if (language === "en") {
|
||||
const extracting = value.match(/^Entpacken(\s*-\s*|\s+)(\d+%)(.*)$/);
|
||||
if (extracting) return `Extracting${extracting[1].includes("-") ? " - " : " "}${extracting[2]}${extracting[3]}`;
|
||||
if (value === "Passwort gefunden") return "Password found";
|
||||
const password = value.match(/^Passwort knacken:\s*(\d+%)\s*(\(\d+\/\d+\))?(.*)$/);
|
||||
if (password) return `Cracking password: ${password[1]}${password[2] ? ` ${password[2]}` : ""}${password[3]}`;
|
||||
const nextArchive = value.match(/^Entpacken\s*(\(\d+\/\d+\))\s*-\s*Nächstes Archiv\.\.\.(.*)$/);
|
||||
if (nextArchive) return `Extracting ${nextArchive[1]} - Next archive...${nextArchive[2]}`;
|
||||
const extractionError = value.match(/^(\d+) Entpackfehler$/);
|
||||
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
|
||||
const retry = value.match(/^(\d+) Wiederholung(?:en)?$/);
|
||||
if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`;
|
||||
const error = value.match(/^(\d+) Fehler$/);
|
||||
if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`;
|
||||
const cancelled = value.match(/^(\d+) abgebrochen$/);
|
||||
if (cancelled) return `${cancelled[1]} cancelled`;
|
||||
} else {
|
||||
const extracting = value.match(/^Extracting(\s*-\s*|\s+)(\d+%)(.*)$/);
|
||||
if (extracting) return `Entpacken${extracting[1].includes("-") ? " - " : " "}${extracting[2]}${extracting[3]}`;
|
||||
if (value === "Password found") return "Passwort gefunden";
|
||||
const password = value.match(/^Cracking password:\s*(\d+%)\s*(\(\d+\/\d+\))?(.*)$/);
|
||||
if (password) return `Passwort knacken: ${password[1]}${password[2] ? ` ${password[2]}` : ""}${password[3]}`;
|
||||
const nextArchive = value.match(/^Extracting\s*(\(\d+\/\d+\))\s*-\s*Next archive\.\.\.(.*)$/);
|
||||
if (nextArchive) return `Entpacken ${nextArchive[1]} - Nächstes Archiv...${nextArchive[2]}`;
|
||||
const extractionError = value.match(/^(\d+) extraction errors?$/);
|
||||
if (extractionError) return `${extractionError[1]} Entpackfehler`;
|
||||
const retry = value.match(/^(\d+) retr(?:y|ies)$/);
|
||||
if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`;
|
||||
const error = value.match(/^(\d+) errors?$/);
|
||||
if (error) return `${error[1]} Fehler`;
|
||||
const cancelled = value.match(/^(\d+) cancelled$/);
|
||||
if (cancelled) return `${cancelled[1]} abgebrochen`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function translateDynamic(value: string, language: AppLanguage): string {
|
||||
@@ -260,8 +286,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
const source = language === "en" ? german : english;
|
||||
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
|
||||
}
|
||||
const packageStatus = translatePackageStatusParts(value, language);
|
||||
if (packageStatus) return packageStatus;
|
||||
const runtimeStatus = translateRuntimeStatusPart(value, language);
|
||||
if (runtimeStatus) return runtimeStatus;
|
||||
if (language === "en") {
|
||||
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
||||
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
|
||||
@@ -445,6 +471,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
[/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"], [/^(.+) kopieren$/, "Copy $1"]
|
||||
];
|
||||
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||
const packageStatus = translatePackageStatusParts(value, language);
|
||||
if (packageStatus) return packageStatus;
|
||||
return value
|
||||
.replace(/Automatisch entpacken/g, "Extract automatically")
|
||||
.replace(/Wartet auf Wiederholung/g, "Waiting to retry")
|
||||
@@ -631,6 +659,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
[/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"], [/^Copy (.+)$/, "$1 kopieren"]
|
||||
];
|
||||
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||
const packageStatus = translatePackageStatusParts(value, language);
|
||||
if (packageStatus) return packageStatus;
|
||||
return value
|
||||
.replace(/Extract automatically/g, "Automatisch entpacken")
|
||||
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
|
||||
|
||||
@@ -392,14 +392,9 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
||||
? "Entpacken - Ausstehend"
|
||||
: compactPostProcessLabel;
|
||||
const detailPostProcessLabel = rawPostProcessLabel || postProcessLabel;
|
||||
const detailPostProcessLabel = presentation.activeOperationLabel ? rawPostProcessLabel || postProcessLabel : "";
|
||||
const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||
const status = presentation.extractFailureCount === 0
|
||||
&& presentation.retryCount === 0
|
||||
&& postProcessLabel
|
||||
&& (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||
? postProcessLabel
|
||||
: presentation.status;
|
||||
const status = presentation.status;
|
||||
const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
|
||||
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
|
||||
return <DownloadStatusCell status={status} title={title} />;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PackagePresentation {
|
||||
progress: PackageProgressPresentation;
|
||||
status: string;
|
||||
details: string;
|
||||
activeOperationLabel: string;
|
||||
extractFailure?: DownloadItem;
|
||||
extractFailureCount: number;
|
||||
retryCount: number;
|
||||
@@ -46,6 +47,16 @@ function isLinkConversionRetry(item: DownloadItem): boolean {
|
||||
return /(?:Link-Umwandlung erneut|Retrying link conversion)/i.test(item.fullStatus || "");
|
||||
}
|
||||
|
||||
function activePackageOperationLabel(packageStatus: DownloadPackageRow["package"]["status"], label: string): string {
|
||||
if (!label) return "";
|
||||
if (/^(?:Entpacken\s+\d+%|Entpacken\s*\(\d+\/\d+\)\s*-\s*Nächstes Archiv|Passwort\b|Password\b|Finalisieren\b|Finalizing\b)/i.test(label)) {
|
||||
return packageStatus === "extracting" ? label : "";
|
||||
}
|
||||
return /^(?:Archive stabilisieren|Entpacken vorbereiten|Entpacken wird neu gestartet|Nested Entpacken|Renaming|Tonspur|Aufräumen|Verschiebe Videos)\b/i.test(label)
|
||||
? label
|
||||
: "";
|
||||
}
|
||||
|
||||
function downloadFraction(item: DownloadItem): number {
|
||||
if (item.status === "completed") {
|
||||
return 1;
|
||||
@@ -71,6 +82,9 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
let retrying = 0;
|
||||
let linkConversionRetrying = 0;
|
||||
let waitsForDisk = 0;
|
||||
let integrityChecking = 0;
|
||||
let extractionPending = 0;
|
||||
let waitingForParts = 0;
|
||||
const extractFailures: DownloadItem[] = [];
|
||||
|
||||
for (const item of row.allItems) {
|
||||
@@ -79,12 +93,15 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
else if (item.status === "cancelled") cancelled += 1;
|
||||
downloadUnits += downloadFraction(item);
|
||||
const fullStatus = item.fullStatus || "";
|
||||
if (item.status === "integrity_check") integrityChecking += 1;
|
||||
if (/^Entpacken\s*-\s*Ausstehend\b/i.test(fullStatus)) extractionPending += 1;
|
||||
if (/^Entpacken\s*-\s*Warten auf Parts\b/i.test(fullStatus)) waitingForParts += 1;
|
||||
if (/^Entpackt\b/i.test(fullStatus)) {
|
||||
extractionUnits += 1;
|
||||
extractionLifecycle = true;
|
||||
} else {
|
||||
const progress = extractionPercent(fullStatus);
|
||||
if (progress > 0 || /^(?:Entpacken|Finalisieren)\b/i.test(fullStatus)) {
|
||||
if (progress > 0 || /^Entpacken\s+\d+%/i.test(fullStatus) || /^Finalisieren\b/i.test(fullStatus)) {
|
||||
extracting += 1;
|
||||
extractionUnits += progress;
|
||||
}
|
||||
@@ -119,29 +136,41 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`;
|
||||
const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1);
|
||||
const packageExtractLabel = (row.package.postProcessLabel || "").trim();
|
||||
const activeOperationLabel = activePackageOperationLabel(row.package.status, packageExtractLabel);
|
||||
const downloading = row.package.status === "downloading"
|
||||
|| row.package.status === "validating"
|
||||
|| row.allItems.some((item) => item.status === "downloading" || item.status === "validating");
|
||||
|
||||
let status = allExtracted ? "Entpackt" : details;
|
||||
if (extractFailures.length > 0 && retrying > 0) {
|
||||
status = `${extractFailures.length} Entpackfehler · ${retryLabel}`;
|
||||
} else if (extractFailures.length > 0) {
|
||||
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
|
||||
if (activeOperationLabel) {
|
||||
status = activeOperationLabel;
|
||||
} else if (waitsForDisk > 0) {
|
||||
status = "Warte auf Festplatte";
|
||||
} else if (extracting > 0 || row.package.status === "extracting") {
|
||||
status = packageExtractLabel || "Entpacken";
|
||||
} else if (integrityChecking > 0) {
|
||||
status = "CRC-Check läuft";
|
||||
} else if (extractFailures.length > 0 && retrying > 0) {
|
||||
status = `${extractFailures.length} Entpackfehler · ${retryLabel}`;
|
||||
} else if (retrying > 0) {
|
||||
status = retryLabel;
|
||||
} else if (downloading) {
|
||||
status = "Download läuft";
|
||||
} else if (extractFailures.length > 0) {
|
||||
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
|
||||
} else if (extracting > 0) {
|
||||
status = "Entpacken";
|
||||
} else if (extractionPending > 0) {
|
||||
status = "Entpacken - Ausstehend";
|
||||
} else if (waitingForParts > 0) {
|
||||
status = "Entpacken - Warten auf Parts";
|
||||
} else if (row.package.status === "extracting") {
|
||||
status = "Entpacken";
|
||||
}
|
||||
|
||||
return {
|
||||
progress: { done, failed, cancelled, total, value },
|
||||
status,
|
||||
details,
|
||||
activeOperationLabel,
|
||||
extractFailure: extractFailures[0],
|
||||
extractFailureCount: extractFailures.length,
|
||||
retryCount: retrying,
|
||||
|
||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, findCrcImplicatedArchiveItems, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveSelectedArchiveSetsFromCandidates, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||
import { ExtractionCoordinator } from "../src/main/extraction-coordinator";
|
||||
@@ -113,6 +113,13 @@ describe("selected item run scope", () => {
|
||||
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
|
||||
|
||||
internal.session.items[itemIds[1]].status = "reconnect_wait";
|
||||
internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF";
|
||||
internal.retryAfterByItem.set(itemIds[1], 99_000);
|
||||
manager.stop();
|
||||
expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" }));
|
||||
expect(internal.retryAfterByItem.get(itemIds[1])).toBe(99_000);
|
||||
});
|
||||
|
||||
it("stops only selected run items without erasing sibling wait state", async () => {
|
||||
@@ -150,6 +157,114 @@ describe("selected item run scope", () => {
|
||||
expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true);
|
||||
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not widen an exhausted selected scope to an unselected sibling", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
internal.runItemIds.delete(itemIds[0]);
|
||||
internal.runPackageIds.clear();
|
||||
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
|
||||
|
||||
internal.session.items[itemIds[1]].status = "reconnect_wait";
|
||||
internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF";
|
||||
internal.retryAfterByItem.set(itemIds[1], Date.now() + 60_000);
|
||||
manager.stop();
|
||||
expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" }));
|
||||
expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true);
|
||||
});
|
||||
|
||||
it("resuming preserves item, disk and provider cooldown state", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
internal.session.paused = true;
|
||||
const now = Date.now();
|
||||
internal.retryAfterByItem.set(itemIds[0], now + 11_000);
|
||||
internal.providerStartReservations.set("provider-key", now + 12_000);
|
||||
internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000);
|
||||
internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 });
|
||||
|
||||
manager.togglePause();
|
||||
|
||||
expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000);
|
||||
expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000);
|
||||
expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000);
|
||||
expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000);
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a newly added package outside a selected running scope", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-add-package-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
|
||||
manager.addPackages([{ name: "added-later", links: ["https://dummy/added-later"] }]);
|
||||
const addedPackageId = internal.session.packageOrder.at(-1);
|
||||
const addedItemId = internal.session.packages[addedPackageId].itemIds[0];
|
||||
|
||||
expect(internal.runPackageIds.has(addedPackageId)).toBe(false);
|
||||
expect(internal.runItemIds.has(addedItemId)).toBe(false);
|
||||
expect(internal.findNextQueuedItem()?.itemId).not.toBe(addedItemId);
|
||||
});
|
||||
|
||||
it("computes provider retry only from selected run items", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-provider-retry-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
const deadline = Date.now() + 30_000;
|
||||
internal.debridService.getBlockingProviderRetryAt = (url: string) => url.endsWith("/first") ? deadline : null;
|
||||
|
||||
expect(internal.getEarliestProviderRetryAt(Date.now())).toBe(deadline);
|
||||
});
|
||||
|
||||
it.each(["items", "packages"] as const)("does not trigger foreign post-processing from a selected %s start", async (mode) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-selected-postprocess-${mode}-`));
|
||||
tempDirs.push(root);
|
||||
const { manager, packageId, itemIds } = createSelectedItemManager(root);
|
||||
manager.addPackages([{ name: "foreign-completed", links: ["https://dummy/foreign.rar"] }]);
|
||||
const internal = manager as any;
|
||||
const foreignPackageId = internal.session.packageOrder.find((id: string) => id !== packageId);
|
||||
const foreignItemId = internal.session.packages[foreignPackageId].itemIds[0];
|
||||
internal.session.items[foreignItemId].status = "completed";
|
||||
internal.session.items[foreignItemId].downloadedBytes = 100;
|
||||
internal.session.items[foreignItemId].totalBytes = 100;
|
||||
internal.session.items[foreignItemId].progressPercent = 100;
|
||||
internal.session.items[foreignItemId].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.session.packages[foreignPackageId].status = "completed";
|
||||
internal.ensureScheduler = async () => {};
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
if (mode === "items") {
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
} else {
|
||||
await internal.startPackagesNow([packageId]);
|
||||
}
|
||||
|
||||
expect(postProcess).not.toHaveBeenCalledWith(foreignPackageId);
|
||||
expect(internal.runPackageIds.has(foreignPackageId)).toBe(false);
|
||||
expect(internal.runItemIds.has(foreignItemId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download live update cadence", () => {
|
||||
@@ -7228,7 +7343,7 @@ describe("download manager", () => {
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
@@ -7326,7 +7441,7 @@ describe("download manager", () => {
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
@@ -7420,7 +7535,7 @@ describe("download manager", () => {
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
@@ -7432,6 +7547,26 @@ describe("download manager", () => {
|
||||
expect(item.targetPath).toContain(".rar");
|
||||
expect(item.downloadedBytes).toBe(archiveSize);
|
||||
}
|
||||
|
||||
for (const archiveName of archiveNames) {
|
||||
fs.writeFileSync(path.join(outputDir, archiveName), Buffer.alloc(archiveSize, 0x7f));
|
||||
}
|
||||
const wrongPasswordChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]!),
|
||||
{
|
||||
archiveName: "show.s01e01.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||
errorText: "Wrong password",
|
||||
category: "wrong_password",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "WRONG_PASSWORD"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
expect(wrongPasswordChanged).toBe(0);
|
||||
expect(itemIds.map((itemId) => session.items[itemId]!.status)).toEqual(["completed", "completed"]);
|
||||
expect(archiveNames.map((archiveName) => fs.existsSync(path.join(outputDir, archiveName)))).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
||||
@@ -7515,8 +7650,9 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
expect((manager as any).looksLikeArchivePart("show.s01e01.part2.rar", "show.s01e01.part1.rar")).toBe(true);
|
||||
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
||||
expect(Array.from(ready)).toHaveLength(0);
|
||||
expect(Array.from(ready)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows disk fallback when queued archive parts are fully present on disk", async () => {
|
||||
@@ -9804,6 +9940,140 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requeues confirmed corrupt volumes independently when one part is locked", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crc-specific-volume-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "crc-specific-volume-pkg";
|
||||
const outputDir = path.join(root, "downloads", "crc-specific-volume");
|
||||
const extractDir = path.join(root, "extract", "crc-specific-volume");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const archiveNames = ["show.part1.rar", "show.part2.rar", "show.part3.rar"];
|
||||
const itemIds = archiveNames.map((_, index) => `crc-specific-${index + 1}`);
|
||||
const createdAt = Date.now() - 1000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "crc-specific-volume",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "extracting",
|
||||
itemIds,
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
resultGeneration: 4,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
for (const [index, archiveName] of archiveNames.entries()) {
|
||||
const targetPath = path.join(outputDir, archiveName);
|
||||
const bytes = Buffer.alloc(64 * 1024, index + 1);
|
||||
Buffer.from("526172211a070100", "hex").copy(bytes, 0);
|
||||
fs.writeFileSync(targetPath, bytes);
|
||||
session.items[itemIds[index]] = {
|
||||
id: itemIds[index],
|
||||
packageId,
|
||||
url: `https://dummy/${archiveName}`,
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: bytes.length,
|
||||
totalBytes: bytes.length,
|
||||
progressPercent: 100,
|
||||
fileName: archiveName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const failure = {
|
||||
archiveName: "show.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.part1.rar"),
|
||||
errorText: `Prüfsummenfehler der gepackten Daten in Volume ${path.join(outputDir, "show.part3.rar")} Corrupt file or wrong password`,
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
} as const;
|
||||
|
||||
session.items[itemIds[0]].downloadedBytes = 0;
|
||||
const lockedPath = path.join(outputDir, "show.part1.rar");
|
||||
const implicatedPath = path.join(outputDir, "show.part3.rar");
|
||||
const originalRmSync = fs.rmSync.bind(fs);
|
||||
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((targetPath: fs.PathLike, options?: fs.RmDirOptions) => {
|
||||
if (path.resolve(String(targetPath)) === path.resolve(lockedPath)) {
|
||||
throw Object.assign(new Error("locked"), { code: "EPERM" });
|
||||
}
|
||||
return originalRmSync(targetPath, options as never);
|
||||
}) as typeof fs.rmSync);
|
||||
const blockedChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
rmSpy.mockRestore();
|
||||
expect(blockedChanged).toBe(1);
|
||||
expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "completed", targetPath: lockedPath }));
|
||||
expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" }));
|
||||
expect(fs.existsSync(lockedPath)).toBe(true);
|
||||
expect(fs.existsSync(implicatedPath)).toBe(false);
|
||||
|
||||
fs.writeFileSync(implicatedPath, Buffer.alloc(64 * 1024, 3));
|
||||
Object.assign(session.items[itemIds[2]], {
|
||||
status: "completed",
|
||||
targetPath: implicatedPath,
|
||||
downloadedBytes: 64 * 1024,
|
||||
totalBytes: 64 * 1024,
|
||||
progressPercent: 100,
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
const secondChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
|
||||
expect(secondChanged).toBe(1);
|
||||
expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" }));
|
||||
expect(session.items[itemIds[1]].status).toBe("completed");
|
||||
expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "completed", targetPath: implicatedPath }));
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[0]))).toBe(false);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[2]))).toBe(true);
|
||||
|
||||
fs.writeFileSync(lockedPath, Buffer.alloc(64 * 1024, 1));
|
||||
Object.assign(session.items[itemIds[0]], {
|
||||
status: "completed",
|
||||
targetPath: lockedPath,
|
||||
downloadedBytes: 64 * 1024,
|
||||
totalBytes: 64 * 1024,
|
||||
progressPercent: 100,
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
const repeatedChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
expect(repeatedChanged).toBe(0);
|
||||
expect(fs.existsSync(lockedPath)).toBe(true);
|
||||
expect(fs.existsSync(implicatedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -16713,3 +16983,576 @@ describe("download health snapshot", () => {
|
||||
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-processing lifecycle audit", () => {
|
||||
function createCompletedFileManager(
|
||||
root: string,
|
||||
entries: Array<{ packageId: string; itemId: string; fileName: string; url?: string; content?: Buffer }>,
|
||||
settings: Partial<AppSettings> = {}
|
||||
): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
|
||||
const session = emptySession();
|
||||
const createdAt = Date.now() - 10_000;
|
||||
for (const entry of entries) {
|
||||
const outputDir = path.join(root, "downloads", entry.packageId);
|
||||
const extractDir = path.join(root, "extract", entry.packageId);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const targetPath = path.join(outputDir, entry.fileName);
|
||||
fs.writeFileSync(targetPath, entry.content ?? Buffer.alloc(256, 1));
|
||||
if (!session.packages[entry.packageId]) {
|
||||
session.packageOrder.push(entry.packageId);
|
||||
session.packages[entry.packageId] = {
|
||||
id: entry.packageId,
|
||||
name: entry.packageId,
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
session.packages[entry.packageId].itemIds.push(entry.itemId);
|
||||
const bytes = fs.statSync(targetPath).size;
|
||||
session.items[entry.itemId] = {
|
||||
id: entry.itemId,
|
||||
packageId: entry.packageId,
|
||||
url: entry.url ?? `https://example.test/${entry.itemId}`,
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: bytes,
|
||||
totalBytes: bytes,
|
||||
progressPercent: 100,
|
||||
fileName: entry.fileName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
autoRename4sf4sj: false,
|
||||
collectMkvToLibrary: false,
|
||||
enableIntegrityCheck: false,
|
||||
cleanupMode: "none",
|
||||
...settings
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
return { manager, session };
|
||||
}
|
||||
|
||||
it("requeues an interrupted integrity check and clears transient package progress on restart", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "integrity-package", itemId: "integrity-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
session.items["integrity-item"].status = "integrity_check";
|
||||
session.items["integrity-item"].fullStatus = "CRC-Check läuft";
|
||||
session.packages["integrity-package"].status = "integrity_check";
|
||||
session.packages["integrity-package"].postProcessLabel = "Finalisieren (1/1)";
|
||||
|
||||
internal.normalizeSessionStatuses();
|
||||
|
||||
expect(session.items["integrity-item"]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" }));
|
||||
expect(session.packages["integrity-package"].status).toBe("queued");
|
||||
expect(session.packages["integrity-package"].postProcessLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps duplicate-suffixed items with independently existing files separate on startup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-distinct-urls-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "distinct-package", itemId: "first-url", fileName: "episode.rar", url: "https://same.test/file" },
|
||||
{ packageId: "distinct-package", itemId: "second-url", fileName: "episode (1).rar", url: "https://same.test/file" }
|
||||
]);
|
||||
|
||||
const snapshot = manager.getSnapshot().session;
|
||||
expect(snapshot.packages["distinct-package"].itemIds).toEqual(["first-url", "second-url"]);
|
||||
expect(snapshot.items["first-url"]).toBeDefined();
|
||||
expect(snapshot.items["second-url"]).toBeDefined();
|
||||
expect(fs.existsSync(session.items["second-url"].targetPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("moves startup cleanup archives to the recoverable trash in trash mode", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-trash-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "trash-package", itemId: "trash-item", fileName: "episode.part1.rar" }]);
|
||||
const internal = manager as any;
|
||||
session.items["trash-item"].fullStatus = "Entpackt - Fertig";
|
||||
internal.settings.cleanupMode = "trash";
|
||||
const archivePath = session.items["trash-item"].targetPath;
|
||||
|
||||
await internal.cleanupExistingExtractedArchives();
|
||||
|
||||
expect(fs.existsSync(archivePath)).toBe(false);
|
||||
expect(fs.readdirSync(path.join(path.dirname(archivePath), ".rd-trash"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let queued startup cleanup remove a newly replaced archive", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-cleanup-race-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "cleanup-race-package", itemId: "cleanup-race-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["cleanup-race-item"].targetPath;
|
||||
session.items["cleanup-race-item"].fullStatus = "Entpackt - Fertig";
|
||||
internal.settings.cleanupMode = "delete";
|
||||
let releaseQueue = (): void => {};
|
||||
const queueGate = new Promise<void>((resolve) => { releaseQueue = resolve; });
|
||||
internal.cleanupQueue = queueGate;
|
||||
|
||||
const cleanup = internal.cleanupExistingExtractedArchives();
|
||||
await waitFor(() => internal.cleanupQueue !== queueGate, 2_000);
|
||||
fs.writeFileSync(archivePath, Buffer.from("replacement"));
|
||||
releaseQueue();
|
||||
await cleanup;
|
||||
|
||||
expect(fs.readFileSync(archivePath, "utf8")).toBe("replacement");
|
||||
});
|
||||
|
||||
it("never cleans an archive part claimed by another package in a shared output directory", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cleanup-shared-claim-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "cleanup-owner-a", itemId: "cleanup-owner-a-item", fileName: "show.part1.rar" },
|
||||
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-item", fileName: "show.part2.rar" },
|
||||
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-nfo", fileName: "show.nfo" }
|
||||
], { cleanupMode: "delete" });
|
||||
const internal = manager as any;
|
||||
const sharedDir = session.packages["cleanup-owner-a"].outputDir;
|
||||
const foreignOldPath = session.items["cleanup-owner-b-item"].targetPath;
|
||||
const foreignNfoOldPath = session.items["cleanup-owner-b-nfo"].targetPath;
|
||||
const foreignSharedPath = path.join(sharedDir, "show.part2.rar");
|
||||
const foreignNfoSharedPath = path.join(sharedDir, "show.nfo");
|
||||
fs.renameSync(foreignOldPath, foreignSharedPath);
|
||||
fs.renameSync(foreignNfoOldPath, foreignNfoSharedPath);
|
||||
session.packages["cleanup-owner-b"].outputDir = sharedDir;
|
||||
session.items["cleanup-owner-b-item"].targetPath = foreignSharedPath;
|
||||
session.items["cleanup-owner-b-nfo"].targetPath = foreignNfoSharedPath;
|
||||
|
||||
const removed = await internal.cleanupRemainingArchiveArtifacts(session.packages["cleanup-owner-a"]);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(fs.existsSync(session.items["cleanup-owner-a-item"].targetPath)).toBe(false);
|
||||
expect(fs.existsSync(foreignSharedPath)).toBe(true);
|
||||
expect(fs.existsSync(foreignNfoSharedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an ambiguous pathless selection shared by multiple archive directories", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ambiguous-pathless-"));
|
||||
tempDirs.push(root);
|
||||
const item = {
|
||||
id: "pathless-item", packageId: "pathless-package", url: "https://example.test/pathless", provider: "realdebrid",
|
||||
status: "completed", retries: 0, speedBps: 0, downloadedBytes: 1, totalBytes: 1, progressPercent: 100,
|
||||
fileName: "episode.part1.rar", targetPath: "", resumable: true, attempts: 1, lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend", createdAt: Date.now(), updatedAt: Date.now()
|
||||
} satisfies DownloadItem;
|
||||
|
||||
const selection = resolveSelectedArchiveSetsFromCandidates(
|
||||
[path.join(root, "set-a", "episode.part1.rar"), path.join(root, "set-b", "episode.part1.rar")],
|
||||
[item],
|
||||
new Set([item.id])
|
||||
);
|
||||
|
||||
expect(selection.archivePaths.size).toBe(0);
|
||||
expect(selection.itemIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts an explicitly named first multipart volume as the CRC target", () => {
|
||||
const items = [
|
||||
{ id: "crc-first", fileName: "show.part1.rar", targetPath: "C:\\Downloads\\show.part1.rar" },
|
||||
{ id: "crc-second", fileName: "show.part2.rar", targetPath: "C:\\Downloads\\show.part2.rar" }
|
||||
] as DownloadItem[];
|
||||
|
||||
expect(findCrcImplicatedArchiveItems("C:\\Downloads\\show.part1.rar - checksum error", items).map((item) => item.id)).toEqual(["crc-first"]);
|
||||
});
|
||||
|
||||
it("marks an unexpected package post-process exception as failure but not an abort", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-exception-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "exception-package", itemId: "exception-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("unexpected-postprocess-failure"); });
|
||||
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
|
||||
expect(session.packages["exception-package"].status).toBe("failed");
|
||||
expect(session.items["exception-item"].fullStatus).toMatch(/^Entpack-Fehler/);
|
||||
|
||||
session.packages["exception-package"].status = "queued";
|
||||
session.items["exception-item"].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted:extract")), { once: true });
|
||||
queueMicrotask(() => internal.abortPackagePostProcessing("exception-package", "stop"));
|
||||
});
|
||||
});
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
expect(session.packages["exception-package"].status).not.toBe("failed");
|
||||
expect(session.items["exception-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
|
||||
session.packages["exception-package"].status = "queued";
|
||||
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("aborted:extract"); });
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
expect(session.packages["exception-package"].status).toBe("failed");
|
||||
});
|
||||
|
||||
it("preserves a manual extraction plan across an automatic disk-capacity retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-disk-retry-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-disk-package", itemId: "manual-disk-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["manual-disk-item"].targetPath;
|
||||
const archiveKey = path.resolve(archivePath).toLowerCase();
|
||||
internal.manualExtractPackages.add("manual-disk-package");
|
||||
internal.manualExtractArchiveFilters.set("manual-disk-package", new Set([archiveKey]));
|
||||
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||
internal.diskReservations = new DiskReservationCoordinator({
|
||||
safetyBytes: 0,
|
||||
retryDelayMs: 1_000,
|
||||
statVolume: async (targetPath) => ({ path: targetPath, volumeKey: "manual-volume", freeBytes: 0, totalBytes: 1_024 })
|
||||
});
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
|
||||
await internal.handlePackagePostProcessing("manual-disk-package");
|
||||
expect(manager.getSnapshot().canStop).toBe(true);
|
||||
manager.togglePackage("manual-disk-package");
|
||||
expect(internal.packageDiskRetryPlans.has("manual-disk-package")).toBe(true);
|
||||
manager.togglePackage("manual-disk-package");
|
||||
internal.manualExtractPackages.clear();
|
||||
internal.manualExtractArchiveFilters.clear();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(rerun).toHaveBeenCalledWith("manual-disk-package");
|
||||
expect(internal.manualExtractPackages.has("manual-disk-package")).toBe(true);
|
||||
expect(internal.manualExtractArchiveFilters.get("manual-disk-package")).toEqual(new Set([archiveKey]));
|
||||
expect(internal.packageDiskRetryAfterByPackage.has("manual-disk-package")).toBe(false);
|
||||
expect(manager.getSnapshot().diskWaitEvents?.some((entry) => entry.packageId === "manual-disk-package")).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps disk retry generations and stale timer callbacks isolated", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-identity-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-identity-package", itemId: "disk-identity-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
const request = { retryAt: Date.now() + 60_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null };
|
||||
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const firstId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const secondId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.packagePostProcessVersions.set("disk-identity-package", 1);
|
||||
expect(internal.schedulePackageDiskRetry("disk-identity-package", request)).toBe(false);
|
||||
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||
internal.packagePostProcessVersions.set("disk-identity-package", 0);
|
||||
internal.executePackageDiskRetry("disk-identity-package", firstId);
|
||||
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
|
||||
const getRunOwner = internal.getPackageResultRunOwner.bind(internal);
|
||||
internal.getPackageResultRunOwner = () => "new-owner";
|
||||
internal.executePackageDiskRetry("disk-identity-package", secondId);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
internal.getPackageResultRunOwner = getRunOwner;
|
||||
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const thirdId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.session.packages["disk-identity-package"].resultGeneration += 1;
|
||||
internal.executePackageDiskRetry("disk-identity-package", thirdId);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
|
||||
manager.stop();
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
internal.healthManualStop = false;
|
||||
manager.prepareForShutdown();
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears a paused run-owned disk retry when re-enable has no matching owner", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-owner-rebind-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-owner-package", itemId: "disk-owner-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
internal.session.running = true;
|
||||
internal.runScopeKind = "selected";
|
||||
internal.runPackageIds.add("disk-owner-package");
|
||||
const context = internal.beginActiveRunContext(new Set(["disk-owner-package"]), Date.now());
|
||||
expect(internal.schedulePackageDiskRetry("disk-owner-package", {
|
||||
retryAt: Date.now() + 1_000,
|
||||
manualRequested: true,
|
||||
postProcessVersion: 0,
|
||||
runOwnerId: context.id
|
||||
})).toBe(true);
|
||||
|
||||
manager.togglePackage("disk-owner-package");
|
||||
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(true);
|
||||
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||
manager.togglePackage("disk-owner-package");
|
||||
|
||||
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.packageDiskRetryAfterByPackage.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.getActivePostProcessingCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels a due disk retry when stop wins the race", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-stop-race-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "disk-stop-package", itemId: "disk-stop-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
internal.session.running = true;
|
||||
internal.runScopeKind = "selected";
|
||||
internal.runPackageIds.add("disk-stop-package");
|
||||
internal.runItemIds.add("disk-stop-item");
|
||||
expect(internal.schedulePackageDiskRetry("disk-stop-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null })).toBe(true);
|
||||
|
||||
manager.stop();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
expect(internal.packageDiskRetryPlans.size).toBe(0);
|
||||
expect(internal.packageDiskRetryTimers.size).toBe(0);
|
||||
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||
expect(session.running).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes separate manual and partial flags to deferred extraction", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-scope-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-cleanup-package", itemId: "manual-cleanup-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["manual-cleanup-item"].targetPath;
|
||||
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||
internal.runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
|
||||
const deferred = vi.fn(async () => {});
|
||||
internal.runDeferredPostExtraction = deferred;
|
||||
internal.manualExtractPackages.add("manual-cleanup-package");
|
||||
|
||||
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, false]);
|
||||
|
||||
session.packages["manual-cleanup-package"].status = "queued";
|
||||
session.items["manual-cleanup-item"].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.manualExtractArchiveFilters.set("manual-cleanup-package", new Set([path.resolve(archivePath).toLowerCase()]));
|
||||
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("cleans full manual package archives but preserves partial manual state", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-behavior-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-a", fileName: "episode-a.rar" },
|
||||
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-b", fileName: "episode-b.rar" }
|
||||
], { cleanupMode: "delete" });
|
||||
const internal = manager as any;
|
||||
const firstPath = session.items["cleanup-a"].targetPath;
|
||||
const secondPath = session.items["cleanup-b"].targetPath;
|
||||
|
||||
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, true);
|
||||
expect(fs.existsSync(firstPath)).toBe(true);
|
||||
expect(fs.existsSync(secondPath)).toBe(true);
|
||||
|
||||
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, false);
|
||||
expect(fs.existsSync(firstPath)).toBe(false);
|
||||
expect(fs.existsSync(secondPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects package extraction when no archive candidate exists", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-no-archive-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "no-archive-package", itemId: "no-archive-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
await expect(manager.extractNow("no-archive-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||
expect(manager.getSnapshot().session.items["no-archive-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
expect(manager.getSnapshot().session.packages["no-archive-package"].status).toBe("completed");
|
||||
expect(internal.manualExtractPackages.has("no-archive-package")).toBe(false);
|
||||
expect(postProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects retryExtraction when the failed package has no complete archive", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-no-archive-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "retry-no-archive", itemId: "retry-no-archive-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
session.items["retry-no-archive-item"].fullStatus = "Entpack-Fehler: vorheriger Fehler";
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.retryExtraction("retry-no-archive")).rejects.toThrow(/Kein .*entpackbarer Archivsatz ausgewählt/);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler");
|
||||
});
|
||||
|
||||
it("recognizes and arms an opaque archive file by signature", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const content = zip.toBuffer();
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "opaque-package", itemId: "opaque-item", fileName: "download.bin", content }]);
|
||||
const internal = manager as any;
|
||||
|
||||
await manager.extractNow("opaque-package");
|
||||
const task = internal.packagePostProcessTasks.get("opaque-package");
|
||||
expect(task).toBeDefined();
|
||||
await task;
|
||||
|
||||
expect(manager.getSnapshot().session.items["opaque-item"].fileName).toMatch(/\.zip$/i);
|
||||
});
|
||||
|
||||
it("extracts a uniquely resolvable pathless legacy item through the public API", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-pathless-public-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "pathless-public", itemId: "pathless-public-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["pathless-public-item"].targetPath;
|
||||
internal.releaseTargetPath("pathless-public-item");
|
||||
session.items["pathless-public-item"].targetPath = "";
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
await manager.extractNow("pathless-public");
|
||||
|
||||
expect(session.items["pathless-public-item"].targetPath).toBe(archivePath);
|
||||
expect(session.items["pathless-public-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
expect(postProcess).toHaveBeenCalledWith("pathless-public");
|
||||
});
|
||||
|
||||
it("rejects an incomplete multipart package before starting extraction", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-incomplete-multipart-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "incomplete-package", itemId: "incomplete-part1", fileName: "show.part1.rar" }]);
|
||||
const internal = manager as any;
|
||||
const part2Id = "incomplete-part2";
|
||||
session.packages["incomplete-package"].itemIds.push(part2Id);
|
||||
session.items[part2Id] = {
|
||||
...session.items["incomplete-part1"],
|
||||
id: part2Id,
|
||||
status: "queued",
|
||||
fileName: "show.part2.rar",
|
||||
targetPath: "",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fullStatus: "Wartet"
|
||||
};
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.extractNow("incomplete-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a mixed extraction batch before starting any package", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||
const { manager } = createCompletedFileManager(root, [
|
||||
{ packageId: "valid-package", itemId: "valid-item", fileName: "episode.zip", content: zip.toBuffer() },
|
||||
{ packageId: "invalid-package", itemId: "invalid-item", fileName: "episode.mkv" }
|
||||
]);
|
||||
const internal = manager as any;
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package");
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package");
|
||||
});
|
||||
|
||||
it("keeps opaque archive files untouched when batch preflight rejects another target", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "atomic-opaque", itemId: "atomic-opaque-item", fileName: "download.bin", content: zip.toBuffer() },
|
||||
{ packageId: "atomic-invalid", itemId: "atomic-invalid-item", fileName: "episode.mkv" }
|
||||
]);
|
||||
const internal = manager as any;
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
const opaquePath = session.items["atomic-opaque-item"].targetPath;
|
||||
|
||||
await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i);
|
||||
|
||||
expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin");
|
||||
expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath);
|
||||
expect(fs.existsSync(opaquePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes and drains stop for standalone manual extraction without starting a download run", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-standalone-stop-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "standalone-package", itemId: "standalone-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||
const internal = manager as any;
|
||||
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
||||
});
|
||||
|
||||
await manager.extractNow("standalone-package");
|
||||
await waitFor(() => internal.packagePostProcessTasks.size === 1, 2_000);
|
||||
expect(manager.getSnapshot().canStop).toBe(true);
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
|
||||
manager.stop();
|
||||
await waitFor(() => internal.packagePostProcessTasks.size === 0, 2_000);
|
||||
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2187,6 +2187,7 @@ describe("download table row contracts", () => {
|
||||
it("keeps the complete package status and audio details in the tooltip", () => {
|
||||
const audioPackage = {
|
||||
...pkg("audio-package", "Audio package", ["audio-item"]),
|
||||
status: "extracting" as const,
|
||||
postProcessLabel: "Entpacken 1%",
|
||||
audioStripSummary: {
|
||||
at: now,
|
||||
@@ -2231,6 +2232,62 @@ describe("download table row contracts", () => {
|
||||
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||||
});
|
||||
|
||||
it("shows active extraction progress while retaining sibling extraction errors in the tooltip", () => {
|
||||
const activePackage = {
|
||||
...pkg("active-extraction-package", "Active extraction", ["failed-archive", "active-archive"]),
|
||||
status: "extracting" as const,
|
||||
postProcessLabel: "Entpacken 42% (1/1) · active.part01.rar"
|
||||
};
|
||||
const failedArchive = item("failed-archive", activePackage.id, "completed", {
|
||||
fullStatus: "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"
|
||||
});
|
||||
const activeArchive = item("active-archive", activePackage.id, "completed", {
|
||||
fullStatus: "Entpacken 42% · active.part01.rar"
|
||||
});
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: activePackage, items: [failedArchive, activeArchive], allItems: [failedArchive, activeArchive], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html.match(/>Entpacken - 42%<\/span>/g)).toHaveLength(2);
|
||||
expect(html).toContain("1 Entpackfehler");
|
||||
expect(html).toContain("Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv");
|
||||
});
|
||||
|
||||
it("does not present a persisted extraction label as active progress after restart", () => {
|
||||
const restoredItem = item("restored-archive", "restored-package", "completed", {
|
||||
fullStatus: "Entpacken - Ausstehend"
|
||||
});
|
||||
const restoredPackage = {
|
||||
...pkg("restored-package", "Restored extraction", [restoredItem.id]),
|
||||
status: "queued" as const,
|
||||
postProcessLabel: "Entpacken 100% (1/1) · release.part01.rar"
|
||||
};
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: restoredPackage, items: [restoredItem], allItems: [restoredItem], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html.match(/>Entpacken - Ausstehend<\/span>/g)).toHaveLength(2);
|
||||
expect(html).not.toContain(">Entpacken - 100%</span>");
|
||||
expect(html).not.toContain("Entpacken 100%");
|
||||
expect(html).not.toContain("release.part01.rar");
|
||||
});
|
||||
|
||||
it("removes redundant service suffixes from runtime statuses", () => {
|
||||
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
||||
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { IPC_CHANNELS } from "../src/shared/ipc";
|
||||
import type { ElectronApi } from "../src/shared/preload-api";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
api: undefined as ElectronApi | undefined,
|
||||
ipcHandlers: new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>(),
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||
appHandlers: new Map<string, (...args: unknown[]) => void>(),
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => "C:\\MDD\\Test"),
|
||||
getAppPath: vi.fn(() => "C:\\MDD\\App"),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => {
|
||||
electron.appHandlers.set(name, handler);
|
||||
}),
|
||||
whenReady: vi.fn(() => new Promise<void>(() => {})),
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
setPath: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: electron.app,
|
||||
BrowserWindow: class {
|
||||
public static getAllWindows(): unknown[] { return []; }
|
||||
},
|
||||
clipboard: { readText: vi.fn(() => ""), writeText: vi.fn() },
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: ElectronApi) => {
|
||||
electron.api = api;
|
||||
}
|
||||
},
|
||||
dialog: {},
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown) => {
|
||||
electron.ipcHandlers.set(channel, handler);
|
||||
}),
|
||||
on: vi.fn()
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: electron.invoke,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn()
|
||||
},
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
nativeTheme: { themeSource: "system" },
|
||||
powerMonitor: { on: vi.fn(), removeListener: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { registerExtractionIpcHandlers } from "../src/main/extraction-ipc";
|
||||
|
||||
function trustedEvent(): IpcMainInvokeEvent {
|
||||
return {} as IpcMainInvokeEvent;
|
||||
}
|
||||
|
||||
function registerHandlers(target: Parameters<typeof registerExtractionIpcHandlers>[1]): void {
|
||||
registerExtractionIpcHandlers((channel, handler) => {
|
||||
electron.ipcHandlers.set(channel, handler);
|
||||
}, target);
|
||||
}
|
||||
|
||||
function createController(manager: {
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (request: { packageIds: string[]; itemIds: string[] }) => Promise<void>;
|
||||
}): AppController {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: typeof manager;
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
controller.manager = manager;
|
||||
controller.audit = vi.fn();
|
||||
return controller as unknown as AppController;
|
||||
}
|
||||
|
||||
describe("manual extraction error propagation", () => {
|
||||
beforeAll(async () => {
|
||||
await import("../src/preload/preload");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
electron.ipcHandlers.clear();
|
||||
electron.invoke.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["stale", "Paket existiert nicht mehr"],
|
||||
["deleted", "Ausgewählte Datei wurde gelöscht"],
|
||||
["non-extractable", "Kein vollständiger entpackbarer Archivsatz ausgewählt"]
|
||||
])("keeps a %s manager rejection intact through AppController", async (_caseName, message) => {
|
||||
const controller = createController({
|
||||
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||
});
|
||||
|
||||
await expect(controller.retryExtraction("package-id")).rejects.toThrow(message);
|
||||
await expect(controller.extractNow({ packageIds: [], itemIds: ["item-id"] })).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("rejects an empty extract-now request at the trusted main-process IPC boundary", async () => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => undefined),
|
||||
extractNow: vi.fn(async () => undefined)
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(IPC_CHANNELS.EXTRACT_NOW);
|
||||
|
||||
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), { packageIds: [], itemIds: [] })))
|
||||
.rejects.toThrow("extractNow benötigt mindestens ein Ziel");
|
||||
expect(controller.extractNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "packageId muss ein nicht-leerer String sein"],
|
||||
[" ", "packageId muss ein nicht-leerer String sein"],
|
||||
["p".repeat(257), "packageId darf höchstens 256 Zeichen lang sein"]
|
||||
])("rejects an invalid retry package ID before calling the controller", async (packageId, message) => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => undefined),
|
||||
extractNow: vi.fn(async () => undefined)
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(IPC_CHANNELS.RETRY_EXTRACTION);
|
||||
|
||||
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), packageId))).rejects.toThrow(message);
|
||||
expect(controller.retryExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[IPC_CHANNELS.RETRY_EXTRACTION, "Paket existiert nicht mehr", ["stale-package"]],
|
||||
[IPC_CHANNELS.EXTRACT_NOW, "Ausgewählte Datei wurde gelöscht", [{ packageIds: [], itemIds: ["deleted-item"] }]],
|
||||
[IPC_CHANNELS.EXTRACT_NOW, "Kein vollständiger entpackbarer Archivsatz ausgewählt", [{ packageIds: [], itemIds: ["plain-file"] }]]
|
||||
])("returns the controller rejection from %s to ipcRenderer.invoke", async (channel, message, args) => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(channel);
|
||||
|
||||
await expect(Promise.resolve(handler?.(trustedEvent(), ...args))).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("exposes main-process extraction rejections unchanged to the renderer API", async () => {
|
||||
electron.invoke
|
||||
.mockRejectedValueOnce(new Error("Paket existiert nicht mehr"))
|
||||
.mockRejectedValueOnce(new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"));
|
||||
|
||||
await expect(electron.api?.retryExtraction("stale-package")).rejects.toThrow("Paket existiert nicht mehr");
|
||||
await expect(electron.api?.extractNow({ packageIds: [], itemIds: ["plain-file"] }))
|
||||
.rejects.toThrow("Kein vollständiger entpackbarer Archivsatz ausgewählt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { extractPackageArchives, shutdownDaemon, type ExtractArchiveFailureInfo } from "../src/main/extractor";
|
||||
|
||||
const hasJdk = spawnSync("javac", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalJava = process.env.RD_JAVA_BIN;
|
||||
const originalJvmRoot = process.env.RD_EXTRACTOR_JVM_DIR;
|
||||
const originalFakeMode = process.env.RD_FAKE_JVM_MODE;
|
||||
const originalFakeSecret = process.env.RD_FAKE_JVM_SECRET;
|
||||
const tempDirs: string[] = [];
|
||||
let runtimeRoot = "";
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveFixture(prefix: string): { packageDir: string; targetDir: string } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c0004", "hex"));
|
||||
return { packageDir, targetDir };
|
||||
}
|
||||
|
||||
function extractionOptions(fixture: ReturnType<typeof createArchiveFixture>) {
|
||||
return {
|
||||
...fixture,
|
||||
cleanupMode: "none" as const,
|
||||
conflictMode: "skip" as const,
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: "candidate-one\ncandidate-two"
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJdk).sequential("JVM protocol integration", () => {
|
||||
beforeAll(() => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-protocol-runtime-"));
|
||||
tempDirs.push(root);
|
||||
runtimeRoot = path.join(root, "extractor-jvm");
|
||||
const sourceDir = path.join(root, "source", "com", "sucukdeluxe", "extractor");
|
||||
const classesDir = path.join(runtimeRoot, "classes");
|
||||
const libDir = path.join(runtimeRoot, "lib");
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
const javaSource = `package com.sucukdeluxe.extractor;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
public final class JBindExtractorMain {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String mode = System.getenv("RD_FAKE_JVM_MODE");
|
||||
String secret = System.getenv("RD_FAKE_JVM_SECRET");
|
||||
boolean daemon = args.length == 1 && "--daemon".equals(args[0]);
|
||||
if (daemon && mode.startsWith("oneshot")) return;
|
||||
if (daemon) {
|
||||
System.out.println("RD_DAEMON_READY");
|
||||
System.out.flush();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
while (reader.readLine() != null) {
|
||||
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||
System.out.println("RD_BACKEND fake-daemon");
|
||||
if ("daemon-success".equals(mode)) {
|
||||
System.out.println("RD_DONE");
|
||||
System.out.println("RD_REQUEST_DONE 0");
|
||||
System.out.flush();
|
||||
continue;
|
||||
}
|
||||
System.out.print("RD_PASS");
|
||||
System.out.flush();
|
||||
Thread.sleep(25L);
|
||||
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||
System.out.flush();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||
System.out.println("RD_BACKEND fake-oneshot");
|
||||
if ("oneshot-success".equals(mode)) {
|
||||
System.out.println("RD_DONE");
|
||||
return;
|
||||
}
|
||||
System.out.print("RD_PASS");
|
||||
System.out.flush();
|
||||
Thread.sleep(25L);
|
||||
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||
System.out.flush();
|
||||
System.exit(1);
|
||||
}
|
||||
}`;
|
||||
const sourcePath = path.join(sourceDir, "JBindExtractorMain.java");
|
||||
fs.writeFileSync(sourcePath, javaSource, "utf8");
|
||||
const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-d", classesDir, sourcePath], { encoding: "utf8" });
|
||||
expect(compiled.status, `${compiled.stdout}\n${compiled.stderr}`).toBe(0);
|
||||
const sourceLibDir = path.join(process.cwd(), "resources", "extractor-jvm", "lib");
|
||||
for (const name of ["sevenzipjbinding.jar", "sevenzipjbinding-all-platforms.jar", "zip4j.jar"]) {
|
||||
fs.copyFileSync(path.join(sourceLibDir, name), path.join(libDir, name));
|
||||
}
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
process.env.RD_JAVA_BIN = "java";
|
||||
process.env.RD_EXTRACTOR_JVM_DIR = runtimeRoot;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDaemon();
|
||||
delete process.env.RD_FAKE_JVM_MODE;
|
||||
delete process.env.RD_FAKE_JVM_SECRET;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
shutdownDaemon();
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
restoreEnv("RD_EXTRACT_BACKEND", originalBackend);
|
||||
restoreEnv("RD_JAVA_BIN", originalJava);
|
||||
restoreEnv("RD_EXTRACTOR_JVM_DIR", originalJvmRoot);
|
||||
restoreEnv("RD_FAKE_JVM_MODE", originalFakeMode);
|
||||
restoreEnv("RD_FAKE_JVM_SECRET", originalFakeSecret);
|
||||
});
|
||||
|
||||
it("transports daemon attempts and isolates throwing log observers", async () => {
|
||||
process.env.RD_FAKE_JVM_MODE = "daemon-success";
|
||||
process.env.RD_FAKE_JVM_SECRET = "unused-daemon-secret";
|
||||
const firstFixture = createArchiveFixture("rd-jvm-daemon-callback-");
|
||||
const first = await extractPackageArchives({
|
||||
...extractionOptions(firstFixture),
|
||||
onLog: (_level, message) => {
|
||||
if (message.startsWith("Passwort-Versuch ")) {
|
||||
throw new Error("ui callback failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(first).toEqual(expect.objectContaining({ extracted: 1, failed: 0, lastError: "" }));
|
||||
|
||||
const secondFixture = createArchiveFixture("rd-jvm-daemon-recovery-");
|
||||
const logs: string[] = [];
|
||||
const second = await extractPackageArchives({
|
||||
...extractionOptions(secondFixture),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("redacts a one-shot password payload when the JVM exits before an error event", async () => {
|
||||
const secret = "one-shot-runtime-secret";
|
||||
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||
process.env.RD_FAKE_JVM_MODE = "oneshot-crash";
|
||||
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||
const fixture = createArchiveFixture("rd-jvm-oneshot-redaction-");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
...extractionOptions(fixture),
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||
expect(diagnosticText).not.toContain(secret);
|
||||
expect(diagnosticText).not.toContain(encodedSecret);
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("redacts a daemon password payload when the JVM exits before request completion", async () => {
|
||||
const secret = "daemon-runtime-secret";
|
||||
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||
process.env.RD_FAKE_JVM_MODE = "daemon-crash";
|
||||
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||
const fixture = createArchiveFixture("rd-jvm-daemon-redaction-");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
...extractionOptions(fixture),
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||
expect(diagnosticText).not.toContain(secret);
|
||||
expect(diagnosticText).not.toContain(encodedSecret);
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
});
|
||||
+753
-1
@@ -1,15 +1,21 @@
|
||||
import fs from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { extractPackageArchives } from "../src/main/extractor";
|
||||
import { extractPackageArchives, shutdownDaemon } from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||
const require = createRequire(import.meta.url);
|
||||
const rarCliPath = [
|
||||
"C:\\Program Files\\WinRAR\\Rar.exe",
|
||||
"C:\\Program Files (x86)\\WinRAR\\Rar.exe"
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
|
||||
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
|
||||
|
||||
@@ -94,6 +100,84 @@ function hasJvmExtractorRuntime(): boolean {
|
||||
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
|
||||
}
|
||||
|
||||
function hasCommand(command: string, args: string[]): boolean {
|
||||
return spawnSync(command, args, { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function compileJvmExtractorSource(root: string): string {
|
||||
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const classesDir = path.join(root, "classes");
|
||||
const libs = [
|
||||
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
||||
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
||||
path.join(runtimeRoot, "lib", "zip4j.jar")
|
||||
];
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
const result = spawnSync("javac", [
|
||||
"-source", "8",
|
||||
"-target", "8",
|
||||
"-encoding", "UTF-8",
|
||||
"-cp", libs.join(path.delimiter),
|
||||
"-d", classesDir,
|
||||
path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java")
|
||||
], { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(String(result.stderr || result.stdout || "javac failed"));
|
||||
}
|
||||
return [classesDir, ...libs].join(path.delimiter);
|
||||
}
|
||||
|
||||
function findZipCryptoVerifierCollision(root: string, archivePath: string): string {
|
||||
const sourcePath = path.join(root, "ZipCryptoCollisionFinder.java");
|
||||
const classesDir = path.join(root, "collision-finder-classes");
|
||||
const zip4jPath = path.join(process.cwd(), "resources", "extractor-jvm", "lib", "zip4j.jar");
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
fs.writeFileSync(sourcePath, `import java.io.InputStream;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
public final class ZipCryptoCollisionFinder {
|
||||
public static void main(String[] args) throws Exception {
|
||||
for (int index = 0; index < 8192; index++) {
|
||||
String candidate = "collision-candidate-" + index;
|
||||
ZipFile zipFile = new ZipFile(args[0]);
|
||||
zipFile.setPassword(candidate.toCharArray());
|
||||
int produced = 0;
|
||||
try {
|
||||
FileHeader header = zipFile.getFileHeaders().get(0);
|
||||
InputStream input = zipFile.getInputStream(header);
|
||||
try {
|
||||
byte[] buffer = new byte[8192];
|
||||
while (true) {
|
||||
int read = input.read(buffer);
|
||||
if (read < 0) break;
|
||||
produced += read;
|
||||
}
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
} catch (Exception error) {
|
||||
if (produced > 0) {
|
||||
System.out.println(candidate);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
zipFile.close();
|
||||
}
|
||||
}
|
||||
System.exit(2);
|
||||
}
|
||||
}`, "utf8");
|
||||
const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-cp", zip4jPath, "-d", classesDir, sourcePath], { encoding: "utf8" });
|
||||
if (compiled.status !== 0) {
|
||||
throw new Error(String(compiled.stderr || compiled.stdout || "collision finder compile failed"));
|
||||
}
|
||||
const run = spawnSync("java", ["-cp", [classesDir, zip4jPath].join(path.delimiter), "ZipCryptoCollisionFinder", archivePath], { encoding: "utf8", timeout: 20_000 });
|
||||
if (run.status !== 0) {
|
||||
throw new Error(String(run.stderr || run.stdout || "collision finder failed"));
|
||||
}
|
||||
return String(run.stdout || "").trim();
|
||||
}
|
||||
|
||||
function corruptFirstZipPayload(zipPath: string): void {
|
||||
const bytes = fs.readFileSync(zipPath);
|
||||
const signature = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||
@@ -112,6 +196,7 @@ function corruptFirstZipPayload(zipPath: string): void {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDaemon();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -120,9 +205,676 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||
}
|
||||
if (originalArchivePasswords === undefined) {
|
||||
delete process.env.RD_ARCHIVE_PASSWORDS;
|
||||
} else {
|
||||
process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords;
|
||||
}
|
||||
});
|
||||
|
||||
describe("JVM extractor build pipeline", () => {
|
||||
it("compiles the JVM runtime before main and release builds", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
||||
|
||||
expect(packageJson.scripts["build:extractor-jvm"]).toBe("node scripts/build-extractor-jvm.mjs");
|
||||
expect(packageJson.scripts["build:main"]).toMatch(/^npm run build:extractor-jvm && /);
|
||||
expect(packageJson.scripts.build).toContain("npm run build:main");
|
||||
expect(packageJson.scripts["release:win"]).toContain("npm run build");
|
||||
});
|
||||
|
||||
it("keeps packaged JVM classes current with Java 8 bytecode and the password attempt protocol", () => {
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
|
||||
const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
|
||||
expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0);
|
||||
const mainClass = fs.readFileSync(path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"));
|
||||
expect(mainClass.readUInt16BE(6)).toBe(52);
|
||||
expect(mainClass.includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true);
|
||||
expect(mainClass.includes(Buffer.from("RD_PASSWORD ", "utf8"))).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]))("builds current source and rejects stale classes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-build-"));
|
||||
tempDirs.push(root);
|
||||
const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const runtimeRoot = path.join(root, "extractor-jvm");
|
||||
fs.mkdirSync(runtimeRoot, { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true });
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
|
||||
const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0);
|
||||
const mainClass = path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||
expect(fs.readFileSync(mainClass).includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true);
|
||||
|
||||
const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0);
|
||||
|
||||
const javaSource = path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java");
|
||||
fs.appendFileSync(javaSource, "\n", "utf8");
|
||||
const stale = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(stale.status).toBe(1);
|
||||
expect(`${stale.stdout}\n${stale.stderr}`).toMatch(/veraltet|stale/i);
|
||||
}, 30_000);
|
||||
|
||||
it("fails clearly when no JDK compiler is available", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-no-jdk-"));
|
||||
tempDirs.push(root);
|
||||
const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const runtimeRoot = path.join(root, "extractor-jvm");
|
||||
fs.mkdirSync(runtimeRoot, { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true });
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
const env = { ...process.env, JAVA_HOME: "", PATH: "" };
|
||||
|
||||
const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8", env });
|
||||
|
||||
expect(build.status).toBe(1);
|
||||
expect(`${build.stdout}\n${build.stderr}`).toMatch(/JDK.*javac|javac.*JDK/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("emits password attempt indices without exposing candidate values", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-password-attempt-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.txt");
|
||||
const archivePath = path.join(root, "protected.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-secret-value";
|
||||
const wrongPassword = "wrong-secret-value";
|
||||
fs.writeFileSync(inputPath, "protected payload", "utf8");
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
const attemptLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "));
|
||||
expect(attemptLines).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(attemptLines.every((line) => !line.includes(actualPassword) && !line.includes(wrongPassword))).toBe(true);
|
||||
expect(String(run.stdout)).not.toContain("RD_PASSWORD ");
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("protected payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("keeps encrypted Zip4j corruption distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-encrypted-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected-corrupt.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "zip4j-corrupt-secret";
|
||||
const sentinelPassword = "must-not-run-after-zip-crc";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
corruptFirstZipPayload(archivePath);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toMatch(/CRC|checksum/i);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("validates encrypted Zip4j entries before extracting plain entries", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-mixed-password-"));
|
||||
tempDirs.push(root);
|
||||
const plainInput = path.join(root, "a-plain.txt");
|
||||
const secretInput = path.join(root, "b-secret.txt");
|
||||
const archivePath = path.join(root, "mixed.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "zip4j-mixed-secret";
|
||||
fs.writeFileSync(plainInput, "plain payload", "utf8");
|
||||
fs.writeFileSync(secretInput, "secret payload", "utf8");
|
||||
expect(spawnSync("7z", ["a", "-tzip", archivePath, plainInput], { encoding: "utf8" }).status).toBe(0);
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, secretInput], { encoding: "utf8" }).status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 2",
|
||||
"RD_PASSWORD_ATTEMPT 2 2"
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "a-plain.txt"), "utf8")).toBe("plain payload");
|
||||
expect(fs.readFileSync(path.join(targetDir, "b-secret.txt"), "utf8")).toBe("secret payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("continues after a ZipCrypto verifier collision reaches CRC", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zipcrypto-collision-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "collision.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-password";
|
||||
const payload = randomBytes(4096);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync("7z", ["a", "-tzip", "-mx=0", "-mem=ZipCrypto", `-p${actualPassword}`, archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const collisionPassword = findZipCryptoVerifierCollision(root, archivePath);
|
||||
expect(collisionPassword).toMatch(/^collision-candidate-\d+$/);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
collisionPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.bin"))).toEqual(payload);
|
||||
|
||||
const failedTargetDir = path.join(root, "failed-out");
|
||||
const failedRun = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
failedTargetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
collisionPassword,
|
||||
"--password",
|
||||
"wrong-after-collision"
|
||||
], { encoding: "utf8" });
|
||||
expect(failedRun.status).not.toBe(0);
|
||||
expect(String(failedRun.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(String(failedRun.stderr)).toContain("zip4j-Fehler: CRCERROR");
|
||||
expect(String(failedRun.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("parses daemon password candidates with JSON metacharacters and Unicode", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-daemon-json-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.txt");
|
||||
const archivePath = path.join(root, "protected.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-daemon-secret";
|
||||
const bracketPassword = "wrong]candidate";
|
||||
const escapedPassword = "päss\\\"\\漢字ß";
|
||||
fs.writeFileSync(inputPath, "daemon protected payload", "utf8");
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
const request = JSON.stringify({
|
||||
archive: archivePath,
|
||||
target: targetDir,
|
||||
conflict: "overwrite",
|
||||
backend: "zip4j",
|
||||
passwords: [bracketPassword, escapedPassword, actualPassword]
|
||||
});
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--daemon"
|
||||
], { encoding: "utf8", input: `${request}\n` });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout)).toContain("RD_REQUEST_DONE 0");
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 4",
|
||||
"RD_PASSWORD_ATTEMPT 2 4",
|
||||
"RD_PASSWORD_ATTEMPT 3 4",
|
||||
"RD_PASSWORD_ATTEMPT 4 4"
|
||||
]);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(bracketPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(escapedPassword);
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("daemon protected payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("tries the real RAR5 password after unreliable encrypted metadata", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-password-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-actual-secret";
|
||||
const wrongPassword = "rar5-wrong-secret";
|
||||
const payload = randomBytes(192 * 1024);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const firstPart = fs.readdirSync(root).find((name) => /^protected\.part0*1\.rar$/i.test(name));
|
||||
expect(firstPart).toBeTruthy();
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, firstPart!),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin"));
|
||||
expect(extractedPayload?.bytes).toBe(payload.toString("base64"));
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("continues after an explicit RAR5 WRONG_PASSWORD result", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-data-password-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected-data.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-data-actual";
|
||||
const wrongPassword = "rar5-data-wrong";
|
||||
const payload = randomBytes(192 * 1024);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-p${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const firstPart = fs.readdirSync(root).find((name) => /^protected-data\.part0*1\.rar$/i.test(name));
|
||||
expect(firstPart).toBeTruthy();
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, firstPart!),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(wrongPassword);
|
||||
const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin"));
|
||||
expect(extractedPayload?.bytes).toBe(payload.toString("base64"));
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps encrypted RAR5 corruption distinct from an exhausted password list", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-encrypted-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "encrypted-corrupt.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-corrupt-secret";
|
||||
const sentinelPassword = "must-not-be-attempted-after-crc";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^encrypted-corrupt\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const corruptPath = path.join(root, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toMatch(/RD_ERROR 7z-Fehler: (?:CRCERROR|DATAERROR)/);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
expect(String(run.stderr).toLowerCase()).not.toContain("wrong_password");
|
||||
expect(String(run.stderr).toLowerCase()).not.toContain("wrong password");
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(sentinelPassword);
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an unencrypted RAR5 CRC failure terminal", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "corrupt.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.writeFileSync(inputPath, randomBytes(192 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^corrupt\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const corruptPath = path.join(root, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
"unused-one",
|
||||
"--password",
|
||||
"unused-two"
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toContain("CRCERROR");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted missing RAR5 volume distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-missing-volume-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "missing-volume.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-missing-volume-secret";
|
||||
const sentinelPassword = "must-not-run-after-missing-volume";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^missing-volume\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
fs.unlinkSync(path.join(root, parts[1]));
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stdout)).not.toContain("RD_OUTPUT ");
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
expect(String(run.stderr)).toMatch(/Missing volume|Volume fehlt/i);
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted locked RAR5 volume distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-locked-volume-"));
|
||||
tempDirs.push(root);
|
||||
const inputDir = path.join(root, "inputs");
|
||||
const archivePath = path.join(root, "locked.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-locked-volume-secret";
|
||||
const sentinelPassword = "must-not-run-after-volume-io";
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
const inputPaths: string[] = [];
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const inputPath = path.join(inputDir, `payload-${index.toString().padStart(2, "0")}.bin`);
|
||||
fs.writeFileSync(inputPath, randomBytes(48 * 1024));
|
||||
inputPaths.push(inputPath);
|
||||
}
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, ...inputPaths], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^locked\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(4);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
const script = `$lock = [IO.File]::Open($env:LOCK_PATH, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
|
||||
try {
|
||||
& $env:JAVA_BIN '-cp' $env:CLASS_PATH 'com.sucukdeluxe.extractor.JBindExtractorMain' '--archive' $env:ARCHIVE_PATH '--target' $env:TARGET_PATH '--conflict' 'overwrite' '--backend' '7zjbinding' '--password' $env:ACTUAL_PASSWORD '--password' $env:SENTINEL_PASSWORD
|
||||
exit $LASTEXITCODE
|
||||
} finally {
|
||||
$lock.Dispose()
|
||||
}`;
|
||||
|
||||
const run = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
LOCK_PATH: path.join(root, parts[3]),
|
||||
JAVA_BIN: "java",
|
||||
CLASS_PATH: classPath,
|
||||
ARCHIVE_PATH: path.join(root, parts[0]),
|
||||
TARGET_PATH: targetDir,
|
||||
ACTUAL_PASSWORD: actualPassword,
|
||||
SENTINEL_PASSWORD: sentinelPassword
|
||||
}
|
||||
});
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toContain("Volume konnte nicht geoffnet");
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("does not convert an encrypted 7z open failure into a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-7z-open-failure-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "truncated.7z");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "sevenzip-open-secret";
|
||||
const sentinelPassword = "must-not-run-after-open-failure";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync("7z", ["a", "-t7z", `-p${actualPassword}`, "-mhe=on", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const archiveSize = fs.statSync(archivePath).size;
|
||||
expect(archiveSize).toBeGreaterThan(256);
|
||||
fs.truncateSync(archivePath, archiveSize - 128);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3"
|
||||
]);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("7z", ["i"]))("routes the emitted German archive-password failure through fallback and cache invalidation", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-german-password-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const firstInput = path.join(root, "first.txt");
|
||||
const secondInput = path.join(root, "second.txt");
|
||||
const learnedPassword = "learned-package-secret";
|
||||
const unavailablePassword = "unavailable-archive-secret";
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(firstInput, "first payload", "utf8");
|
||||
fs.writeFileSync(secondInput, "second payload", "utf8");
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${learnedPassword}`, "-mem=AES256", path.join(packageDir, "a-first.zip"), firstInput]).status).toBe(0);
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${unavailablePassword}`, "-mem=AES256", path.join(packageDir, "b-second.zip"), secondInput]).status).toBe(0);
|
||||
const failures: import("../src/main/extractor").ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: learnedPassword,
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
archiveName: "b-second.zip",
|
||||
category: "wrong_password",
|
||||
suggestRedownload: false
|
||||
}));
|
||||
expect(String(failures[0]?.jvmFailureReason || "")).toContain("Falsches Archiv-Passwort");
|
||||
expect(logs.some((message) => message.includes("JVM-Extractor Fallback-Analyse:") && message.includes("wrongPassword=true"))).toBe(true);
|
||||
expect(logs.some((message) => message.startsWith("Legacy-Extractor Start: archive=b-second.zip"))).toBe(true);
|
||||
expect(logs.some((message) => message.includes("Passwort-Cache Update"))).toBe(true);
|
||||
expect(logs.some((message) => message.includes("Passwort-Cache verworfen"))).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
|
||||
+303
-5
@@ -1,4 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +21,10 @@ import {
|
||||
shouldSerialRetryParallelFailures,
|
||||
findArchiveCandidates,
|
||||
orderExtractorCandidatesForArchive,
|
||||
extractorCommandsShareIdentity,
|
||||
parseJvmPasswordAttemptLine,
|
||||
redactJvmDiagnosticLine,
|
||||
summarizeJvmPasswordAttempts,
|
||||
parseNativeExtractOutput,
|
||||
parseNativeArchiveEntryList,
|
||||
remapNativeSubstOutput,
|
||||
@@ -26,14 +32,44 @@ import {
|
||||
resolveExtractorBackendModeForArchive,
|
||||
resolveExtractorBackendMode,
|
||||
shouldFallbackLegacyRarToJvm,
|
||||
shouldRunAlternativeNativeExtractor,
|
||||
shouldSuggestRedownloadAfterCrossBackendFailure,
|
||||
validateNativeArchiveEntryCandidates,
|
||||
validateNativeFlatArchiveEntryCandidates,
|
||||
} from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||
const originalStatfs = fs.promises.statfs;
|
||||
const require = createRequire(import.meta.url);
|
||||
const rarCliPath = [
|
||||
"C:\\Program Files\\WinRAR\\Rar.exe",
|
||||
"C:\\Program Files (x86)\\WinRAR\\Rar.exe"
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
const javaAvailable = spawnSync("java", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
const sevenZipAvailable = spawnSync("7z", ["i"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
function createEncryptedCorruptRarFixture(root: string, password: string, stem: string): string {
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const payloadPath = path.join(root, "payload.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(payloadPath, randomBytes(256 * 1024));
|
||||
const archivePath = path.join(packageDir, `${stem}.rar`);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${password}`, "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" });
|
||||
if (created.status !== 0) {
|
||||
throw new Error(String(created.stderr || created.stdout || `Rar Exit ${created.status}`));
|
||||
}
|
||||
const parts = fs.readdirSync(packageDir).filter((name) => new RegExp(`^${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.part\\d+\\.rar$`, "i").test(name)).sort();
|
||||
if (parts.length < 3) {
|
||||
throw new Error("RAR fixture has fewer than three volumes");
|
||||
}
|
||||
const corruptPath = path.join(packageDir, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
|
||||
|
||||
@@ -115,6 +151,11 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
|
||||
}
|
||||
if (originalArchivePasswords === undefined) {
|
||||
delete process.env.RD_ARCHIVE_PASSWORDS;
|
||||
} else {
|
||||
process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords;
|
||||
}
|
||||
(fs.promises as any).statfs = originalStatfs;
|
||||
});
|
||||
|
||||
@@ -151,6 +192,67 @@ describe("extractor", () => {
|
||||
expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !sevenZipAvailable)("runs one five-candidate legacy pass for a deterministic multipart CRC failure", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-crc-pass-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const payloadPath = path.join(root, "payload.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(payloadPath, randomBytes(256 * 1024));
|
||||
const archivePath = path.join(packageDir, "release.test.rar");
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(packageDir).filter((name) => /^release\.test\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start:"))).toHaveLength(1);
|
||||
expect(logs.filter((message) => /^Legacy-Passwort-Versuch \d\/5:/.test(message))).toHaveLength(5);
|
||||
expect(logs.some((message) => message.startsWith("Legacy-Fallback:"))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath)("does not serially retry a deterministic CRC archive after another package archive succeeded", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parallel-crc-pass-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const validPayload = path.join(root, "valid.bin");
|
||||
const failedPayload = path.join(root, "failed.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(validPayload, randomBytes(96 * 1024));
|
||||
fs.writeFileSync(failedPayload, randomBytes(96 * 1024));
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, "a.valid.rar"), validPayload]).status).toBe(0);
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-idq", path.join(packageDir, "b.failed.rar"), failedPayload]).status).toBe(0);
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 }));
|
||||
expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start: archive=b.failed.rar"))).toHaveLength(1);
|
||||
}, 30_000);
|
||||
|
||||
it("deletes only successfully extracted archives", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
@@ -1096,11 +1198,20 @@ describe("extractor", () => {
|
||||
it("classifies CRC errors", () => {
|
||||
expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Checksum error in data")).toBe("crc_error");
|
||||
expect(classifyExtractionError("7z-Fehler: CRCERROR")).toBe("crc_error");
|
||||
expect(classifyExtractionError("7z-Fehler: DATAERROR")).toBe("crc_error");
|
||||
expect(classifyExtractionError("CRC-Fehler in release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Pr�fsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
});
|
||||
|
||||
it("classifies wrong password", () => {
|
||||
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Archiv-Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Archiv Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches-Archiv-Passwort")).toBe("wrong_password");
|
||||
});
|
||||
|
||||
it("classifies missing parts", () => {
|
||||
@@ -1153,13 +1264,15 @@ describe("extractor", () => {
|
||||
});
|
||||
|
||||
describe("shouldSerialRetryParallelFailures", () => {
|
||||
it("keeps serial recovery enabled after mixed parallel results", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
|
||||
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
|
||||
it("retries unknown failures that can result from parallel contention", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["unknown", "unknown"])).toBe(true);
|
||||
});
|
||||
|
||||
it("only retries a total parallel wipe-out for contention-like failures", () => {
|
||||
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
|
||||
it("does not retry deterministic archive failures after another archive succeeded", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["crc_error"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(1, ["unsupported_format"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
|
||||
});
|
||||
@@ -1358,6 +1471,191 @@ describe("extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractorCommandsShareIdentity", () => {
|
||||
it("deduplicates aliases of the same native extraction engine", () => {
|
||||
expect(extractorCommandsShareIdentity("Rar.exe", "UnRAR.exe", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("C:\\Program Files\\WinRAR\\Rar.exe", "rar", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("7z.exe", "7za", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("Rar.exe", "7z.exe", "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("budgets automatic RAR recovery to one native engine before JVM", () => {
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "auto", "legacy", "win32")).toBe(false);
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "jvm", "jvm", "win32")).toBe(false);
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "legacy", "legacy", "win32")).toBe(true);
|
||||
expect(shouldRunAlternativeNativeExtractor("7z.exe", "C:\\release.zip", "auto", "auto", "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs one deduplicated serial recovery pass after parallel unknown failures", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-serial-recovery-once-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
for (const name of ["a.zip", "b.zip", "c.zip"]) {
|
||||
writeZipFixture(path.join(packageDir, name), [{ name: `${name}.txt`, content: name }]);
|
||||
}
|
||||
const attempts = new Map<string, number>();
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
scheduleArchive: async (archivePath, execute) => {
|
||||
const archiveName = path.basename(archivePath);
|
||||
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||
return execute(new AbortController().signal);
|
||||
},
|
||||
onOutput: (event) => {
|
||||
const archiveName = path.basename(event.archivePath);
|
||||
if (event.state === "opened" && (archiveName !== "b.zip" || attempts.get(archiveName) === 1)) {
|
||||
throw new Error(`transient-${archiveName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 2 }));
|
||||
expect(Object.fromEntries(attempts)).toEqual({ "a.zip": 1, "b.zip": 2, "c.zip": 2 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath)("isolates throwing Legacy password-log callbacks without retrying archives", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-callback-isolation-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
for (const name of ["a", "b"]) {
|
||||
const inputPath = path.join(root, `${name}.txt`);
|
||||
fs.writeFileSync(inputPath, `${name} payload`, "utf8");
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, `${name}.rar`), inputPath]).status).toBe(0);
|
||||
}
|
||||
const attempts = new Map<string, number>();
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
scheduleArchive: async (archivePath, execute) => {
|
||||
const archiveName = path.basename(archivePath);
|
||||
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||
return execute(new AbortController().signal);
|
||||
},
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => {
|
||||
if (message.startsWith("Passwort-Versuch ")) {
|
||||
throw new Error("observer failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 2, failed: 0 }));
|
||||
expect(Object.fromEntries(attempts)).toEqual({ "a.rar": 1, "b.rar": 1 });
|
||||
expect(failures).toHaveLength(0);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("shouldSuggestRedownloadAfterCrossBackendFailure", () => {
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("reports redownload only after real Legacy and JVM CRC failures exhaust candidates", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = createEncryptedCorruptRarFixture(root, "serienjunkies.org", "cross-backend-exhausted");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir: path.join(root, "out"),
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: "",
|
||||
onArchiveFailure: (failure) => failures.push(failure)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
category: "crc_error",
|
||||
suggestRedownload: true
|
||||
}));
|
||||
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("keeps real Cross-Backend CRC recovery disabled when JVM stops before the final candidate", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-not-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const actualPassword = "cross-backend-early-secret";
|
||||
const packageDir = createEncryptedCorruptRarFixture(root, actualPassword, "cross-backend-not-exhausted");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir: path.join(root, "out"),
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: actualPassword,
|
||||
onArchiveFailure: (failure) => failures.push(failure)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
category: "crc_error",
|
||||
suggestRedownload: false
|
||||
}));
|
||||
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||
}, 30_000);
|
||||
|
||||
it("suggests recovery when both backends report CRC failure after every password candidate", () => {
|
||||
expect(shouldSuggestRedownloadAfterCrossBackendFailure("crc_error", "crc_error", true)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong_password", "crc_error", true],
|
||||
["crc_error", "wrong_password", true],
|
||||
["crc_error", "unsupported_format", true],
|
||||
["crc_error", "crc_error", false]
|
||||
] as const)("does not suggest recovery for legacy=%s jvm=%s exhausted=%s", (legacyCategory, jvmCategory, exhausted) => {
|
||||
expect(shouldSuggestRedownloadAfterCrossBackendFailure(legacyCategory, jvmCategory, exhausted)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJvmPasswordAttemptLine", () => {
|
||||
it("accepts only bounded attempt metadata without a password field", () => {
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5")).toEqual({ attempt: 2, total: 5 });
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 0 5")).toBeNull();
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5 secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("derives exhaustion only from a valid final JVM attempt", () => {
|
||||
expect(summarizeJvmPasswordAttempts(1, 3, false)).toEqual({ attempts: 1, total: 3, exhausted: false });
|
||||
expect(summarizeJvmPasswordAttempts(3, 3, false)).toEqual({ attempts: 3, total: 3, exhausted: true });
|
||||
expect(summarizeJvmPasswordAttempts(3, 3, true)).toEqual({ attempts: 3, total: 3, exhausted: false });
|
||||
expect(summarizeJvmPasswordAttempts(4, 3, false)).toEqual({ attempts: 0, total: 0, exhausted: false });
|
||||
});
|
||||
|
||||
it("redacts successful password payloads from JVM diagnostics", () => {
|
||||
expect(redactJvmDiagnosticLine("RD_PASSWORD c2VjcmV0")).toBe("RD_PASSWORD <redacted>");
|
||||
expect(redactJvmDiagnosticLine("RD_PASSWORD_ATTEMPT 2 5")).toBe("RD_PASSWORD_ATTEMPT 2 5");
|
||||
expect(redactJvmDiagnosticLine("RD_ERROR CRCERROR")).toBe("RD_ERROR CRCERROR");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("direct output scope", () => {
|
||||
it.each([
|
||||
["overwrite", "new", "overwritten", ["episode.mkv"]],
|
||||
|
||||
@@ -49,6 +49,35 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CRC-Check läuft", "CRC check running"],
|
||||
["Entpacken - Ausstehend", "Extracting - Pending"],
|
||||
["Entpacken - Warten auf Parts", "Extracting - Waiting for parts"],
|
||||
["Archive stabilisieren...", "Stabilizing archives..."],
|
||||
["Entpacken vorbereiten...", "Preparing extraction..."],
|
||||
["Entpacken wird neu gestartet...", "Restarting extraction..."],
|
||||
["Nested Entpacken...", "Nested extraction..."],
|
||||
["Umbenennen...", "Renaming..."],
|
||||
["Tonspur...", "Audio track..."],
|
||||
["Aufräumen...", "Cleaning up..."],
|
||||
["Verschiebe Videos...", "Moving videos..."]
|
||||
])("translates package runtime state %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Entpacken - 42%", "Extracting - 42%"],
|
||||
["Passwort gefunden", "Password found"],
|
||||
["Passwort knacken: 50% (2/4)", "Cracking password: 50% (2/4)"],
|
||||
["Entpacken (1/3) - Nächstes Archiv...", "Extracting (1/3) - Next archive..."]
|
||||
])("translates compact runtime text %s for visible and attribute surfaces", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
expect(translateUiText(`Bereit · ${german}`, "en")).toBe(`Ready · ${english}`);
|
||||
expect(translateUiText(`Ready · ${english}`, "de")).toBe(`Bereit · ${german}`);
|
||||
});
|
||||
|
||||
it("translates the complete history surface including status values", () => {
|
||||
const translations = new Map([
|
||||
["Alle Einträge", "All entries"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
@@ -13,6 +14,7 @@ import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const sessionRoots = new WeakMap<object, string>();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -36,6 +38,7 @@ function setup(settings: Partial<AppSettings> = {}): {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
sessionRoots.set(session, root);
|
||||
const events: NotificationEvent[] = [];
|
||||
const history: HistoryEntry[] = [];
|
||||
const manager = new DownloadManager(
|
||||
@@ -70,11 +73,15 @@ function addPackage(
|
||||
packageId = "pkg-1"
|
||||
): PackageEntry {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const root = sessionRoots.get(session) || os.tmpdir();
|
||||
const outputDir = path.join(root, "out", packageId);
|
||||
const extractDir = path.join(root, "extract", packageId);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const pkg: PackageEntry = {
|
||||
id: packageId,
|
||||
name: `Test ${packageId}`,
|
||||
outputDir: `C:/out/${packageId}`,
|
||||
extractDir: `C:/extract/${packageId}`,
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "queued",
|
||||
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
|
||||
cancelled: false,
|
||||
@@ -90,6 +97,14 @@ function addPackage(
|
||||
session.packageOrder.push(packageId);
|
||||
statuses.forEach((status, index) => {
|
||||
const itemId = `${packageId}-item-${index}`;
|
||||
const fileName = `${packageId}-${index}.zip`;
|
||||
const targetPath = path.join(outputDir, fileName);
|
||||
if (status === "completed") {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from(`video-${packageId}-${index}`));
|
||||
zip.writeZip(targetPath);
|
||||
}
|
||||
const downloadedBytes = status === "completed" ? fs.statSync(targetPath).size : 0;
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
@@ -98,11 +113,11 @@ function addPackage(
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: status === "completed" ? 1_000 : 0,
|
||||
totalBytes: 1_000,
|
||||
downloadedBytes,
|
||||
totalBytes: status === "completed" ? downloadedBytes : 1_000,
|
||||
progressPercent: status === "completed" ? 100 : 0,
|
||||
fileName: `${packageId}-${index}.rar`,
|
||||
targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`,
|
||||
fileName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: status === "failed" ? "offline" : "",
|
||||
@@ -249,7 +264,7 @@ describe("authoritative package completion", () => {
|
||||
|
||||
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
expect(postProcess).toHaveBeenCalledWith(pkg.id);
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-2",
|
||||
@@ -293,7 +308,7 @@ describe("authoritative package completion", () => {
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
expect(pkg.resultGeneration).toBe(8);
|
||||
|
||||
pkg.archiveOperations = [{
|
||||
@@ -897,7 +912,7 @@ describe("authoritative run completion", () => {
|
||||
postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||
expect(retriedPostProcess).toBeDefined();
|
||||
releasePostProcess();
|
||||
|
||||
@@ -94,6 +94,104 @@ describe("download package presentation", () => {
|
||||
expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar");
|
||||
});
|
||||
|
||||
it("shows the active CRC check instead of a completed fraction", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("archive", "CRC-Check läuft", { status: "integrity_check" })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("CRC-Check läuft");
|
||||
});
|
||||
|
||||
it("keeps an active CRC check ahead of historical sibling extraction errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "CRC-Check läuft", { status: "integrity_check" })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("CRC-Check läuft");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["extracting", "Archive stabilisieren..."],
|
||||
["extracting", "Entpacken vorbereiten..."],
|
||||
["queued", "Entpacken wird neu gestartet..."],
|
||||
["completed", "Nested Entpacken..."],
|
||||
["completed", "Renaming..."],
|
||||
["completed", "Tonspur..."],
|
||||
["completed", "Aufräumen..."],
|
||||
["completed", "Verschiebe Videos..."]
|
||||
] as const)("keeps the active package phase %s / %s ahead of historical sibling errors", (packageStatus, postProcessLabel) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Fertig")
|
||||
], { status: packageStatus, postProcessLabel }));
|
||||
|
||||
expect(presentation.status).toBe(postProcessLabel);
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
expect(presentation.extractFailure?.id).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps a running download ahead of historical sibling extraction errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("Download läuft");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Entpacken - Ausstehend",
|
||||
"Entpacken - Warten auf Parts"
|
||||
])("keeps a running download ahead of the sibling state %s", (fullStatus) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("pending", fullStatus),
|
||||
item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("Download läuft");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "Entpacken - Ausstehend", "Entpacken - Ausstehend"],
|
||||
["extracting", "Entpacken - Ausstehend", "Entpacken - Ausstehend"],
|
||||
["queued", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"],
|
||||
["extracting", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"]
|
||||
] as const)("keeps the pending extraction state %s / %s visible on the package", (packageStatus, fullStatus, expectedStatus) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("archive", fullStatus)
|
||||
], { status: packageStatus }));
|
||||
|
||||
expect(presentation.status).toBe(expectedStatus);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Entpacken 42% (1/1) · release.part01.rar",
|
||||
"Passwort knacken: 50% (2/4)",
|
||||
"Finalisieren - 99% (0/1) · release.part01.rar"
|
||||
])("keeps the active extraction phase %s ahead of historical sibling errors", (postProcessLabel) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", postProcessLabel)
|
||||
], { status: "extracting", postProcessLabel }));
|
||||
|
||||
expect(presentation.status).toBe(postProcessLabel);
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
expect(presentation.extractFailure?.id).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps the active disk wait ahead of historical sibling errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Warte auf Festplatte")
|
||||
], { status: "queued" }));
|
||||
|
||||
expect(presentation.status).toBe("Warte auf Festplatte");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
|
||||
const items = [
|
||||
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
|
||||
|
||||
@@ -66,6 +66,14 @@ const redistributionFiles = [
|
||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
||||
] as const;
|
||||
const jvmRuntimeFiles = Object.freeze({
|
||||
"resources/extractor-jvm/classes/.source.sha256": "source-digest\n",
|
||||
"resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x01]),
|
||||
"resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x02]),
|
||||
"resources/extractor-jvm/lib/sevenzipjbinding.jar": Buffer.from("sevenzip-binding"),
|
||||
"resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar": Buffer.from("sevenzip-platforms"),
|
||||
"resources/extractor-jvm/lib/zip4j.jar": Buffer.from("zip4j")
|
||||
});
|
||||
|
||||
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||
@@ -104,9 +112,24 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||
if (omittedName !== "app_icon.ico") {
|
||||
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
|
||||
}
|
||||
for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) {
|
||||
if (path.basename(relativePath) !== omittedName) {
|
||||
writeFile(outputDir, `resources/app.asar.unpacked/${relativePath}`, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "") {
|
||||
function writeJvmRuntimeFiles(rootDir: string, packaged = false): void {
|
||||
for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) {
|
||||
const targetPath = packaged
|
||||
? `win-unpacked/resources/app.asar.unpacked/${relativePath}`
|
||||
: relativePath;
|
||||
writeFile(rootDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "", corruptArchiveName = "") {
|
||||
let currentArchiveName = "";
|
||||
return (command: string, args: string[]): CommandResult => {
|
||||
const archivePath = args[1] || "";
|
||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||
@@ -115,9 +138,17 @@ function createArchiveCommandRunner(omittedName = "") {
|
||||
}
|
||||
const outputDir = outputArg.slice(2);
|
||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||
currentArchiveName = path.basename(archivePath);
|
||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||
writeArchivePayload(outputDir, omittedName);
|
||||
if (currentArchiveName === corruptArchiveName) {
|
||||
writeFile(
|
||||
outputDir,
|
||||
"resources/app.asar.unpacked/resources/extractor-jvm/lib/zip4j.jar",
|
||||
"corrupt-zip4j"
|
||||
);
|
||||
}
|
||||
}
|
||||
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||
};
|
||||
@@ -147,6 +178,9 @@ function createReleaseFixture(): string {
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"package.json"
|
||||
],
|
||||
asarUnpack: [
|
||||
"resources/extractor-jvm/**/*"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "LICENSE",
|
||||
@@ -189,6 +223,8 @@ function createReleaseFixture(): string {
|
||||
writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||
writeRedistributionFiles(rootDir);
|
||||
writeRedistributionFiles(rootDir, true);
|
||||
writeJvmRuntimeFiles(rootDir);
|
||||
writeJvmRuntimeFiles(rootDir, true);
|
||||
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||
writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon");
|
||||
writeFile(rootDir, "win-unpacked/resources/app.asar", validAppAsar);
|
||||
@@ -357,6 +393,65 @@ describe("public release metadata", () => {
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not unpack the JVM runtime", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
delete packageJson.build.asarUnpack;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/asarUnpack|JVM runtime/i);
|
||||
});
|
||||
|
||||
it("rejects a missing JVM class in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"classes",
|
||||
"com",
|
||||
"sucukdeluxe",
|
||||
"extractor",
|
||||
"JBindExtractorMain$Backend.class"
|
||||
));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Backend\.class|missing/i);
|
||||
});
|
||||
|
||||
it("rejects changed JVM bytecode in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"classes",
|
||||
"com",
|
||||
"sucukdeluxe",
|
||||
"extractor",
|
||||
"JBindExtractorMain.class"
|
||||
), "stale-bytecode");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|JBindExtractorMain\.class|SHA-?256|content/i);
|
||||
});
|
||||
|
||||
it("rejects stale extra JVM bytecode in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
writeFile(
|
||||
rootDir,
|
||||
"win-unpacked/resources/app.asar.unpacked/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/Stale.class",
|
||||
"stale-bytecode"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Stale\.class|unexpected/i);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
@@ -438,6 +533,18 @@ describe("public release metadata", () => {
|
||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||
});
|
||||
|
||||
it("rejects a portable archive whose JVM runtime differs from the repository", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
expect(() => verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner(
|
||||
"",
|
||||
"Multi-Debrid-Downloader-1.7.233-portable.exe"
|
||||
)
|
||||
})).toThrow(/portable|JVM runtime|zip4j\.jar|SHA-?256|content/i);
|
||||
});
|
||||
|
||||
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const result = spawnSync(process.execPath, [
|
||||
|
||||
@@ -122,10 +122,10 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns single archive item when no pattern matches", () => {
|
||||
it("does not guess a different single archive when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when items have no archive extensions", () => {
|
||||
@@ -217,6 +217,17 @@ describe("resolveSelectedArchiveSetsFromCandidates", () => {
|
||||
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]);
|
||||
expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]);
|
||||
});
|
||||
|
||||
it("resolves a uniquely matching pathless legacy item", () => {
|
||||
const selected = resolveSelectedArchiveSetsFromCandidates(
|
||||
["C:\\Downloads\\Episode.E01.rar"],
|
||||
[{ id: "legacy", fileName: "Episode.E01.rar", targetPath: "", status: "completed" }] as any,
|
||||
new Set(["legacy"])
|
||||
);
|
||||
|
||||
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.rar"]);
|
||||
expect([...selected.itemIds]).toEqual(["legacy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markPlannedHybridArchiveItemsPending", () => {
|
||||
|
||||
Reference in New Issue
Block a user