diff --git a/CHANGELOG.md b/CHANGELOG.md index afdaac1..c09874f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index f3cfa67..e1a9483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3162f80..6280c81 100644 --- a/package.json +++ b/package.json @@ -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", @@ -11,8 +11,9 @@ "visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort", "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": "npm run build:main && npm run build:renderer", + "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", diff --git a/resources/extractor-jvm/classes/.source.sha256 b/resources/extractor-jvm/classes/.source.sha256 new file mode 100644 index 0000000..fc0c605 --- /dev/null +++ b/resources/extractor-jvm/classes/.source.sha256 @@ -0,0 +1 @@ +77818e292a3c70c1cf1cf8ab12f02e8e05e0e9a5e4140108465d62cf18ecf30f diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class index e3c3b5e..3ec1fcf 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$AmbiguousPasswordOrIntegrityException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$AmbiguousPasswordOrIntegrityException.class new file mode 100644 index 0000000..18f4ff9 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$AmbiguousPasswordOrIntegrityException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index c20c66d..d7293e2 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class index 1a901bc..0904ad0 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class index acdb6fd..47f498c 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index 0317fda..8a7b69f 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class new file mode 100644 index 0000000..fd2089b Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index aa364eb..12c090d 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class index 991cc7c..5c8562f 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 8c8a0c7..c5e5778 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class index 3630119..4ac22eb 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class index be482e3..e0d910d 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class index ec8056a..32f1078 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index d90666a..7c13fb6 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index 33816fd..79460f2 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class index ba1fb38..9ea7279 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class index 59e99dc..a7edda9 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index 4303c1d..ef61388 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index c443794..fd08e27 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 60c2826..d7cc3d0 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -2,7 +2,8 @@ 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.FileHeader; +import net.lingala.zip4j.model.enums.EncryptionMethod; import net.sf.sevenzipjbinding.ExtractAskMode; import net.sf.sevenzipjbinding.ExtractOperationResult; import net.sf.sevenzipjbinding.IArchiveExtractCallback; @@ -106,115 +107,234 @@ public final class JBindExtractorMain { } } - private static ExtractionRequest parseDaemonRequest(String jsonLine) { - - ExtractionRequest request = new ExtractionRequest(); - request.archiveFile = new File(extractJsonString(jsonLine, "archive")); - request.targetDir = new File(extractJsonString(jsonLine, "target")); - String conflict = extractJsonString(jsonLine, "conflict"); - if (conflict.length() > 0) { - request.conflictMode = ConflictMode.fromValue(conflict); - } - String backend = extractJsonString(jsonLine, "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; - } - } - } + private static ExtractionRequest parseDaemonRequest(String jsonLine) { + Map values = new DaemonJsonParser(jsonLine).parseObject(); + ExtractionRequest request = new ExtractionRequest(); + 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 = optionalJsonString(values, "backend"); + if (backend.length() > 0) { + request.backend = Backend.fromValue(backend); + } + 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()) { throw new IllegalArgumentException("Archiv nicht gefunden: " + (request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath())); } if (request.targetDir == null) { throw new IllegalArgumentException("--target fehlt"); - } - 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; - } - return -1; - } - - 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; - } - } else { - sb.append(c); - } - } - return sb.toString(); - } + } + return request; + } + + private static String requireJsonString(Map values, String key) { + String value = optionalJsonString(values, key); + if (value.length() == 0) { + throw new IllegalArgumentException("Daemon-Feld fehlt: " + key); + } + return value; + } + + private static String optionalJsonString(Map 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 parseObject() { + skipWhitespace(); + expect('{'); + Map values = new HashMap(); + 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(); + } + values.put(key, value); + skipWhitespace(); + if (consume('}')) { + requireEnd(); + return values; + } + expect(','); + skipWhitespace(); + } + } + + private List parseStringArray() { + expect('['); + List values = new ArrayList(); + 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 passwords = normalizePasswords(request.passwords); - Exception lastError = null; - boolean hadWrongPassword = false; - for (String password : passwords) { - try { - extractSingle(request, password); - emitPassword(password); - emitDone(); + Exception lastError = null; + Exception integrityError = null; + boolean hadWrongPassword = false; + for (int passwordIndex = 0; passwordIndex < passwords.size(); passwordIndex++) { + String password = passwords.get(passwordIndex); + emitPasswordAttempt(passwordIndex + 1, passwords.size()); + try { + extractSingle(request, password); + emitDone(); return 0; - } catch (WrongPasswordException wrongPassword) { - hadWrongPassword = true; - lastError = wrongPassword; - } catch (Exception error) { - lastError = error; + } catch (AmbiguousPasswordOrIntegrityException ambiguous) { + integrityError = ambiguous; + lastError = ambiguous; + } catch (WrongPasswordException wrongPassword) { + hadWrongPassword = true; + lastError = wrongPassword; + } catch (Exception error) { + integrityError = null; + lastError = error; break; } } - if (hadWrongPassword && (lastError instanceof WrongPasswordException)) { + if (integrityError != null) { + throw integrityError; + } + if (hadWrongPassword && (lastError instanceof WrongPasswordException)) { emitError("Falsches Archiv-Passwort"); return 1; } @@ -272,8 +392,31 @@ public final class JBindExtractorMain { continue; } encrypted = encrypted || header.isEncrypted(); - totalUnits += safeSize(header.getUncompressedSize()); - } + 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 reserved = new HashSet(); TargetPlanInvariant targetPlan = new TargetPlanInvariant(); Map plannedEntryNames = new IdentityHashMap(); @@ -326,8 +469,9 @@ public final class JBindExtractorMain { emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition); ensureDirectory(output.getParentFile()); rejectLinkedPath(request.targetDir, output); - long[] remaining = new long[] { itemUnits }; - boolean extractionSuccess = false; + long[] remaining = new long[] { itemUnits }; + boolean extractionSuccess = false; + boolean outputProduced = false; try { InputStream in = zipFile.getInputStream(header); try { @@ -341,8 +485,9 @@ public final class JBindExtractorMain { } if (read == 0) { continue; - } - out.write(buffer, 0, read); + } + out.write(buffer, 0, read); + outputProduced = true; long accounted = Math.min(remaining[0], (long) read); remaining[0] -= accounted; progress.advance(accounted); @@ -369,10 +514,18 @@ public final class JBindExtractorMain { extractionSuccess = true; emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition); } catch (ZipException error) { - if (isWrongPassword(error, encrypted)) { - throw new WrongPasswordException(error); - } - throw error; + 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()) { if (output.delete()) { @@ -405,8 +558,13 @@ public final class JBindExtractorMain { ensureSevenZipInitialized(); SevenZipArchiveContext context = null; try { - context = openSevenZipArchive(request.archiveFile, password); - IInArchive archive = context.archive; + 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()); @@ -512,19 +670,21 @@ public final class JBindExtractorMain { final File[] currentOutput = new File[1]; final FileOutputStream[] currentStream = new FileOutputStream[1]; final boolean[] currentSuccess = new boolean[1]; - final long[] currentRemaining = new long[1]; - final Throwable[] firstError = new Throwable[1]; - final int[] currentPos = new int[] { -1 }; - + 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; @@ -552,15 +712,22 @@ public final class JBindExtractorMain { String effectivePassword = password == null ? "" : password; SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword); - if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { - VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); - try { - IInArchive archive = SevenZip.openInArchive(null, volumed, callback); - return new SevenZipArchiveContext(archive, null, volumed, callback); - } catch (Exception error) { - callback.close(); - throw error; - } + if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { + VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); + try { + 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; + } } RandomAccessFile raf = new RandomAccessFile(archiveFile, "r"); @@ -568,23 +735,34 @@ public final class JBindExtractorMain { try { IInArchive archive = SevenZip.openInArchive(null, stream, callback); return new SevenZipArchiveContext(archive, stream, null, callback); - } catch (Exception error) { + } catch (Exception error) { + SevenZipException volumeAccessError = callback.getVolumeAccessError(); try { stream.close(); } catch (Throwable ignored) { } - try { - raf.close(); - } catch (Throwable ignored) { - } - throw error; + try { + 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) { - if (error == null) { - return false; - } + 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,13 +770,31 @@ 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 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) { - if (!encrypted || result == null) { - return false; - } + 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,10 +1227,9 @@ 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() { System.out.println("RD_DONE"); @@ -1141,16 +1336,18 @@ public final class JBindExtractorMain { private final FileOutputStream[] currentStream; private final boolean[] currentSuccess; private final long[] currentRemaining; - private final int[] currentPos; - private final Throwable[] firstError; + private final int[] currentPos; + private final Throwable[] firstError; + private final boolean[] passwordRequested; + private final boolean[] outputProduced; BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map indexToPos, List fileIndices, List outputFiles, List fileSizes, List entryNames, List dispositions, - ProgressTracker progress, boolean encrypted, String password, - File[] currentOutput, FileOutputStream[] currentStream, - boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, - Throwable[] firstError) { + ProgressTracker progress, boolean encrypted, String password, + File[] currentOutput, FileOutputStream[] currentStream, + boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, + Throwable[] firstError, boolean[] passwordRequested, boolean[] outputProduced) { this.archive = archive; this.archiveFile = archiveFile; this.targetDir = targetDir; @@ -1168,12 +1365,15 @@ public final class JBindExtractorMain { this.currentSuccess = currentSuccess; this.currentRemaining = currentRemaining; this.currentPos = currentPos; - this.firstError = firstError; + this.firstError = firstError; + this.passwordRequested = passwordRequested; + this.outputProduced = outputProduced; } @Override - public String cryptoGetTextPassword() { - return password; + public String cryptoGetTextPassword() { + passwordRequested[0] = true; + return password; } @Override @@ -1225,9 +1425,10 @@ public final class JBindExtractorMain { if (data == null || data.length == 0) { return 0; } - try { - currentStream[0].write(data); - } catch (IOException error) { + try { + currentStream[0].write(data); + outputProduced[0] = true; + } catch (IOException error) { throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error); } long accounted = Math.min(currentRemaining[0], (long) data.length); @@ -1267,8 +1468,8 @@ public final class JBindExtractorMain { } } else { discardCurrentOutput(); - if (firstError[0] == null) { - if (isPasswordFailure(result, encrypted)) { + if (firstError[0] == null) { + 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()); @@ -1310,13 +1511,21 @@ public final class JBindExtractorMain { } } - private static final class WrongPasswordException extends Exception { + private static final class WrongPasswordException extends Exception { private static final long serialVersionUID = 1L; WrongPasswordException(Throwable cause) { super(cause); } - } + } + + 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; @@ -1401,7 +1610,9 @@ public final class JBindExtractorMain { private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable { private final File archiveDir; private final String firstFileName; - private final String password; + private final String password; + private boolean passwordRequested; + private SevenZipException volumeAccessError; private final Map openRafs = new HashMap(); SevenZipVolumeCallback(File archiveFile, String password) { @@ -1433,8 +1644,9 @@ public final class JBindExtractorMain { } raf.seek(0L); return new RandomAccessFileInStream(raf); - } catch (IOException error) { - throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); + } catch (IOException error) { + volumeAccessError = new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); + throw volumeAccessError; } } @@ -1449,9 +1661,18 @@ public final class JBindExtractorMain { } @Override - public String cryptoGetTextPassword() { - return password; - } + 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) { diff --git a/scripts/build-extractor-jvm.mjs b/scripts/build-extractor-jvm.mjs new file mode 100644 index 0000000..e8c488f --- /dev/null +++ b/scripts/build-extractor-jvm.mjs @@ -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; +} diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index 27cba24..16d4f87 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -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"); @@ -308,11 +400,15 @@ export function verifyPublicRelease(rootDir = process.cwd()) { throw new Error(`NSIS update include omits ${requiredText}`); } } - const buildFiles = Array.isArray(build.files) ? build.files : []; + const buildFiles = Array.isArray(build.files) ? build.files : []; const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry)); - if (missingBuildFiles.length > 0) { - throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`); - } + 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: { @@ -399,8 +500,9 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) { requireNonEmptyFile(archivePath, "release archive"); const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-")); try { - extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); + extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot); + verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot); verifiedArchives.push(archiveName); } finally { fs.rmSync(extractionRoot, { recursive: true, force: true }); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 883475b..a3f9992 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -66,7 +66,7 @@ function releaseTlsSkip(): void { import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; -import { clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; +import { classifyExtractionError, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; @@ -157,11 +157,46 @@ type PackageItemDiskState = { reason: "ok" | "missing_path" | "missing_file" | "too_small" | "persisted_shortfall"; }; -type HybridFailedArchiveState = { - marker: string; - lastError: string; - updatedAt: number; -}; +type HybridFailedArchiveState = { + marker: string; + lastError: string; + updatedAt: number; +}; + +type PackageDiskRetryRequest = { + retryAt: number; + manualRequested: boolean; + postProcessVersion: number; + runOwnerId: string | null; + archiveFilter?: Set; + selectedItemIds?: Set; +}; + +type PackageDiskRetryPlan = PackageDiskRetryRequest & { + id: string; + generation: number; +}; + +type FileFingerprint = { + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; +}; + +type ArchiveCleanupTarget = { + filePath: string; + fingerprint: FileFingerprint; +}; + +type ManualExtractionPlan = { + packageId: string; + generation: number; + targetItemIds: Set; + archiveFilter?: Set; + itemFiles: Map; +}; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; @@ -1680,7 +1715,7 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download ? items.filter((item) => { const targetPath = String(item.targetPath || "").trim(); if (!targetPath) { - return true; + return false; } return pathKey(path.dirname(path.resolve(targetPath))) === pathKey(path.dirname(path.resolve(normalizedArchivePath))); }) @@ -1732,7 +1767,7 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const exactMatch = candidateItems.filter((item) => itemBaseName(item).toLowerCase() === entryLower); if (exactMatch.length > 0) return exactMatch; - const archiveStem = entryLower + const archiveStem = entryLower .replace(/\.part\d+\.rar$/i, "") .replace(/\.r\d{2,3}$/i, "") .replace(/\.rar$/i, "") @@ -1744,16 +1779,13 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const name = itemBaseName(item).toLowerCase(); return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); }); - if (stemMatch.length > 0) return stemMatch; - } - - if (candidateItems.length === 1) { - const singleName = itemBaseName(candidateItems[0]).toLowerCase(); - if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { - return candidateItems; - } + if (stemMatch.length > 0) return stemMatch; } - + + if (normalizedArchivePath) { + return []; + } + return []; } @@ -1764,13 +1796,33 @@ export function resolveSelectedArchiveSetsFromCandidates( ): { archivePaths: Set; itemIds: Set } { const archivePaths = new Set(); const itemIds = new Set(); - for (const candidatePath of candidatePaths) { - const archiveItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath); - if (!archiveItems.some((item) => selectedItemIds.has(item.id))) { + const pathlessItems = items.filter((item) => !String(item.targetPath || "").trim()); + const candidates = candidatePaths.map((candidatePath) => { + const qualifiedItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath); + const legacyItems = resolveArchiveItemsFromList(path.basename(candidatePath), pathlessItems); + return { + candidatePath, + archiveItems: [...new Map([...qualifiedItems, ...legacyItems].map((item) => [item.id, item])).values()] + }; + }); + const pathlessMatchCount = new Map(); + for (const candidate of candidates) { + for (const item of candidate.archiveItems) { + if (!String(item.targetPath || "").trim()) { + pathlessMatchCount.set(item.id, (pathlessMatchCount.get(item.id) || 0) + 1); + } + } + } + const ambiguousPathlessIds = new Set([...pathlessMatchCount] + .filter(([, count]) => count > 1) + .map(([itemId]) => itemId)); + for (const { candidatePath, archiveItems } of candidates) { + const unambiguousItems = archiveItems.filter((item) => !ambiguousPathlessIds.has(item.id)); + if (!unambiguousItems.some((item) => selectedItemIds.has(item.id))) { continue; } archivePaths.add(candidatePath); - for (const item of archiveItems) { + for (const item of unambiguousItems) { itemIds.add(item.id); } } @@ -1797,6 +1849,57 @@ export function markPlannedHybridArchiveItemsPending( } return changed; } + +export function findCrcImplicatedArchiveItems(errorText: string, archiveItems: DownloadItem[]): DownloadItem[] { + const normalized = String(errorText || "").toLocaleLowerCase("en-US"); + const markers = ["checksum error", "crc error", "crcerror", "dataerror", "prüfsummenfehler", "pr�fsummenfehler"]; + const markerIndex = markers.reduce((latest, marker) => Math.max(latest, normalized.lastIndexOf(marker)), -1); + if (markerIndex < 0) { + return []; + } + let selected: DownloadItem | null = null; + let selectedDistance = Number.POSITIVE_INFINITY; + let selectedIndex = -1; + for (const item of archiveItems) { + const fileName = path.basename(item.targetPath || item.fileName || "").toLocaleLowerCase("en-US"); + if (!fileName) { + continue; + } + const isBoundary = (index: number): boolean => index < 0 || index >= normalized.length || !/[a-z0-9]/i.test(normalized[index]); + const findBoundedBefore = (): number => { + let index = normalized.lastIndexOf(fileName, markerIndex); + while (index >= 0 && (!isBoundary(index - 1) || !isBoundary(index + fileName.length))) { + index = normalized.lastIndexOf(fileName, index - 1); + } + return index; + }; + const findBoundedAfter = (): number => { + let index = normalized.indexOf(fileName, markerIndex); + while (index >= 0 && (!isBoundary(index - 1) || !isBoundary(index + fileName.length))) { + index = normalized.indexOf(fileName, index + 1); + } + return index; + }; + const beforeIndex = findBoundedBefore(); + const afterIndex = findBoundedAfter(); + const beforeDistance = beforeIndex >= 0 && markerIndex - beforeIndex <= 4096 + ? markerIndex - (beforeIndex + fileName.length) + : Number.POSITIVE_INFINITY; + const afterDistance = afterIndex >= 0 && afterIndex - markerIndex <= 4096 + ? afterIndex - markerIndex + : Number.POSITIVE_INFINITY; + const distance = Math.min(Math.max(0, beforeDistance), afterDistance); + if (distance < selectedDistance) { + selected = item; + selectedDistance = distance; + selectedIndex = beforeDistance <= afterDistance ? beforeIndex : afterIndex; + } + } + if (!selected || selectedIndex < 0) { + return []; + } + return [selected]; +} function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); @@ -2065,7 +2168,9 @@ export class DownloadManager extends EventEmitter { private runItemIds = new Set(); - private runPackageIds = new Set(); + private runPackageIds = new Set(); + + private runScopeKind: "all" | "selected" | "postprocessing" | null = null; private runOutcomes = new Map(); @@ -2123,8 +2228,141 @@ export class DownloadManager extends EventEmitter { private packageDiskRetryAfterByPackage = new Map(); + private packageDiskRetryPlans = new Map(); + + private packageDiskRetryTimers = new Map }>(); + private diskWaitEvents: NonNullable = []; + private clearPackageDiskRetry(packageId: string, clearEvent = true): void { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState) { + clearTimeout(timerState.timer); + this.packageDiskRetryTimers.delete(packageId); + } + this.packageDiskRetryAfterByPackage.delete(packageId); + this.packageDiskRetryPlans.delete(packageId); + if (clearEvent) { + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.packageId !== packageId || event.phase !== "extract"); + } + } + + private pausePackageDiskRetry(packageId: string): void { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState) { + clearTimeout(timerState.timer); + this.packageDiskRetryTimers.delete(packageId); + } + } + + private clearAllPackageDiskRetries(): void { + for (const timerState of this.packageDiskRetryTimers.values()) { + clearTimeout(timerState.timer); + } + this.packageDiskRetryTimers.clear(); + this.packageDiskRetryAfterByPackage.clear(); + this.packageDiskRetryPlans.clear(); + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.phase !== "extract"); + } + + private schedulePackageDiskRetry(packageId: string, plan: PackageDiskRetryRequest): boolean { + const pkg = this.session.packages[packageId]; + if (!pkg + || pkg.cancelled + || !pkg.enabled + || plan.postProcessVersion !== this.getPackagePostProcessVersion(packageId) + || plan.runOwnerId !== this.getPackageResultRunOwner(packageId) + || this.lifecyclePhase === "stopping" + || this.healthManualStop + || this.healthShuttingDown) { + return false; + } + this.clearPackageDiskRetry(packageId, false); + const storedPlan: PackageDiskRetryPlan = { + id: uuidv4(), + generation: this.getPackageResultGeneration(packageId), + retryAt: plan.retryAt, + manualRequested: plan.manualRequested, + postProcessVersion: plan.postProcessVersion, + runOwnerId: plan.runOwnerId, + archiveFilter: plan.archiveFilter ? new Set(plan.archiveFilter) : undefined, + selectedItemIds: plan.selectedItemIds ? new Set(plan.selectedItemIds) : undefined + }; + this.packageDiskRetryAfterByPackage.set(packageId, storedPlan.retryAt); + this.packageDiskRetryPlans.set(packageId, storedPlan); + const timer = setTimeout(() => { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState?.planId === storedPlan.id) { + this.packageDiskRetryTimers.delete(packageId); + } + this.executePackageDiskRetry(packageId, storedPlan.id); + }, Math.max(0, storedPlan.retryAt - nowMs())); + timer.unref?.(); + this.packageDiskRetryTimers.set(packageId, { planId: storedPlan.id, timer }); + return true; + } + + private executePackageDiskRetry(packageId: string, planId: string): void { + const currentPlan = this.packageDiskRetryPlans.get(packageId); + if (!currentPlan || currentPlan.id !== planId) { + this.completeStopIfDrained(); + return; + } + const pkg = this.session.packages[packageId]; + if (!pkg + || pkg.cancelled + || currentPlan.generation !== this.getPackageResultGeneration(packageId) + || currentPlan.postProcessVersion !== this.getPackagePostProcessVersion(packageId) + || currentPlan.runOwnerId !== this.getPackageResultRunOwner(packageId)) { + this.clearPackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + if (!pkg.enabled) { + this.pausePackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + if (this.lifecyclePhase === "stopping" + || (this.healthManualStop && currentPlan.runOwnerId !== null) + || this.healthShuttingDown) { + this.clearPackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + this.packageDiskRetryAfterByPackage.delete(packageId); + this.packageDiskRetryPlans.delete(packageId); + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.packageId !== packageId || event.phase !== "extract"); + const selectedIds = currentPlan.selectedItemIds; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed" || (selectedIds && !selectedIds.has(itemId))) { + continue; + } + if (item.fullStatus === "Warte auf Festplatte") { + item.fullStatus = "Entpacken - Ausstehend"; + } + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); + } + if (currentPlan.manualRequested) { + this.manualExtractPackages.add(packageId); + if (currentPlan.archiveFilter) { + this.manualExtractArchiveFilters.set(packageId, new Set(currentPlan.archiveFilter)); + } + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + void this.runPackagePostProcessing(packageId).catch((error) => logger.warn(`runPackagePostProcessing Fehler (diskRetry): ${compactErrorText(error)}`)); + } else if (this.settings.autoExtract && (this.session.running || this.settings.autoExtractWhenStopped)) { + this.triggerPendingExtractions(new Set([packageId])); + } + this.persistSoon(); + this.emitState(true); + this.completeStopIfDrained(); + } + private diskReservations = new DiskReservationCoordinator(); private diskLeasesByOwner = new Map(); @@ -2745,6 +2983,7 @@ export class DownloadManager extends EventEmitter { } public abortAllPostProcessing(): void { + this.clearAllPackageDiskRetries(); this.abortPostProcessing("external"); } @@ -2753,7 +2992,7 @@ export class DownloadManager extends EventEmitter { return; } this.recoverPostProcessingOnStartup(); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(); this.persistSoon(); this.emitState(); } @@ -2858,7 +3097,7 @@ export class DownloadManager extends EventEmitter { let totalItems = 0; let doneItems = 0; - if (this.session.running && this.runItemIds.size > 0) { + if (this.session.running) { totalItems = this.runItemIds.size; for (const itemId of this.runItemIds) { if (this.runOutcomes.has(itemId)) { @@ -2913,7 +3152,7 @@ export class DownloadManager extends EventEmitter { canStart: hasUsableAccount && (paused || (!this.session.running && lifecycle.phase !== "waiting_provider" && (lifecycle.phase !== "stopping" || !lifecycle.pendingStart))), - canStop: this.session.running, + canStop: this.session.running || lifecycle.activePostProcessing > 0, canPause: this.session.running, clipboardActive: this.settings.clipboardWatch, reconnectSeconds: Math.ceil(reconnectMs / 1000), @@ -3252,7 +3491,8 @@ export class DownloadManager extends EventEmitter { const nextEnabled = !pkg.enabled; pkg.enabled = nextEnabled; - if (!nextEnabled) { + if (!nextEnabled) { + this.pausePackageDiskRetry(packageId); if (pkg.status === "downloading" || pkg.status === "extracting") { pkg.status = "paused"; } @@ -3281,8 +3521,26 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.delete(packageId); this.untrackActiveRunPackage(packageId); this.runCompletedPackages.delete(packageId); - } else { - if (pkg.status === "paused") { + } else { + const diskRetryPlan = this.packageDiskRetryPlans.get(packageId); + if (diskRetryPlan) { + if (diskRetryPlan.generation === this.getPackageResultGeneration(packageId)) { + const resumed = this.schedulePackageDiskRetry(packageId, { + retryAt: Math.max(nowMs(), diskRetryPlan.retryAt), + manualRequested: diskRetryPlan.manualRequested, + postProcessVersion: this.getPackagePostProcessVersion(packageId), + runOwnerId: diskRetryPlan.runOwnerId, + archiveFilter: diskRetryPlan.archiveFilter, + selectedItemIds: diskRetryPlan.selectedItemIds + }); + if (!resumed) { + this.clearPackageDiskRetry(packageId); + } + } else { + this.clearPackageDiskRetry(packageId); + } + } + if (pkg.status === "paused") { pkg.status = "queued"; } let hasReactivatedRunItems = false; @@ -3378,7 +3636,7 @@ export class DownloadManager extends EventEmitter { return this.addPackages(inputs); } - public clearAll(): void { + public clearAll(): void { this.clearPersistTimer(); this.stop(); this.abortPostProcessing("clear_all"); @@ -3396,8 +3654,9 @@ export class DownloadManager extends EventEmitter { this.session.items = {}; this.itemCount = 0; this.session.summaryText = ""; - this.runItemIds.clear(); - this.runPackageIds.clear(); + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.historyRecordedPackages.clear(); @@ -3431,9 +3690,11 @@ export class DownloadManager extends EventEmitter { this.manualExtractArchiveFilters.clear(); this.manualExtractPackages.clear(); this.hybridExtractRequeue.clear(); - this.hybridExtractedPaths.clear(); - this.hybridFailedArchives.clear(); + this.hybridExtractedPaths.clear(); + this.hybridFailedArchives.clear(); + this.autoRecoveredForRedownload.clear(); this.providerFailures.clear(); + this.clearAllPackageDiskRetries(); this.summary = null; this.nonResumableActive = 0; this.resetSessionTotalsIfQueueEmpty(true); @@ -3516,7 +3777,7 @@ export class DownloadManager extends EventEmitter { packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; this.itemCount += 1; - if (this.session.running) { + if (this.session.running && this.runScopeKind === "all") { this.runItemIds.add(itemId); this.runPackageIds.add(packageId); this.beginPackageResultGeneration(packageId); @@ -4051,7 +4312,10 @@ export class DownloadManager extends EventEmitter { tasks.add(task); } } - return tasks.size; + const pendingDiskRetries = this.lifecyclePhase === "stopping" + ? 0 + : [...this.packageDiskRetryPlans.keys()].filter((packageId) => this.session.packages[packageId]?.enabled !== false).length; + return tasks.size + pendingDiskRetries; } private getEarliestProviderRetryAt(now: number): number | null { @@ -4061,10 +4325,13 @@ export class DownloadManager extends EventEmitter { if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + if (this.session.running && !this.runPackageIds.has(packageId)) { continue; } for (const itemId of pkg.itemIds) { + if (this.session.running && !this.runItemIds.has(itemId)) { + continue; + } const item = this.session.items[itemId]; if (item && (item.status === "queued" || item.status === "reconnect_wait")) { queuedItems.push(item); @@ -4325,8 +4592,7 @@ export class DownloadManager extends EventEmitter { extractDirUsage.set(key, (extractDirUsage.get(key) || 0) + 1); } - const cleanupTargetsByPackage = new Map>(); - const dirFilesCache = new Map(); + const cleanupPlans: Array<{ packageId: string; packageUpdatedAt: number; itemState: string; targets: Map }> = []; for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || pkg.status !== "completed") { @@ -4357,64 +4623,52 @@ export class DownloadManager extends EventEmitter { continue; } - const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set(); - for (const item of items) { - const rawTargetPath = String(item.targetPath || "").trim(); - const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : ""; - const targetPath = rawTargetPath || fallbackTargetPath; - if (!targetPath || !isArchiveLikePath(targetPath)) { - continue; - } - const dir = path.dirname(targetPath); - let filesInDir = dirFilesCache.get(dir); - if (!filesInDir) { - try { - filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); - } catch { - filesInDir = []; - } - dirFilesCache.set(dir, filesInDir); - } - - for (const cleanupTarget of collectArchiveCleanupTargets(targetPath, filesInDir)) { - packageTargets.add(cleanupTarget); - } - } - if (packageTargets.size > 0) { - cleanupTargetsByPackage.set(packageId, packageTargets); - } - } - - if (cleanupTargetsByPackage.size === 0) { - return; - } - - this.cleanupQueue = this.cleanupQueue - .then(async () => { - for (const [packageId, targets] of cleanupTargetsByPackage.entries()) { - const pkg = this.session.packages[packageId]; - if (!pkg) { - continue; - } - - logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => isExtractedLabel(this.session.items[id]?.fullStatus || ""))}`); - - let removed = 0; - for (const targetPath of targets) { - if (!await this.existsAsync(targetPath)) { - continue; - } - try { - await fs.promises.rm(targetPath, { force: true }); - removed += 1; - } catch { - } - } - + const targets = await this.collectPackageArchiveCleanupTargets(pkg); + if (targets.size === 0) { + continue; + } + cleanupPlans.push({ + packageId, + packageUpdatedAt: pkg.updatedAt, + itemState: items.map((item) => `${item.id}:${item.updatedAt}:${item.status}:${item.targetPath}`).join("|"), + targets + }); + } + + if (cleanupPlans.length === 0) { + return; + } + + this.cleanupQueue = this.cleanupQueue + .then(async () => { + for (const plan of cleanupPlans) { + const packageId = plan.packageId; + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || pkg.status !== "completed" || pkg.updatedAt !== plan.packageUpdatedAt || this.packagePostProcessTasks.has(packageId)) { + continue; + } + const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const currentItemState = items.map((item) => `${item.id}:${item.updatedAt}:${item.status}:${item.targetPath}`).join("|"); + if (items.length === 0 + || currentItemState !== plan.itemState + || !items.every((item) => item.status === "completed") + || items.some((item) => isExtractErrorLabel(item.fullStatus || ""))) { + continue; + } + const sharedExtractDir = this.session.packageOrder.some((otherId) => { + if (otherId === packageId) return false; + const other = this.session.packages[otherId]; + return Boolean(other && !other.cancelled && pathKey(other.extractDir) === pathKey(pkg.extractDir)); + }); + const hasExtractMarker = items.some((item) => isExtractedLabel(item.fullStatus)); + const hasExtractedOutput = !sharedExtractDir + && this.getPackageOutputScope(pkg).completeFiles().some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); + if (!hasExtractMarker && !hasExtractedOutput) { + continue; + } + const removed = await this.cleanupRemainingArchiveArtifacts(pkg, undefined, plan.targets); if (removed > 0) { - logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) bereinigt`); const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); if (removedDirs > 0) { logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); @@ -4424,10 +4678,11 @@ export class DownloadManager extends EventEmitter { } } }) - .catch((error) => { - logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); - }); - } + .catch((error) => { + logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); + }); + await this.cleanupQueue; + } private async directoryHasAnyFiles(rootDir: string): Promise { if (!rootDir) { @@ -5963,58 +6218,128 @@ export class DownloadManager extends EventEmitter { return removed; } - private async cleanupRemainingArchiveArtifacts(pkg: PackageEntry, shouldAbort?: () => boolean): Promise { + private async readFileFingerprint(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs + }; + } catch { + return null; + } + } + + private readFileFingerprintSync(filePath: string): FileFingerprint | null { + try { + const stat = fs.statSync(filePath); + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs + }; + } catch { + return null; + } + } + + private fingerprintsEqual(left: FileFingerprint, right: FileFingerprint): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; + } + + private async collectPackageArchiveCleanupTargets(pkg: PackageEntry): Promise> { + const claims = new Map>(); + for (const otherPkg of Object.values(this.session.packages)) { + if (!otherPkg || otherPkg.cancelled) { + continue; + } + for (const itemId of otherPkg.itemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(otherPkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath) { + continue; + } + const key = pathKey(path.resolve(itemPath)); + const owners = claims.get(key) || new Set(); + owners.add(otherPkg.id); + claims.set(key, owners); + } + } + const ownedPaths = new Set(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath || !isArchiveLikePath(itemPath) || !isPathInsideDir(itemPath, pkg.outputDir)) { + continue; + } + ownedPaths.add(path.resolve(itemPath)); + } + const dirFiles = new Map(); + const targets = new Map(); + for (const sourcePath of ownedPaths) { + const directory = path.dirname(sourcePath); + const directoryKey = pathKey(directory); + if (!dirFiles.has(directoryKey)) { + try { + dirFiles.set(directoryKey, (await fs.promises.readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name)); + } catch { + dirFiles.set(directoryKey, []); + } + } + for (const candidate of collectArchiveCleanupTargets(sourcePath, dirFiles.get(directoryKey) || [])) { + const resolved = path.resolve(candidate); + if (!isPathInsideDir(resolved, pkg.outputDir)) { + continue; + } + const owners = claims.get(pathKey(resolved)); + if (owners && [...owners].some((ownerId) => ownerId !== pkg.id)) { + continue; + } + const fingerprint = await this.readFileFingerprint(resolved); + if (fingerprint) { + targets.set(pathKey(resolved), { filePath: resolved, fingerprint }); + } + } + } + return targets; + } + + private async cleanupRemainingArchiveArtifacts( + pkg: PackageEntry, + shouldAbort?: () => boolean, + expectedTargets?: ReadonlyMap + ): Promise { if (this.settings.cleanupMode === "none") { return 0; } if (shouldAbort?.()) { return 0; } - const ownedPaths = new Map(); - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - const rawPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); - if (!rawPath || !isArchiveLikePath(rawPath) || !isPathInsideDir(rawPath, pkg.outputDir)) { - continue; - } - const resolved = path.resolve(rawPath); - ownedPaths.set(pathKey(resolved), resolved); - } - if (ownedPaths.size === 0) { - return 0; - } - let removed = 0; - const dirFiles = new Map(); - for (const ownedPath of ownedPaths.values()) { - const directory = path.dirname(ownedPath); - const directoryKey = pathKey(directory); - const files = dirFiles.get(directoryKey) || []; - files.push(path.basename(ownedPath)); - dirFiles.set(directoryKey, files); - } - const targets = new Set(); - for (const sourceFile of ownedPaths.values()) { + const targets = expectedTargets + ? new Map(expectedTargets) + : await this.collectPackageArchiveCleanupTargets(pkg); + for (const target of targets.values()) { if (shouldAbort?.()) { return removed; } - const dir = path.dirname(sourceFile); - for (const target of collectArchiveCleanupTargets(sourceFile, dirFiles.get(pathKey(dir)) || [])) { - const resolved = path.resolve(target); - if (ownedPaths.has(pathKey(resolved))) { - targets.add(resolved); + const targetPath = target.filePath; + try { + const currentFingerprint = await this.readFileFingerprint(targetPath); + if (!currentFingerprint || !this.fingerprintsEqual(currentFingerprint, target.fingerprint)) { + continue; } - } - } - - for (const targetPath of targets) { - if (shouldAbort?.()) { - return removed; - } - try { - if (!await this.existsAsync(targetPath)) { - continue; - } if (this.settings.cleanupMode === "trash") { const parsed = path.parse(targetPath); const trashDir = path.join(parsed.dir, ".rd-trash"); @@ -6023,20 +6348,30 @@ export class DownloadManager extends EventEmitter { for (let index = 0; index <= 1000; index += 1) { const suffix = index === 0 ? "" : `-${index}`; const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`); - if (await this.existsAsync(candidate)) { - continue; - } - await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); - moved = true; - break; + if (await this.existsAsync(candidate)) { + continue; + } + const moveFingerprint = await this.readFileFingerprint(targetPath); + if (!moveFingerprint || !this.fingerprintsEqual(moveFingerprint, target.fingerprint)) { + break; + } + await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); + moved = !await this.existsAsync(targetPath) && await this.existsAsync(candidate); + break; } if (moved) { removed += 1; } continue; } + const deleteFingerprint = await this.readFileFingerprint(targetPath); + if (!deleteFingerprint || !this.fingerprintsEqual(deleteFingerprint, target.fingerprint)) { + continue; + } await fs.promises.rm(toWindowsLongPathIfNeeded(targetPath), { force: true }); - removed += 1; + if (!await this.existsAsync(targetPath)) { + removed += 1; + } } catch { } } @@ -6522,8 +6857,11 @@ export class DownloadManager extends EventEmitter { } public async resetPackage(packageId: string): Promise { - const pkg = this.session.packages[packageId]; - if (!pkg) return; + const pkg = this.session.packages[packageId]; + if (!pkg) return; + + this.clearPackageDiskRetry(packageId); + this.clearArchiveRedownloadRecovery(packageId); const itemIds = [...pkg.itemIds]; @@ -6893,7 +7231,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - this.triggerPendingExtractions(); + this.triggerPendingExtractions(targetSet); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -6902,6 +7240,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "selected"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6917,7 +7256,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(targetSet); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -7009,7 +7348,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - this.triggerPendingExtractions(); + this.triggerPendingExtractions(affectedPackageIds); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -7018,6 +7357,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "selected"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7033,7 +7373,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(affectedPackageIds); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -7065,6 +7405,7 @@ export class DownloadManager extends EventEmitter { private async startAllNow(excludePackageIds: ReadonlySet | undefined, generation: number): Promise { this.session.running = true; this.session.paused = false; + this.runScopeKind = "all"; const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => { const pkg = this.session.packages[packageId]; return Boolean(pkg && !pkg.cancelled && pkg.enabled && !excludePackageIds?.has(packageId)); @@ -7105,7 +7446,7 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } - this.triggerPendingExtractions(); + this.triggerPendingExtractions(recoveryRunPackageIds); const runItems = Object.values(this.session.items) .filter((item) => { @@ -7119,9 +7460,10 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - if (this.packagePostProcessTasks.size > 0) { + if (this.getActivePostProcessingCount() > 0) { this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = "postprocessing"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.schedulerGeneration += 1; @@ -7143,6 +7485,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7175,6 +7518,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "all"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7241,8 +7585,15 @@ export class DownloadManager extends EventEmitter { const wasRunning = this.session.running; const stoppedItemIds = new Set(this.runItemIds); const stoppedPackageIds = new Set(this.runPackageIds); - const hasScopedRun = wasRunning && stoppedItemIds.size > 0; - const stopsStandalonePostProcessing = wasRunning && !hasScopedRun && previousLifecyclePhase === "postprocessing"; + const hasScopedRun = wasRunning && this.runScopeKind === "selected"; + const stopsStandalonePostProcessing = wasRunning && this.runScopeKind === "postprocessing"; + if (hasScopedRun) { + for (const packageId of stoppedPackageIds) { + this.clearPackageDiskRetry(packageId); + } + } else { + this.clearAllPackageDiskRetries(); + } const stoppedRunContext = wasRunning ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) : null; @@ -7330,6 +7681,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.persistSoon(); @@ -7343,6 +7695,7 @@ export class DownloadManager extends EventEmitter { this.updateStatisticsActivity(nowMs()); this.rotationListenerActive = false; this.clearPersistTimer(); + this.clearAllPackageDiskRetries(); if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; @@ -7405,9 +7758,10 @@ export class DownloadManager extends EventEmitter { this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; - this.runItemIds.clear(); - this.runPackageIds.clear(); - this.runOutcomes.clear(); + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runScopeKind = null; + this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); @@ -7455,13 +7809,8 @@ export class DownloadManager extends EventEmitter { this.speedEventsHead = 0; } - if (wasPaused && !this.session.paused) { - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.providerFailures.clear(); - - const now = nowMs(); + if (wasPaused && !this.session.paused) { + const now = nowMs(); for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; @@ -7487,9 +7836,10 @@ export class DownloadManager extends EventEmitter { return this.session.paused; } - private normalizeSessionStatuses(): void { - this.session.running = false; - this.session.paused = false; + private normalizeSessionStatuses(): void { + this.session.running = false; + this.session.paused = false; + this.runScopeKind = null; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -7515,9 +7865,14 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); continue; } - if (item.status === "extracting" || item.status === "integrity_check") { - item.status = "completed"; - item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; + if (item.status === "integrity_check") { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } else if (item.status === "extracting") { + item.status = "completed"; + item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.speedBps = 0; item.updatedAt = nowMs(); } else if (item.status === "downloading" @@ -7557,8 +7912,9 @@ export class DownloadManager extends EventEmitter { } } } - for (const pkg of Object.values(this.session.packages)) { - if (pkg.enabled === undefined) { + for (const pkg of Object.values(this.session.packages)) { + pkg.postProcessLabel = undefined; + if (pkg.enabled === undefined) { pkg.enabled = true; } if (!pkg.priority) { @@ -8013,12 +8369,16 @@ export class DownloadManager extends EventEmitter { } const canonicalBaseName = stripDuplicateSuffixBeforeExtension(duplicateBaseName); - const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); - const canonicalKey = pathKey(canonicalPath); - let primaryItem = Object.values(this.session.items).find((candidate) => - candidate.packageId === packageId - && candidate.id !== duplicateItem.id - && ( + const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); + const canonicalKey = pathKey(canonicalPath); + const duplicateUrl = String(duplicateItem.url || "").trim(); + let primaryItem = Object.values(this.session.items).find((candidate) => + candidate.packageId === packageId + && candidate.id !== duplicateItem.id + && duplicateUrl.length > 0 + && String(candidate.url || "").trim() === duplicateUrl + && candidate.provider === duplicateItem.provider + && ( pathKey(String(candidate.targetPath || "")) === canonicalKey || ( !candidate.targetPath @@ -8030,9 +8390,12 @@ export class DownloadManager extends EventEmitter { continue; } - const duplicateExists = fs.existsSync(duplicateTargetPath); - let canonicalExists = fs.existsSync(canonicalPath); - const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); + const duplicateExists = fs.existsSync(duplicateTargetPath); + let canonicalExists = fs.existsSync(canonicalPath); + if (duplicateExists && canonicalExists) { + continue; + } + const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); if (duplicateExists && !canonicalExists) { try { @@ -8349,14 +8712,11 @@ export class DownloadManager extends EventEmitter { return relevant; } - private clearHybridArchiveState(packageId: string, archiveKey?: string): void { - if (!archiveKey) { - this.hybridExtractedPaths.delete(packageId); - this.hybridFailedArchives.delete(packageId); - for (const key of this.autoRecoveredForRedownload) { - if (key.startsWith(`${packageId}::`)) this.autoRecoveredForRedownload.delete(key); - } - return; + private clearHybridArchiveState(packageId: string, archiveKey?: string): void { + if (!archiveKey) { + this.hybridExtractedPaths.delete(packageId); + this.hybridFailedArchives.delete(packageId); + return; } const normalizedKey = pathKey(archiveKey); @@ -8374,10 +8734,19 @@ export class DownloadManager extends EventEmitter { if (failed.size === 0) { this.hybridFailedArchives.delete(packageId); } - } - } - - private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { + } + } + + private clearArchiveRedownloadRecovery(packageId: string): void { + const prefix = packageId + "::"; + for (const key of this.autoRecoveredForRedownload) { + if (key.startsWith(prefix)) { + this.autoRecoveredForRedownload.delete(key); + } + } + } + + private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { const archiveName = path.basename(archiveKey); const archiveItems = resolveArchiveItemsFromList(archiveName, items, archiveKey) .slice() @@ -8406,27 +8775,42 @@ export class DownloadManager extends EventEmitter { }); } - private autoRecoverArchiveCrcFailure( + private autoRecoverArchiveCrcFailure( pkg: PackageEntry, items: DownloadItem[], failure: ExtractArchiveFailureInfo, scope: "hybrid" | "full" ): number { - if (!failure.suggestRedownload || (failure.category !== "crc_error" && failure.category !== "wrong_password")) { + if (!failure.suggestRedownload || failure.category !== "crc_error") { return 0; } const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items, failure.archivePath) .filter((item) => item.status === "completed"); - if (archiveItems.length === 0) { + if (archiveItems.length === 0) { logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); - return 0; - } - - const inspectedArchiveItems = archiveItems - .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); - const corruptArchiveItems = inspectedArchiveItems - .filter(({ state }) => state.reason !== "ok"); + return 0; + } + + const inspectedArchiveItems = archiveItems + .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); + const corruptArchiveItems = inspectedArchiveItems + .filter(({ state }) => state.reason !== "ok"); + const failureText = `${failure.errorText || ""} ${failure.jvmFailureReason || ""}`; + const standardUnrarCrcText = /corrupt file or wrong password/i.test(failureText); + const passwordAmbiguousFailure = /encrypted file|falsches? archiv-passwort/i.test(failureText) + || (!standardUnrarCrcText && /wrong password/i.test(failureText)); + const corroboratedCrc = failure.category === "crc_error" + && !passwordAmbiguousFailure + && classifyExtractionError(failure.jvmFailureReason || "") === "crc_error"; + if (corroboratedCrc) { + const implicatedIds = new Set(findCrcImplicatedArchiveItems(failure.errorText, archiveItems).map((item) => item.id)); + for (const inspected of inspectedArchiveItems) { + if (implicatedIds.has(inspected.item.id) && !corruptArchiveItems.some(({ item }) => item.id === inspected.item.id)) { + corruptArchiveItems.push(inspected); + } + } + } if (corruptArchiveItems.length === 0) { const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); @@ -8460,21 +8844,38 @@ export class DownloadManager extends EventEmitter { `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` + `erzwinge Re-Download aller ${archiveItems.length} Parts` ); - corruptArchiveItems.push(...inspectedArchiveItems); - } - - const queuedAt = nowMs(); - const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; - let changed = 0; - for (const { item } of corruptArchiveItems) { - const claimedTargetPath = String(item.targetPath || "").trim(); - if (claimedTargetPath) { - try { - fs.rmSync(claimedTargetPath, { force: true }); - } catch { - } - } - this.releaseTargetPath(item.id); + corruptArchiveItems.push(...inspectedArchiveItems); + } + + const generation = this.getPackageResultGeneration(pkg.id); + const recoveryKeyForItem = (item: DownloadItem): string => + `${pkg.id}::${generation}::${pathKey(item.targetPath || item.fileName || item.id)}`; + const pendingRecoveryItems = corruptArchiveItems + .filter(({ item }) => !this.autoRecoveredForRedownload.has(recoveryKeyForItem(item))); + if (pendingRecoveryItems.length === 0) { + return 0; + } + + const queuedAt = nowMs(); + const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; + const requeuedItems: typeof pendingRecoveryItems = []; + for (const inspected of pendingRecoveryItems) { + const { item } = inspected; + const itemRecoveryKey = recoveryKeyForItem(item); + const claimedTargetPath = String(item.targetPath || "").trim(); + let removalConfirmed = claimedTargetPath.length === 0; + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + removalConfirmed = !fs.existsSync(claimedTargetPath); + } catch (error) { + logger.warn(`Auto-Recovery (${scope}): Defektes Archiv konnte nicht entfernt werden (${item.fileName}): ${compactErrorText(error)}`); + } + } + if (!removalConfirmed) { + continue; + } + this.releaseTargetPath(item.id); this.dropItemContribution(item.id); item.targetPath = ""; item.status = "queued"; @@ -8483,14 +8884,16 @@ export class DownloadManager extends EventEmitter { item.progressPercent = 0; item.speedBps = 0; item.lastError = failure.errorText; - item.fullStatus = reason; - item.updatedAt = queuedAt; - changed += 1; - } - + item.fullStatus = reason; + item.updatedAt = queuedAt; + this.autoRecoveredForRedownload.add(itemRecoveryKey); + requeuedItems.push(inspected); + } + + const changed = requeuedItems.length; if (changed > 0) { if (this.session.running) { - for (const { item } of corruptArchiveItems) { + for (const { item } of requeuedItems) { this.runItemIds.add(item.id); this.runOutcomes.delete(item.id); } @@ -8498,13 +8901,13 @@ export class DownloadManager extends EventEmitter { this.trackActiveRunPackage(pkg.id); } this.clearHybridArchiveState(pkg.id); - pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; - pkg.updatedAt = queuedAt; - const evidence = corruptArchiveItems - .slice(0, 3) + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = queuedAt; + const evidence = requeuedItems + .slice(0, 3) .map(({ item, state }) => `${item.fileName}:${state.reason}`) .join(", "); - const suffix = corruptArchiveItems.length > 3 ? ` (+${corruptArchiveItems.length - 3} weitere)` : ""; + const suffix = requeuedItems.length > 3 ? ` (+${requeuedItems.length - 3} weitere)` : ""; logger.warn( `Auto-Recovery (${scope}): ${failure.archiveName} auf queued gesetzt (${changed} Items), ` + `evidence=${evidence}${suffix}, cause=${compactErrorText(failure.jvmFailureReason || failure.errorText)}` @@ -8522,8 +8925,9 @@ export class DownloadManager extends EventEmitter { fallbackReason: string, previousStatuses: Map, appliedAt = nowMs() - ): void { - const affectedItemIds = new Set(); + ): void { + const affectedItemIds = new Set(); + const soleFailure = failedArchiveErrors.size === 1 ? [...failedArchiveErrors.values()][0] : undefined; for (const failure of failedArchiveErrors.values()) { const reason = compactErrorText(failure.errorText || fallbackReason || "Entpacken fehlgeschlagen"); @@ -8546,9 +8950,9 @@ export class DownloadManager extends EventEmitter { continue; } - const currentStatus = String(entry.fullStatus || "").trim(); - if (currentStatus === "Entpacken - Error") { - entry.fullStatus = formatExtractFailureLabel(fallbackReason); + const currentStatus = String(entry.fullStatus || "").trim(); + if (currentStatus === "Entpacken - Error") { + entry.fullStatus = formatExtractFailureLabel(fallbackReason, soleFailure?.archiveName); entry.updatedAt = appliedAt; appliedSpecificFailure = true; continue; @@ -8944,9 +9348,42 @@ export class DownloadManager extends EventEmitter { controller.abort(reason); } } - } - } - + } + } + + private isExpectedPostProcessAbort(error: unknown, signal: AbortSignal): boolean { + void error; + return signal.aborted; + } + + private markUnexpectedPostProcessFailure(packageId: string, error: unknown): void { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + const reason = compactErrorText(error).replace(/^Error:\s*/i, "") || "Unbekannter Nachbearbeitungsfehler"; + const failedAt = nowMs(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed" || isExtractedLabel(item.fullStatus || "")) { + continue; + } + item.fullStatus = formatExtractFailureLabel(reason); + item.lastError = reason; + item.updatedAt = failedAt; + } + pkg.status = "failed"; + pkg.postProcessLabel = undefined; + pkg.updatedAt = failedAt; + if (this.runPackageIds.has(packageId)) { + this.runCompletedPackages.add(packageId); + } + this.hybridExtractRequeue.delete(packageId); + this.logPackageForPackage(pkg, "ERROR", "Post-Processing unerwartet fehlgeschlagen", { reason }); + this.persistSoon(); + this.emitState(true); + } + private runPackagePostProcessing(packageId: string): Promise { this.trackPackagePostProcessResult(packageId); const existing = this.packagePostProcessTasks.get(packageId); @@ -8980,11 +9417,16 @@ export class DownloadManager extends EventEmitter { const hadRequeue = this.hybridExtractRequeue.has(packageId); this.hybridExtractRequeue.delete(packageId); const roundStart = nowMs(); - try { - await this.handlePackagePostProcessing(packageId, abortController.signal); - } catch (error) { - logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); - } + try { + await this.handlePackagePostProcessing(packageId, abortController.signal); + } catch (error) { + if (this.isExpectedPostProcessAbort(error, abortController.signal)) { + logger.info(`Post-Processing für Paket abgebrochen: ${compactErrorText(error)}`); + } else { + logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); + this.markUnexpectedPostProcessFailure(packageId, error); + } + } const roundMs = nowMs() - roundStart; logger.info(`Post-Process Runde ${round} fertig in ${(roundMs / 1000).toFixed(1)}s (requeue=${hadRequeue}, nextRequeue=${this.hybridExtractRequeue.has(packageId)}): pkg=${packageId.slice(0, 8)}`); const pkg = this.session.packages[packageId]; @@ -9163,12 +9605,18 @@ export class DownloadManager extends EventEmitter { } } - private triggerPendingExtractions(): void { + private triggerPendingExtractions(packageFilter?: ReadonlySet): void { if (!this.settings.autoExtract) { return; } - for (const packageId of this.session.packageOrder) { - const pkg = this.session.packages[packageId]; + for (const packageId of this.session.packageOrder) { + if (packageFilter && !packageFilter.has(packageId)) { + continue; + } + if (!packageFilter && this.session.running && !this.runPackageIds.has(packageId)) { + continue; + } + const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } @@ -9199,9 +9647,12 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); for (const item of items) { - if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); } } logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); @@ -9219,9 +9670,12 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); for (const item of items) { - if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); } } logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); @@ -9233,131 +9687,201 @@ export class DownloadManager extends EventEmitter { } public async retryExtraction(packageId: string): Promise { - if (!(await this.armExtractNowPackage(packageId))) { - throw new Error("Kein entpackbarer Archivsatz ausgewählt"); - } + await this.extractNow(packageId); } - private async armExtractNowPackage( + private async resolveManualExtractionPlan( packageId: string, - selectedItemIds?: ReadonlySet, - archiveFilter?: ReadonlySet - ): Promise { - let pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) return false; - let items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - let completedItems = items.filter((item) => item.status === "completed"); - let targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); + selectedItemIds?: ReadonlySet + ): Promise { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return null; + const completedItems = pkg.itemIds + .map((id) => this.session.items[id]) + .filter((item): item is DownloadItem => Boolean(item && item.status === "completed")); + const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) + && (!selectedItemIds || selectedItemIds.has(item.id))); if (targetItems.length === 0) { - this.manualExtractArchiveFilters.delete(packageId); - this.manualExtractPackages.delete(packageId); - return false; + return null; } - const initialTargetIds = new Set(targetItems.map((item) => item.id)); - if (this.packagePostProcessTasks.has(packageId) || this.hasDeferredPostProcessPending(packageId)) { - pkg.postProcessLabel = "Entpacken wird neu gestartet..."; - pkg.updatedAt = nowMs(); - this.emitState(true); - await Promise.allSettled(this.abortPackagePostProcessing(packageId, "manual_extract_restart")); - pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) return false; - items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - completedItems = items.filter((item) => item.status === "completed"); - targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); - if (targetItems.length === 0) { - pkg.postProcessLabel = undefined; - pkg.updatedAt = nowMs(); - this.emitState(true); - return [...initialTargetIds].every((itemId) => { - const item = this.session.items[itemId]; - return Boolean(item && item.status === "completed" && isExtractedLabel(item.fullStatus)); - }); + const candidates = [...await this.findReadyArchiveSets(pkg)]; + const requestedItemIds = new Set(targetItems.map((item) => item.id)); + const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, requestedItemIds); + const plannedItemIds = new Set(selection.itemIds); + const opaqueItemIds = new Set(); + for (const item of targetItems) { + if (plannedItemIds.has(item.id) || !item.targetPath || isArchiveLikePath(item.targetPath || item.fileName || "")) { + continue; + } + if (inspectPackageItemDiskState(pkg, item).reason !== "ok") { + continue; + } + if (await detectArchiveSignature(item.targetPath)) { + opaqueItemIds.add(item.id); + plannedItemIds.add(item.id); } } - this.clearHybridArchiveState(packageId); + if (plannedItemIds.size === 0) { + return null; + } + if (selectedItemIds && opaqueItemIds.size > 0 && ( + candidates.length > 0 + || plannedItemIds.size !== opaqueItemIds.size + || completedItems.some((item) => !selectedItemIds.has(item.id) && !isExtractedLabel(item.fullStatus)) + )) { + return null; + } + const itemFiles = new Map(); + for (const itemId of plannedItemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + const fingerprint = itemPath ? await this.readFileFingerprint(itemPath) : null; + if (!fingerprint) { + return null; + } + const existingOwner = this.reservedTargetPaths.get(pathKey(itemPath)); + if (existingOwner && existingOwner !== itemId) { + return null; + } + itemFiles.set(itemId, { filePath: path.resolve(itemPath), fingerprint }); + } + return { + packageId, + generation: this.getPackageResultGeneration(packageId), + targetItemIds: plannedItemIds, + archiveFilter: selectedItemIds && opaqueItemIds.size === 0 + ? new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) + : undefined, + itemFiles + }; + } + + private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean { + const pkg = this.session.packages[plan.packageId]; + if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { + return false; + } + return [...plan.targetItemIds].every((itemId) => { + const item = this.session.items[itemId]; + const expectedFile = plan.itemFiles.get(itemId); + const currentFingerprint = expectedFile ? this.readFileFingerprintSync(expectedFile.filePath) : null; + return Boolean(item + && item.packageId === plan.packageId + && item.status === "completed" + && !isExtractedLabel(item.fullStatus) + && expectedFile + && currentFingerprint + && this.fingerprintsEqual(currentFingerprint, expectedFile.fingerprint)); + }); + } + + private async prepareManualExtractionPlan(plan: ManualExtractionPlan): Promise { + if (!this.isManualExtractionPlanCurrent(plan)) { + return false; + } + if (this.packagePostProcessTasks.has(plan.packageId) || this.hasDeferredPostProcessPending(plan.packageId)) { + await Promise.allSettled(this.abortPackagePostProcessing(plan.packageId, "manual_extract_restart")); + } + return this.isManualExtractionPlanCurrent(plan); + } + + private commitManualExtractionPlan(plan: ManualExtractionPlan): void { + const pkg = this.session.packages[plan.packageId] as PackageEntry; + this.beginHealthRun(); + this.clearPackageDiskRetry(plan.packageId); + this.clearHybridArchiveState(plan.packageId); if (!pkg.enabled) { pkg.enabled = true; } - if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter)); - else this.manualExtractArchiveFilters.delete(packageId); - this.manualExtractPackages.add(packageId); - pkg.status = "queued"; - pkg.updatedAt = nowMs(); - for (const item of targetItems) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); - } - logger.info(`Jetzt entpacken: pkg=${pkg.name}, completed=${completedItems.length}, targeted=${targetItems.length}`); - this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { - completedItems: completedItems.length, - targetedItems: targetItems.length - }); - this.beginPackageResultGeneration(packageId, false, true); - this.reactivateStandalonePackageResult(packageId); - this.persistSoon(); - this.emitState(true); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); - return true; + if (plan.archiveFilter) this.manualExtractArchiveFilters.set(plan.packageId, new Set(plan.archiveFilter)); + else this.manualExtractArchiveFilters.delete(plan.packageId); + this.manualExtractPackages.add(plan.packageId); + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const itemId of plan.targetItemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const plannedFile = plan.itemFiles.get(itemId); + if (!item.targetPath && plannedFile) { + item.fileName = path.basename(plannedFile.filePath); + item.targetPath = plannedFile.filePath; + this.reservedTargetPaths.set(pathKey(plannedFile.filePath), item.id); + this.claimedTargetPathByItem.set(item.id, plannedFile.filePath); + } + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); + } + this.beginPackageResultGeneration(plan.packageId, false, true); + this.reactivateStandalonePackageResult(plan.packageId); } - private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet): Promise { - const selectedByPackage = new Map>(); - let armed = 0; - for (const itemId of itemIds) { - const item = this.session.items[itemId]; - if (!item || excludedPackageIds.has(item.packageId)) { - continue; - } - const selected = selectedByPackage.get(item.packageId) || new Set(); - selected.add(itemId); - selectedByPackage.set(item.packageId, selected); - } - for (const [packageId, selectedItemIds] of selectedByPackage) { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) { - continue; - } - const completedItems = pkg.itemIds - .map((itemId) => this.session.items[itemId]) - .filter((item): item is DownloadItem => Boolean(item && item.status === "completed")); - const candidates = await findArchiveCandidates(pkg.outputDir); - const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, selectedItemIds); - if (selection.archivePaths.size === 0 || selection.itemIds.size === 0) { - logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`); - continue; - } - if (await this.armExtractNowPackage( - packageId, - selection.itemIds, - new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) - )) { - armed += 1; + private async executeManualExtractionPlans(plans: ManualExtractionPlan[]): Promise { + for (const plan of plans) { + if (!await this.prepareManualExtractionPlan(plan)) { + throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); } } - return armed; + if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan))) { + throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); + } + for (const plan of plans) { + this.commitManualExtractionPlan(plan); + } + this.persistSoon(); + this.emitState(true); + for (const plan of plans) { + const pkg = this.session.packages[plan.packageId]; + logger.info(`Jetzt entpacken: pkg=${pkg?.name || plan.packageId}, targeted=${plan.targetItemIds.size}`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { targetedItems: plan.targetItemIds.size }); + } + void this.runPackagePostProcessing(plan.packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); + } } public async extractNow(target: string | ExtractNowRequest): Promise { - if (typeof target === "string") { - if (!(await this.armExtractNowPackage(target))) { - throw new Error("Kein entpackbarer Archivsatz ausgewählt"); - } - return; - } - const packageIds = [...new Set(target.packageIds)]; + const packageIds = typeof target === "string" ? [target] : [...new Set(target.packageIds)]; const packageSet = new Set(packageIds); - let armed = 0; - for (const packageId of packageIds) { - if (await this.armExtractNowPackage(packageId)) { - armed += 1; + const itemIdsByPackage = new Map>(); + let rejected = 0; + if (typeof target !== "string") { + for (const itemId of target.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + rejected += 1; + continue; + } + if (packageSet.has(item.packageId)) { + continue; + } + const selected = itemIdsByPackage.get(item.packageId) || new Set(); + selected.add(itemId); + itemIdsByPackage.set(item.packageId, selected); } } - armed += await this.extractNowItems(target.itemIds, packageSet); - if (armed === 0) { - throw new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"); + const plans: ManualExtractionPlan[] = []; + for (const packageId of packageIds) { + const plan = await this.resolveManualExtractionPlan(packageId); + if (plan) plans.push(plan); + else rejected += 1; } + for (const [packageId, itemIds] of itemIdsByPackage) { + const plan = await this.resolveManualExtractionPlan(packageId, itemIds); + if (plan) plans.push(plan); + else rejected += 1; + } + if (plans.length === 0) { + throw new Error("Kein entpackbarer Archivsatz ausgewählt"); + } + if (rejected > 0) { + throw new Error(`${plans.length} Entpackvorgang bereit, ${rejected} nicht gestartet`); + } + await this.executeManualExtractionPlans(plans); } - + private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { if ((pkg.downloadStartedAt || 0) <= 0) { pkg.downloadStartedAt = startedAt; @@ -9419,7 +9943,7 @@ export class DownloadManager extends EventEmitter { this.onHistoryEntryCallback(entry); } - private removePackageFromSession(packageId: string, itemIds: string[], reason: "completed" | "deleted" = "deleted"): void { + private removePackageFromSession(packageId: string, itemIds: string[], reason: "completed" | "deleted" = "deleted"): void { const pkg = this.session.packages[packageId]; if (pkg) { this.logPackageForPackage(pkg, "INFO", "Paket aus Session entfernt", { @@ -9458,6 +9982,8 @@ export class DownloadManager extends EventEmitter { } } this.historyRecordedPackages.delete(packageId); + this.clearPackageDiskRetry(packageId); + this.clearArchiveRedownloadRecovery(packageId); this.abortPackagePostProcessing(packageId, "package_removed"); this.packagePostProcessVersions.delete(packageId); this.packageFileOpChain.delete(packageId); @@ -10154,7 +10680,7 @@ export class DownloadManager extends EventEmitter { const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; - const postProcessComplete = this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending(); + const postProcessComplete = this.getActivePostProcessingCount() === 0; if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) { this.finishRun(); break; @@ -10314,7 +10840,7 @@ export class DownloadManager extends EventEmitter { changed = true; } }; - if (this.runItemIds.size > 0) { + if (this.session.running) { for (const itemId of this.runItemIds) updateItem(itemId); } else { for (const itemId in this.session.items) updateItem(itemId); @@ -10333,13 +10859,13 @@ export class DownloadManager extends EventEmitter { for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + if (this.session.running && !this.runPackageIds.has(packageId)) continue; const pkgPrio = pkg.priority || "normal"; if (normalCandidate && pkgPrio === "low") continue; if (normalCandidate && pkgPrio === "normal") continue; for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) continue; + if (this.session.running && !this.runItemIds.has(itemId)) continue; const item = this.session.items[itemId]; if (!item) continue; const retryAfter = this.retryAfterByItem.get(itemId) || 0; @@ -10377,9 +10903,9 @@ export class DownloadManager extends EventEmitter { for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + if (this.session.running && !this.runPackageIds.has(packageId)) continue; for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + if (this.session.running && !this.runItemIds.has(itemId)) { continue; } const item = this.session.items[itemId]; @@ -10412,11 +10938,11 @@ export class DownloadManager extends EventEmitter { if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + if (this.session.running && !this.runPackageIds.has(packageId)) { continue; } for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + if (this.session.running && !this.runItemIds.has(itemId)) { continue; } const item = this.session.items[itemId]; @@ -12777,6 +13303,9 @@ export class DownloadManager extends EventEmitter { if (pkg) { pkg.resultGeneration = next; } + if (next !== current) { + this.clearArchiveRedownloadRecovery(packageId); + } if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { if (resetDownloadTelemetry) { pkg.downloadStartedAt = 0; @@ -13129,6 +13658,7 @@ export class DownloadManager extends EventEmitter { return true; } return this.packagePostProcessTasks.has(packageId) + || this.packageDiskRetryPlans.has(packageId) || this.hasDeferredPostProcessPending(packageId) || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 || this.packageFileOpChain.has(packageId); @@ -13494,24 +14024,31 @@ export class DownloadManager extends EventEmitter { return ready; } - const completedPaths = new Set(); - const pendingPaths = new Set(); - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (!item) { - continue; - } - if (item.status === "completed" && item.targetPath) { - completedPaths.add(pathKey(item.targetPath)); - } else { - if (item.targetPath) { - pendingPaths.add(pathKey(item.targetPath)); - } - if (item.fileName && pkg.outputDir) { - pendingPaths.add(pathKey(path.join(pkg.outputDir, item.fileName))); - } - } - } + const completedPaths = new Set(); + const effectivePaths = new Map(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + const itemPath = String(item.targetPath || (item.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath) { + continue; + } + const key = pathKey(itemPath); + const claim = effectivePaths.get(key) || { filePath: itemPath, itemIds: [] }; + claim.itemIds.push(item.id); + effectivePaths.set(key, claim); + } + for (const claim of effectivePaths.values()) { + if (claim.itemIds.length !== 1) { + continue; + } + const item = this.session.items[claim.itemIds[0]]; + if (item?.status === "completed" && await this.existsAsync(claim.filePath)) { + completedPaths.add(pathKey(claim.filePath)); + } + } if (completedPaths.size === 0) { return ready; } @@ -13530,23 +14067,25 @@ export class DownloadManager extends EventEmitter { return ready; } - const packageItems = pkg.itemIds + const packageItems = pkg.itemIds .map((itemId) => this.session.items[itemId]) .filter(Boolean) as DownloadItem[]; - - for (const candidate of candidates) { - const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); - const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); - if (allPartsCompleted) { - const hasUnstartedParts = [...pendingPaths].some((pendingPath) => { - const pendingName = path.basename(pendingPath).toLowerCase(); - const candidateStem = path.basename(candidate).toLowerCase(); - return this.looksLikeArchivePart(pendingName, candidateStem); - }); - if (hasUnstartedParts) { - continue; - } - ready.add(pathKey(candidate)); + + for (const candidate of candidates) { + if (/\.rev$/i.test(candidate)) { + continue; + } + const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); + const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); + const candidateStem = path.basename(candidate).toLowerCase(); + const hasUnreadyPendingPart = packageItems.some((item) => item.status !== "completed" + && this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem) + && inspectPackageItemDiskState(pkg, item).reason !== "ok"); + if (hasUnreadyPendingPart) { + continue; + } + if (allPartsCompleted) { + ready.add(pathKey(candidate)); continue; } @@ -14054,8 +14593,9 @@ export class DownloadManager extends EventEmitter { return 0; } - private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { - const handleStart = nowMs(); + private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { + const handleStart = nowMs(); + const postProcessVersion = this.getPackagePostProcessVersion(packageId); const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled) { return; @@ -14183,6 +14723,7 @@ export class DownloadManager extends EventEmitter { const allDone = this.areAllPackageItemRefsFinished(pkg); const manualExtraction = this.manualExtractPackages.has(packageId); + const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); const shouldExtract = this.settings.autoExtract || manualExtraction; if (!allDone && success + failed + cancelled >= items.length) { logger.warn( @@ -14302,7 +14843,6 @@ export class DownloadManager extends EventEmitter { } const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); - const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); if (manualArchiveFilter) { for (const archivePath of [...fullArchiveSet]) { if (!manualArchiveFilter.has(pathKey(archivePath))) { @@ -14450,9 +14990,26 @@ export class DownloadManager extends EventEmitter { })); } catch (error) { if (error instanceof DiskCapacityError) { - this.diskWaitEvents = [{ ...error.event, packageId }]; + if (signal?.aborted || this.lifecyclePhase === "stopping" || this.healthManualStop || this.healthShuttingDown || !pkg.enabled) { + this.clearPackageDiskRetry(packageId); + pkg.postProcessLabel = undefined; + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } const retryAt = error.event.retryAt; - this.packageDiskRetryAfterByPackage.set(packageId, retryAt); + const scheduled = this.schedulePackageDiskRetry(packageId, { + retryAt, + manualRequested: manualExtraction, + postProcessVersion, + runOwnerId: this.getPackageResultRunOwner(packageId), + archiveFilter: manualArchiveFilter ? new Set(manualArchiveFilter) : undefined, + selectedItemIds: new Set(fullExtractionItems.map((entry) => entry.id)) + }); + if (!scheduled) { + return; + } + this.diskWaitEvents = [{ ...error.event, packageId }]; for (const entry of fullExtractionItems) { entry.fullStatus = "Warte auf Festplatte"; entry.lastError = "Zu wenig Speicherplatz"; @@ -14627,7 +15184,8 @@ export class DownloadManager extends EventEmitter { failed, alreadyMarkedExtracted, extractedCount, - manualExtraction + manualExtraction, + Boolean(manualArchiveFilter) || !allDone ); } @@ -14638,10 +15196,11 @@ export class DownloadManager extends EventEmitter { failed: number, alreadyMarkedExtracted: boolean, extractedCount: number, - manualSelection = false + manualRequested = false, + preservePartialArchiveState = manualRequested ): Promise { this.trackPackagePostProcessResult(packageId); - const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualSelection) + const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualRequested, preservePartialArchiveState) .finally(() => { const tasks = this.packageDeferredPostProcessTasks.get(packageId); tasks?.delete(task); @@ -14662,10 +15221,11 @@ export class DownloadManager extends EventEmitter { packageId: string, pkg: PackageEntry, success: number, - failed: number, - alreadyMarkedExtracted: boolean, + failed: number, + alreadyMarkedExtracted: boolean, extractedCount: number, - manualSelection: boolean + manualRequested: boolean, + preservePartialArchiveState: boolean ): Promise { const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId); if (replacedController && !replacedController.signal.aborted) { @@ -14686,7 +15246,7 @@ export class DownloadManager extends EventEmitter { try { throwIfAborted(); - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualSelection)) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualRequested)) { const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; const nestedCandidates = outputScope.archiveFiles() .filter((candidate) => isPathInsideDir(candidate, pkg.extractDir) && !nestedBlacklist.test(candidate)); @@ -14753,7 +15313,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !manualSelection) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !preservePartialArchiveState) { pkg.postProcessLabel = "Aufräumen..."; this.emitState(); throwIfAborted(); @@ -14788,7 +15348,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !manualSelection) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !preservePartialArchiveState) { throwIfAborted(); await clearExtractResumeState(pkg.outputDir, packageId); await clearExtractResumeState(pkg.outputDir); @@ -15004,6 +15564,7 @@ export class DownloadManager extends EventEmitter { const runContext = total > 0 ? this.finishActiveRunContext(this.runPackageIds, runStartedAt) : null; this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); diff --git a/src/main/extraction-ipc.ts b/src/main/extraction-ipc.ts new file mode 100644 index 0000000..fb77de6 --- /dev/null +++ b/src/main/extraction-ipc.ts @@ -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; + extractNow(request: ExtractNowRequest): Promise; +} + +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])); + }); +} diff --git a/src/main/extractor.ts b/src/main/extractor.ts index cf40a20..e653502 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -127,6 +127,13 @@ class ExtractionOutputCallbackError extends Error { this.name = "ExtractionOutputCallbackError"; } } + +class ExtractionPasswordAttemptCallbackError extends Error { + public constructor() { + super("extract_password_attempt_callback_failed"); + this.name = "ExtractionPasswordAttemptCallbackError"; + } +} type ExtractionErrorWithHints = Error & { suggestRedownload?: boolean; @@ -150,15 +157,23 @@ type JvmExtractResult = { aborted: boolean; timedOut: boolean; errorText: string; - usedPassword: string; + usedPassword: string; backend: string; + passwordAttempts: number; + passwordAttemptTotal: number; + passwordCandidatesExhausted: boolean; }; +type JvmExtractBaseResult = Omit; + type JvmParseState = { bestPercent: number; usedPassword: string; backend: string; reportedError: string; + passwordAttempts: number; + passwordAttemptTotal: number; + passwordCandidates: string[]; outputError?: Error; openedOutputs?: Map; }; @@ -206,8 +221,9 @@ type ExtractorCommandKind = "rar_native" | "seven_zip" | "other"; interface SubstMapping { drive: string; original: string; } interface DaemonRequest { - resolve: (result: JvmExtractResult) => void; - onArchiveProgress?: (percent: number) => void; + resolve: (result: JvmExtractResult) => void; + onArchiveProgress?: (percent: number) => void; + onPasswordAttempt?: (attempt: number, total: number) => void; signal?: AbortSignal; timeoutMs?: number; parseState: JvmParseState; @@ -911,7 +927,7 @@ function archivePasswords(listInput: string): string[] { return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS])); } -function prioritizePassword(passwords: string[], successful: string): string[] { +function prioritizePassword(passwords: string[], successful: string): string[] { const target = String(successful || ""); if (!target || passwords.length <= 1) { return passwords; @@ -926,9 +942,9 @@ function prioritizePassword(passwords: string[], successful: string): string[] { const next = [...passwords]; const [value] = next.splice(index, 1); next.unshift(value); - return next; -} - + return next; +} + export function cleanErrorText(text: string): string { const normalized = String(text || "").replace(/\s+/g, " ").trim(); if (normalized.length <= 500) { @@ -976,33 +992,45 @@ function withExtractionErrorHints( export function classifyExtractionError(errorText: unknown): ExtractErrorCategory { if (errorText instanceof ExtractionError) return errorText.category; 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("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") + || 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"; if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full"; - return "unknown"; -} + 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, - 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" - ); -} +export function shouldSerialRetryParallelFailures( + _extractedCount: number, + failedCategories: ExtractErrorCategory[] +): boolean { + if (failedCategories.length === 0) { + return false; + } + return failedCategories.every((category) => category === "unknown"); +} export function shouldFallbackLegacyRarToJvm( archivePath: string, @@ -1129,15 +1157,52 @@ function isRarNativeCommand(command: string): boolean { || base === "rar"; } -function extractorCommandKind(command: string): ExtractorCommandKind { +function extractorCommandKind(command: string): ExtractorCommandKind { if (isRarNativeCommand(command)) { return "rar_native"; } if (is7zCommand(command)) { return "seven_zip"; } - return "other"; -} + 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) @@ -1251,10 +1316,10 @@ function extractorProbeArgs(command: string): string[] { return isRarNativeCommand(command) ? ["-?"] : ["?"]; } -async function resolveExtractorCommandInternal(archivePath = ""): Promise { - if (resolvedExtractorCommand) { - return resolvedExtractorCommand; - } +async function resolveExtractorCommandInternal(archivePath = ""): Promise { + if (resolvedExtractorCommand && cachedExtractorFitsArchive(resolvedExtractorCommand, archivePath)) { + return resolvedExtractorCommand; + } if (resolveFailureReason) { const age = Date.now() - resolveFailureAt; if (age < EXTRACTOR_RETRY_AFTER_MS) { @@ -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 ` + : value; +} + +function jvmPasswordAttemptResult(state: JvmParseState, extractionSucceeded: boolean): Pick { + 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) { @@ -1694,7 +1813,7 @@ function parseJvmLine( return; } - if (trimmed.startsWith("RD_PASSWORD ")) { + if (trimmed.startsWith("RD_PASSWORD ")) { const encoded = trimmed.slice("RD_PASSWORD ".length).trim(); try { state.usedPassword = Buffer.from(encoded, "base64").toString("utf8"); @@ -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]; @@ -1751,8 +1892,12 @@ let daemonStdoutBuffer = ""; let daemonStderrBuffer = ""; let daemonOutput = ""; let daemonTimeoutId: NodeJS.Timeout | null = null; -let daemonAbortHandler: (() => void) | null = null; -let daemonLayout: JvmExtractorLayout | 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) { @@ -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,19 +1946,24 @@ 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 { if (!req) { return; } - if (daemonStdoutBuffer.trim()) { - parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput); + if (daemonStdoutBuffer.trim()) { + appendDaemonDiagnosticLine(daemonStdoutBuffer); + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt); daemonStdoutBuffer = ""; } - if (daemonStderrBuffer.trim()) { - parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput); + if (daemonStderrBuffer.trim()) { + 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); } } @@ -1914,30 +2064,30 @@ function startDaemon(layout: JvmExtractorLayout): boolean { daemonProcess = child; daemonLayout = layout; - child.stdout!.on("data", (chunk) => { - const raw = String(chunk || ""); - daemonOutput = appendLimited(daemonOutput, raw); - daemonStdoutBuffer += raw; + child.stdout!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonStdoutBuffer += raw; const lines = daemonStdoutBuffer.split(/\r?\n/); - daemonStdoutBuffer = lines.pop() || ""; - for (const line of lines) { - handleDaemonLine(line); + 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); + child.stderr!.on("data", (chunk) => { + const raw = String(chunk || ""); 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,21 +2213,31 @@ 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 { 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; daemonOutput = ""; daemonCurrentRequest = { resolve, - onArchiveProgress, + 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 { 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; @@ -2229,16 +2402,16 @@ async function runJvmExtractCommand( const child = spawn(layout.javaCommand, args, { windowsHide: true }); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); - const flushLines = (rawChunk: string, fromStdErr = false): void => { + const flushLines = (rawChunk: string, fromStdErr = false): void => { if (!rawChunk) { return; } - output = appendLimited(output, rawChunk); - const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; + const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; const lines = nextBuffer.split(/\r?\n/); - const keep = lines.pop() || ""; + 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,8 +2458,11 @@ async function runJvmExtractCommand( signal.removeEventListener("abort", onAbort); } cleanupTmpDir(); - resolve(finalResult); - }; + resolve({ + ...finalResult, + ...jvmPasswordAttemptResult(parseState, finalResult.ok) + }); + }; if (timeoutMs && timeoutMs > 0) { timeoutId = setTimeout(() => { @@ -2325,9 +2501,17 @@ async function runJvmExtractCommand( }); }); - child.on("close", (code) => { - parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput); - parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput); + child.on("close", (code) => { + 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,9 +3041,7 @@ function createNativeOutputCollector( }; } -const extractRetryDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -async function runExternalExtractInner( +async function runExternalExtractInner( command: string, archivePath: string, targetDir: string, @@ -2874,9 +3056,11 @@ async function runExternalExtractInner( flatModeResult?: { needed: boolean }, onLog?: ExtractOptions["onLog"], onOutput?: (event: ExtractOutputEvent) => void -): Promise { - const passwords = passwordCandidates; - let lastError = ""; +): Promise { + 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; @@ -2958,10 +3142,14 @@ async function runExternalExtractInner( passwordAttempt += 1; const attemptStartedAt = Date.now(); onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=`); - logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); - if (passwords.length > 1) { - onPasswordAttempt?.(passwordAttempt, passwords.length); - } + logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); + if (passwords.length > 1) { + 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); @@ -2979,12 +3167,15 @@ async function runExternalExtractInner( `ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}` ); onLog?.("INFO", `Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); - if (!result.ok) { - const errorSummary = summarizeResultError(result.errorText); + if (!result.ok) { + const errorSummary = summarizeResultError(result.errorText); if (errorSummary) { 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}`); - } + 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) { @@ -3003,9 +3194,10 @@ async function runExternalExtractInner( throw new Error("aborted:extract"); } - if (result.timedOut) { - lastError = result.errorText; - break; + if (result.timedOut) { + lastError = result.errorText; + terminalError = true; + break; } if (result.missingCommand) { @@ -3044,8 +3236,9 @@ 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( archivePath: string, @@ -3065,9 +3258,10 @@ async function runExternalExtract( const configuredBackendMode = extractorBackendMode(); const backendMode = extractorBackendModeForArchive(archivePath); const archiveName = path.basename(archivePath); - const totalStartedAt = Date.now(); - let jvmFailureReason = ""; - let jvmCodecError = false; + const totalStartedAt = Date.now(); + let jvmFailureReason = ""; + 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}`); @@ -3090,9 +3284,9 @@ async function runExternalExtract( logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`); const jvmStartedAt = Date.now(); 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 + const jvmResult = await runJvmExtractCommand( + layout, archivePath, targetDir, conflictMode, passwordCandidates, + 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"}`); @@ -3110,16 +3304,16 @@ async function runExternalExtract( throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`); } - jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; - fallbackFromJvm = true; + 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 || ""); @@ -3275,9 +3414,10 @@ async function runExternalExtract( conflictMode, passwordCandidates, onArchiveProgress, - signal, + 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"}`); @@ -3288,12 +3428,18 @@ async function runExternalExtract( onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); return jvmResult.usedPassword; } - if (jvmResult.aborted) { - throw new Error("aborted:extract"); - } - finalLegacyError = withExtractionErrorHints(finalLegacyError, { - jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen" - }); + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + const fallbackJvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; + finalLegacyError = withExtractionErrorHints(finalLegacyError, { + 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")}`); } else { @@ -3851,6 +3997,15 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { + 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 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 ${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 { 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=`); + safeOnLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); } : undefined; try { @@ -4167,7 +4322,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { 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 { 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 { 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 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,54 +4484,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise 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)); - 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`); - } - } - } - - 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`); - } - } - } + const serialRecoveryByPath = new Map(); + 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) { + if (!extractedArchives.has(archivePath)) { + failed = failedBeforeRetry; + } + const errText = String(retryError); + if (isExtractAbortError(errText)) throw retryError; + } + } + if (retryRecovered > 0) { + logger.info(`Serielle Wiederholung: ${retryRecovered}/${serialRecoveryArchives.length} Archive erfolgreich entpackt`); + } + } } if (noExtractorEncountered) { @@ -4445,11 +4587,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { 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); } }); diff --git a/src/main/main.ts b/src/main/main.ts index 107384d..d6f3cb7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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); diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 6dd5153..aee0de0 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -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") diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 10cddd7..7a34f27 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -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 ; diff --git a/src/renderer/views/downloads/package-presentation.ts b/src/renderer/views/downloads/package-presentation.ts index 5fea16d..d7bd72b 100644 --- a/src/renderer/views/downloads/package-presentation.ts +++ b/src/renderer/views/downloads/package-presentation.ts @@ -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, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 4e93479..2a263d0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -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" ); @@ -7324,9 +7439,9 @@ describe("download manager", () => { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", - category: "crc_error", - suggestRedownload: true, - jvmFailureReason: "Can not open the file as archive" + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" }, "hybrid" ); @@ -7343,7 +7458,7 @@ describe("download manager", () => { expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); }); - it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { + it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -7418,21 +7533,41 @@ describe("download manager", () => { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", - category: "crc_error", - suggestRedownload: true, - jvmFailureReason: "Can not open the file as archive" + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" }, "hybrid" ); expect(changed).toBe(0); - for (const itemId of itemIds) { - const item = session.items[itemId]!; - expect(item.status).toBe("completed"); - expect(item.targetPath).toContain(".rar"); - expect(item.downloadedBytes).toBe(archiveSize); - } - }); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("completed"); + 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 () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -7513,10 +7648,11 @@ describe("download manager", () => { }, session, createStoragePaths(path.join(root, "state")) - ); - - const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); - expect(Array.from(ready)).toHaveLength(0); + ); + + 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)).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 = {} + ): { manager: DownloadManager; session: ReturnType } { + 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((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((_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((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); + }); +}); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 218b4b8..aef0d75 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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(), + 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(), + selectedVersion: 0 + })); + + expect(html.match(/>Entpacken - Ausstehend<\/span>/g)).toHaveLength(2); + expect(html).not.toContain(">Entpacken - 100%"); + 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"); diff --git a/tests/extraction-ipc-contract.test.ts b/tests/extraction-ipc-contract.test.ts new file mode 100644 index 0000000..877e81c --- /dev/null +++ b/tests/extraction-ipc-contract.test.ts @@ -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 unknown>(), + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), + appHandlers: new Map 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(() => {})), + 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[1]): void { + registerExtractionIpcHandlers((channel, handler) => { + electron.ipcHandlers.set(channel, handler); + }, target); +} + +function createController(manager: { + retryExtraction: (packageId: string) => Promise; + extractNow: (request: { packageIds: string[]; itemIds: string[] }) => Promise; +}): AppController { + const controller = Object.create(AppController.prototype) as { + manager: typeof manager; + audit: ReturnType; + }; + 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"); + }); +}); diff --git a/tests/extractor-jvm-protocol.test.ts b/tests/extractor-jvm-protocol.test.ts new file mode 100644 index 0000000..58b108b --- /dev/null +++ b/tests/extractor-jvm-protocol.test.ts @@ -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) { + 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 "); + 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 "); + expect(diagnosticText).not.toContain(secret); + expect(diagnosticText).not.toContain(encodedSecret); + expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true); + }, 20_000); +}); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 81bd76e..999de34 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -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 }; @@ -91,7 +97,85 @@ function hasJvmExtractorRuntime(): boolean { path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"), path.join(root, "lib", "zip4j.jar") ]; - return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); + 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 { @@ -111,19 +195,687 @@ function corruptFirstZipPayload(zipPath: string): void { fs.writeFileSync(zipPath, bytes); } -afterEach(() => { +afterEach(() => { + shutdownDaemon(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } if (originalBackend === undefined) { delete process.env.RD_EXTRACT_BACKEND; - } else { - process.env.RD_EXTRACT_BACKEND = originalBackend; - } -}); - -describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => { - it("extracts zip archives through SevenZipJBinding backend", async () => { + } 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"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-")); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3335377..0cd89cf 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -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 }; @@ -110,16 +146,21 @@ afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } - if (originalExtractBackend === undefined) { + if (originalExtractBackend === undefined) { delete process.env.RD_EXTRACT_BACKEND; - } else { - process.env.RD_EXTRACT_BACKEND = originalExtractBackend; + } 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; }); describe("extractor", () => { - it("maps external extractor args by conflict mode", () => { + it("maps external extractor args by conflict mode", () => { const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite"); expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]); expect(overwriteArgs).toContain("-idc"); @@ -148,8 +189,69 @@ describe("extractor", () => { const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org"); expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]); expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar"); - expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\"); - }); + 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-")); @@ -1092,15 +1194,24 @@ describe("extractor", () => { }); }); - describe("classifyExtractionError", () => { - it("classifies CRC errors", () => { - expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error"); - expect(classifyExtractionError("Checksum error in data")).toBe("crc_error"); - }); + describe("classifyExtractionError", () => { + 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"); + 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", () => { @@ -1140,9 +1251,9 @@ describe("extractor", () => { expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error"); }); - it("returns unknown for unrecognized errors", () => { - expect(classifyExtractionError("something weird happened")).toBe("unknown"); - }); + it("returns unknown for unrecognized errors", () => { + expect(classifyExtractionError("something weird happened")).toBe("unknown"); + }); it("keeps important tail markers when long extractor output is trimmed", () => { const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`; @@ -1152,17 +1263,19 @@ 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("only retries a total parallel wipe-out for contention-like failures", () => { - expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true); - expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false); - expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false); - }); + describe("shouldSerialRetryParallelFailures", () => { + it("retries unknown failures that can result from parallel contention", () => { + expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true); + expect(shouldSerialRetryParallelFailures(0, ["unknown", "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); + }); }); describe("password discovery", () => { @@ -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(); + + 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(); + 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 "); + 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"]], diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index a70a782..ce1bd9c 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -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"], diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 6336c3f..fa0cda6 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -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(); afterEach(() => { vi.restoreAllMocks(); @@ -36,6 +38,7 @@ function setup(settings: Partial = {}): { 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((resolve) => { releasePostProcess = resolve; }); - manager.retryExtraction(pkg.id); + await manager.retryExtraction(pkg.id); const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id); expect(retriedPostProcess).toBeDefined(); releasePostProcess(); diff --git a/tests/package-presentation.test.ts b/tests/package-presentation.test.ts index 10efcd4..de88d55 100644 --- a/tests/package-presentation.test.ts +++ b/tests/package-presentation.test.ts @@ -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")), diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts index 31cd321..ec23110 100644 --- a/tests/public-release-metadata.test.ts +++ b/tests/public-release-metadata.test.ts @@ -58,14 +58,22 @@ async function createFixtureAsar(version: string): Promise { } const validAppAsar = await createFixtureAsar("1.7.233"); const staleAppAsar = await createFixtureAsar("1.7.232"); -const redistributionFiles = [ +const redistributionFiles = [ "LICENSE", "THIRD_PARTY_NOTICES.md", "resources/extractor-jvm/licenses/LGPL-2.1.txt", "resources/extractor-jvm/licenses/7-Zip-license.txt", "resources/extractor-jvm/licenses/Apache-2.0.txt", "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt" -] as const; +] 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("/")); @@ -73,7 +81,7 @@ function writeFile(rootDir: string, relativePath: string, content: string | Buff fs.writeFileSync(filePath, content); } -function writeRedistributionFiles(rootDir: string, packaged = false): void { +function writeRedistributionFiles(rootDir: string, packaged = false): void { for (const relativePath of redistributionFiles) { const content = fs.readFileSync(path.resolve(...relativePath.split("/"))); let targetPath: string = relativePath; @@ -104,21 +112,44 @@ 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 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 = "") { +function createArchiveCommandRunner(omittedName = "", corruptArchiveName = "") { + let currentArchiveName = ""; return (command: string, args: string[]): CommandResult => { const archivePath = args[1] || ""; const outputArg = args.find((arg) => arg.startsWith("-o")); if (!outputArg) { return { status: 2, stderr: "missing output directory" }; } - const outputDir = outputArg.slice(2); - if (archivePath.toLowerCase().endsWith(".exe")) { - writeFile(outputDir, "payload/app-64.7z", "nested archive"); - } else if (archivePath.toLowerCase().endsWith(".7z")) { - writeArchivePayload(outputDir, 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: "" }; }; } @@ -139,15 +170,18 @@ function createReleaseFixture(): string { owner: "Sucukdeluxe", repo: "Multi-Debrid-Downloader" }, - files: [ + files: [ "build/main/**/*", "build/renderer/**/*", "resources/extractor-jvm/**/*", "LICENSE", "THIRD_PARTY_NOTICES.md", - "package.json" - ], - extraResources: [ + "package.json" + ], + asarUnpack: [ + "resources/extractor-jvm/**/*" + ], + extraResources: [ { from: "LICENSE", to: "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); @@ -347,15 +383,74 @@ describe("public release metadata", () => { expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i); }); - it("rejects build metadata that omits the project license", () => { + it("rejects build metadata that omits the project license", () => { const rootDir = createReleaseFixture(); const packagePath = path.join(rootDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE"); fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); - expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/); - }); + 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(); @@ -429,14 +524,26 @@ describe("public release metadata", () => { ]); }); - it("rejects an archive whose nested application payload omits a license", () => { + it("rejects an archive whose nested application payload omits a license", () => { const rootDir = createReleaseFixture(); expect(() => verifyReleaseArchives(rootDir, { sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe", runCommand: createArchiveCommandRunner("Apache-2.0.txt") - })).toThrow(/Apache-2\.0\.txt|missing redistribution file/i); - }); + })).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(); diff --git a/tests/resolve-archive-items.test.ts b/tests/resolve-archive-items.test.ts index 6307e11..7635e79 100644 --- a/tests/resolve-archive-items.test.ts +++ b/tests/resolve-archive-items.test.ts @@ -122,11 +122,11 @@ describe("resolveArchiveItemsFromList", () => { expect(result).toHaveLength(1); }); - it("returns single archive item when no pattern matches", () => { - const items = makeItems(["totally-different-name.rar"]); - const result = resolveArchiveItemsFromList("Original.rar", items as any); - expect(result).toHaveLength(1); - }); + 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(0); + }); it("returns empty when items have no archive extensions", () => { const items = makeItems(["video.mkv", "subtitle.srt"]); @@ -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", () => {