release: harden archive recovery and lifecycle for v2.0.63
Corroborate CRC failures across extraction backends, retry only implicated multipart volumes, and distinguish corruption, missing volumes, I/O failures, and wrong passwords across native, Zip4j, and JBinding paths. Make disk retries generation-safe, preserve selective run scopes and cooldowns, protect shared output files during cleanup, validate manual extraction batches atomically, and restore interrupted integrity work safely. Prioritize active package operations in the UI, strengthen extraction IPC validation, compile the JVM sidecar before release builds, and verify shipped JVM resources byte-for-byte in every Windows artifact.
This commit is contained in:
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
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
|
## [2.0.62] - 2026-08-23
|
||||||
|
|
||||||
### Manual extraction
|
### Manual extraction
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.62",
|
"version": "2.0.63",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.62",
|
"version": "2.0.63",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.62",
|
"version": "2.0.63",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
"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: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",
|
"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": "npm run build:main && npm run build:renderer",
|
||||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
"build:extractor-jvm": "node scripts/build-extractor-jvm.mjs",
|
||||||
|
"build:main": "npm run build:extractor-jvm && tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"start": "cross-env NODE_ENV=production electron .",
|
"start": "cross-env NODE_ENV=production electron .",
|
||||||
"test": "npm run test:client && npm run test:backup-api",
|
"test": "npm run test:client && npm run test:backup-api",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
77818e292a3c70c1cf1cf8ab12f02e8e05e0e9a5e4140108465d62cf18ecf30f
|
||||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,8 @@ package com.sucukdeluxe.extractor;
|
|||||||
|
|
||||||
import net.lingala.zip4j.ZipFile;
|
import net.lingala.zip4j.ZipFile;
|
||||||
import net.lingala.zip4j.exception.ZipException;
|
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.ExtractAskMode;
|
||||||
import net.sf.sevenzipjbinding.ExtractOperationResult;
|
import net.sf.sevenzipjbinding.ExtractOperationResult;
|
||||||
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
|
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
|
||||||
@@ -106,115 +107,234 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
|
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
|
||||||
|
Map<String, Object> values = new DaemonJsonParser(jsonLine).parseObject();
|
||||||
ExtractionRequest request = new ExtractionRequest();
|
ExtractionRequest request = new ExtractionRequest();
|
||||||
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
|
request.archiveFile = new File(requireJsonString(values, "archive"));
|
||||||
request.targetDir = new File(extractJsonString(jsonLine, "target"));
|
request.targetDir = new File(requireJsonString(values, "target"));
|
||||||
String conflict = extractJsonString(jsonLine, "conflict");
|
String conflict = optionalJsonString(values, "conflict");
|
||||||
if (conflict.length() > 0) {
|
if (conflict.length() > 0) {
|
||||||
request.conflictMode = ConflictMode.fromValue(conflict);
|
request.conflictMode = ConflictMode.fromValue(conflict);
|
||||||
}
|
}
|
||||||
String backend = extractJsonString(jsonLine, "backend");
|
String backend = optionalJsonString(values, "backend");
|
||||||
if (backend.length() > 0) {
|
if (backend.length() > 0) {
|
||||||
request.backend = Backend.fromValue(backend);
|
request.backend = Backend.fromValue(backend);
|
||||||
}
|
}
|
||||||
|
Object rawPasswords = values.get("passwords");
|
||||||
int pwStart = jsonLine.indexOf("\"passwords\"");
|
if (rawPasswords != null) {
|
||||||
if (pwStart >= 0) {
|
if (!(rawPasswords instanceof List<?>)) {
|
||||||
int arrStart = jsonLine.indexOf('[', pwStart);
|
throw new IllegalArgumentException("Daemon-Feld passwords muss ein String-Array sein");
|
||||||
int arrEnd = jsonLine.indexOf(']', arrStart);
|
}
|
||||||
if (arrStart >= 0 && arrEnd > arrStart) {
|
for (Object value : (List<?>) rawPasswords) {
|
||||||
String arrContent = jsonLine.substring(arrStart + 1, arrEnd);
|
if (!(value instanceof String)) {
|
||||||
int idx = 0;
|
throw new IllegalArgumentException("Daemon-Feld passwords muss nur Strings enthalten");
|
||||||
while (idx < arrContent.length()) {
|
}
|
||||||
int qStart = arrContent.indexOf('"', idx);
|
request.passwords.add((String) value);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) {
|
if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) {
|
||||||
throw new IllegalArgumentException("Archiv nicht gefunden: " +
|
throw new IllegalArgumentException("Archiv nicht gefunden: " +
|
||||||
(request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath()));
|
(request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath()));
|
||||||
}
|
}
|
||||||
if (request.targetDir == null) {
|
if (request.targetDir == null) {
|
||||||
throw new IllegalArgumentException("--target fehlt");
|
throw new IllegalArgumentException("--target fehlt");
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractJsonString(String json, String key) {
|
private static String requireJsonString(Map<String, Object> values, String key) {
|
||||||
String search = "\"" + key + "\"";
|
String value = optionalJsonString(values, key);
|
||||||
int keyIdx = json.indexOf(search);
|
if (value.length() == 0) {
|
||||||
if (keyIdx < 0) return "";
|
throw new IllegalArgumentException("Daemon-Feld fehlt: " + key);
|
||||||
int colonIdx = json.indexOf(':', keyIdx + search.length());
|
}
|
||||||
if (colonIdx < 0) return "";
|
return value;
|
||||||
int qStart = json.indexOf('"', colonIdx + 1);
|
}
|
||||||
if (qStart < 0) return "";
|
|
||||||
int qEnd = findClosingQuote(json, qStart + 1);
|
private static String optionalJsonString(Map<String, Object> values, String key) {
|
||||||
if (qEnd < 0) return "";
|
Object value = values.get(key);
|
||||||
return unescapeJsonString(json.substring(qStart + 1, qEnd));
|
if (value == null) {
|
||||||
}
|
return "";
|
||||||
|
}
|
||||||
private static int findClosingQuote(String s, int from) {
|
if (!(value instanceof String)) {
|
||||||
for (int i = from; i < s.length(); i++) {
|
throw new IllegalArgumentException("Daemon-Feld muss ein String sein: " + key);
|
||||||
char c = s.charAt(i);
|
}
|
||||||
if (c == '\\') {
|
return (String) value;
|
||||||
i++;
|
}
|
||||||
continue;
|
|
||||||
}
|
private static final class DaemonJsonParser {
|
||||||
if (c == '"') return i;
|
private final String input;
|
||||||
}
|
private int index;
|
||||||
return -1;
|
|
||||||
}
|
DaemonJsonParser(String input) {
|
||||||
|
this.input = input == null ? "" : input;
|
||||||
private static String unescapeJsonString(String s) {
|
}
|
||||||
if (s.indexOf('\\') < 0) return s;
|
|
||||||
StringBuilder sb = new StringBuilder(s.length());
|
Map<String, Object> parseObject() {
|
||||||
for (int i = 0; i < s.length(); i++) {
|
skipWhitespace();
|
||||||
char c = s.charAt(i);
|
expect('{');
|
||||||
if (c == '\\' && i + 1 < s.length()) {
|
Map<String, Object> values = new HashMap<String, Object>();
|
||||||
char next = s.charAt(i + 1);
|
skipWhitespace();
|
||||||
switch (next) {
|
if (consume('}')) {
|
||||||
case '"': sb.append('"'); i++; break;
|
requireEnd();
|
||||||
case '\\': sb.append('\\'); i++; break;
|
return values;
|
||||||
case '/': sb.append('/'); i++; break;
|
}
|
||||||
case 'n': sb.append('\n'); i++; break;
|
while (true) {
|
||||||
case 'r': sb.append('\r'); i++; break;
|
String key = parseString();
|
||||||
case 't': sb.append('\t'); i++; break;
|
skipWhitespace();
|
||||||
default: sb.append(c); break;
|
expect(':');
|
||||||
}
|
skipWhitespace();
|
||||||
} else {
|
Object value = peek('"') ? parseString() : parseStringArray();
|
||||||
sb.append(c);
|
if (values.containsKey(key)) {
|
||||||
}
|
throw malformed();
|
||||||
}
|
}
|
||||||
return sb.toString();
|
values.put(key, value);
|
||||||
}
|
skipWhitespace();
|
||||||
|
if (consume('}')) {
|
||||||
|
requireEnd();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
expect(',');
|
||||||
|
skipWhitespace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> parseStringArray() {
|
||||||
|
expect('[');
|
||||||
|
List<String> values = new ArrayList<String>();
|
||||||
|
skipWhitespace();
|
||||||
|
if (consume(']')) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
values.add(parseString());
|
||||||
|
skipWhitespace();
|
||||||
|
if (consume(']')) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
expect(',');
|
||||||
|
skipWhitespace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseString() {
|
||||||
|
expect('"');
|
||||||
|
StringBuilder value = new StringBuilder();
|
||||||
|
while (index < input.length()) {
|
||||||
|
char current = input.charAt(index++);
|
||||||
|
if (current == '"') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (current < 0x20) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
if (current != '\\') {
|
||||||
|
value.append(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (index >= input.length()) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
char escaped = input.charAt(index++);
|
||||||
|
switch (escaped) {
|
||||||
|
case '"': value.append('"'); break;
|
||||||
|
case '\\': value.append('\\'); break;
|
||||||
|
case '/': value.append('/'); break;
|
||||||
|
case 'b': value.append('\b'); break;
|
||||||
|
case 'f': value.append('\f'); break;
|
||||||
|
case 'n': value.append('\n'); break;
|
||||||
|
case 'r': value.append('\r'); break;
|
||||||
|
case 't': value.append('\t'); break;
|
||||||
|
case 'u': value.append(parseUnicodeEscape()); break;
|
||||||
|
default: throw malformed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private char parseUnicodeEscape() {
|
||||||
|
if (index + 4 > input.length()) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
int value = 0;
|
||||||
|
for (int offset = 0; offset < 4; offset += 1) {
|
||||||
|
int digit = Character.digit(input.charAt(index++), 16);
|
||||||
|
if (digit < 0) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
value = (value << 4) | digit;
|
||||||
|
}
|
||||||
|
return (char) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireEnd() {
|
||||||
|
skipWhitespace();
|
||||||
|
if (index != input.length()) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void skipWhitespace() {
|
||||||
|
while (index < input.length()) {
|
||||||
|
char current = input.charAt(index);
|
||||||
|
if (current != ' ' && current != '\t' && current != '\r' && current != '\n') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean peek(char expected) {
|
||||||
|
return index < input.length() && input.charAt(index) == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean consume(char expected) {
|
||||||
|
if (!peek(expected)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expect(char expected) {
|
||||||
|
if (!consume(expected)) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IllegalArgumentException malformed() {
|
||||||
|
return new IllegalArgumentException("Ungueltige Daemon-JSON-Anfrage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int runExtraction(ExtractionRequest request) throws Exception {
|
private static int runExtraction(ExtractionRequest request) throws Exception {
|
||||||
List<String> passwords = normalizePasswords(request.passwords);
|
List<String> passwords = normalizePasswords(request.passwords);
|
||||||
Exception lastError = null;
|
Exception lastError = null;
|
||||||
boolean hadWrongPassword = false;
|
Exception integrityError = null;
|
||||||
for (String password : passwords) {
|
boolean hadWrongPassword = false;
|
||||||
try {
|
for (int passwordIndex = 0; passwordIndex < passwords.size(); passwordIndex++) {
|
||||||
extractSingle(request, password);
|
String password = passwords.get(passwordIndex);
|
||||||
emitPassword(password);
|
emitPasswordAttempt(passwordIndex + 1, passwords.size());
|
||||||
emitDone();
|
try {
|
||||||
|
extractSingle(request, password);
|
||||||
|
emitDone();
|
||||||
return 0;
|
return 0;
|
||||||
} catch (WrongPasswordException wrongPassword) {
|
} catch (AmbiguousPasswordOrIntegrityException ambiguous) {
|
||||||
hadWrongPassword = true;
|
integrityError = ambiguous;
|
||||||
lastError = wrongPassword;
|
lastError = ambiguous;
|
||||||
} catch (Exception error) {
|
} catch (WrongPasswordException wrongPassword) {
|
||||||
lastError = error;
|
hadWrongPassword = true;
|
||||||
|
lastError = wrongPassword;
|
||||||
|
} catch (Exception error) {
|
||||||
|
integrityError = null;
|
||||||
|
lastError = error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hadWrongPassword && (lastError instanceof WrongPasswordException)) {
|
if (integrityError != null) {
|
||||||
|
throw integrityError;
|
||||||
|
}
|
||||||
|
if (hadWrongPassword && (lastError instanceof WrongPasswordException)) {
|
||||||
emitError("Falsches Archiv-Passwort");
|
emitError("Falsches Archiv-Passwort");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -272,8 +392,31 @@ public final class JBindExtractorMain {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
encrypted = encrypted || header.isEncrypted();
|
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<String> reserved = new HashSet<String>();
|
Set<String> reserved = new HashSet<String>();
|
||||||
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
|
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
|
||||||
Map<FileHeader, String> plannedEntryNames = new IdentityHashMap<FileHeader, String>();
|
Map<FileHeader, String> plannedEntryNames = new IdentityHashMap<FileHeader, String>();
|
||||||
@@ -326,8 +469,9 @@ public final class JBindExtractorMain {
|
|||||||
emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition);
|
emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition);
|
||||||
ensureDirectory(output.getParentFile());
|
ensureDirectory(output.getParentFile());
|
||||||
rejectLinkedPath(request.targetDir, output);
|
rejectLinkedPath(request.targetDir, output);
|
||||||
long[] remaining = new long[] { itemUnits };
|
long[] remaining = new long[] { itemUnits };
|
||||||
boolean extractionSuccess = false;
|
boolean extractionSuccess = false;
|
||||||
|
boolean outputProduced = false;
|
||||||
try {
|
try {
|
||||||
InputStream in = zipFile.getInputStream(header);
|
InputStream in = zipFile.getInputStream(header);
|
||||||
try {
|
try {
|
||||||
@@ -341,8 +485,9 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
if (read == 0) {
|
if (read == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.write(buffer, 0, read);
|
out.write(buffer, 0, read);
|
||||||
|
outputProduced = true;
|
||||||
long accounted = Math.min(remaining[0], (long) read);
|
long accounted = Math.min(remaining[0], (long) read);
|
||||||
remaining[0] -= accounted;
|
remaining[0] -= accounted;
|
||||||
progress.advance(accounted);
|
progress.advance(accounted);
|
||||||
@@ -369,10 +514,18 @@ public final class JBindExtractorMain {
|
|||||||
extractionSuccess = true;
|
extractionSuccess = true;
|
||||||
emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition);
|
emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition);
|
||||||
} catch (ZipException error) {
|
} catch (ZipException error) {
|
||||||
if (isWrongPassword(error, encrypted)) {
|
if (isWrongPassword(error, encrypted, outputProduced)) {
|
||||||
throw new WrongPasswordException(error);
|
throw new WrongPasswordException(error);
|
||||||
}
|
}
|
||||||
throw 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 {
|
} finally {
|
||||||
if (!extractionSuccess && output.exists()) {
|
if (!extractionSuccess && output.exists()) {
|
||||||
if (output.delete()) {
|
if (output.delete()) {
|
||||||
@@ -405,8 +558,13 @@ public final class JBindExtractorMain {
|
|||||||
ensureSevenZipInitialized();
|
ensureSevenZipInitialized();
|
||||||
SevenZipArchiveContext context = null;
|
SevenZipArchiveContext context = null;
|
||||||
try {
|
try {
|
||||||
context = openSevenZipArchive(request.archiveFile, password);
|
context = openSevenZipArchive(request.archiveFile, password);
|
||||||
IInArchive archive = context.archive;
|
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();
|
int itemCount = archive.getNumberOfItems();
|
||||||
if (itemCount <= 0) {
|
if (itemCount <= 0) {
|
||||||
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
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 File[] currentOutput = new File[1];
|
||||||
final FileOutputStream[] currentStream = new FileOutputStream[1];
|
final FileOutputStream[] currentStream = new FileOutputStream[1];
|
||||||
final boolean[] currentSuccess = new boolean[1];
|
final boolean[] currentSuccess = new boolean[1];
|
||||||
final long[] currentRemaining = new long[1];
|
final long[] currentRemaining = new long[1];
|
||||||
final Throwable[] firstError = new Throwable[1];
|
final Throwable[] firstError = new Throwable[1];
|
||||||
final int[] currentPos = new int[] { -1 };
|
final int[] currentPos = new int[] { -1 };
|
||||||
|
final boolean[] passwordRequested = new boolean[1];
|
||||||
|
final boolean[] outputProduced = new boolean[1];
|
||||||
|
|
||||||
BulkExtractCallback extractCallback = new BulkExtractCallback(
|
BulkExtractCallback extractCallback = new BulkExtractCallback(
|
||||||
archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
|
archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
|
||||||
progress, encryptedFinal, effectivePassword, currentOutput,
|
progress, encryptedFinal, effectivePassword, currentOutput,
|
||||||
currentStream, currentSuccess, currentRemaining, currentPos, firstError
|
currentStream, currentSuccess, currentRemaining, currentPos, firstError, passwordRequested, outputProduced
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
archive.extract(indices, false, extractCallback);
|
archive.extract(indices, false, extractCallback);
|
||||||
} catch (SevenZipException error) {
|
} catch (SevenZipException error) {
|
||||||
if (looksLikeWrongPassword(error, encryptedFinal)) {
|
if (!outputProduced[0] && looksLikeWrongPassword(error, encryptedFinal || passwordRequested[0])) {
|
||||||
throw new WrongPasswordException(error);
|
throw new WrongPasswordException(error);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -552,15 +712,22 @@ public final class JBindExtractorMain {
|
|||||||
String effectivePassword = password == null ? "" : password;
|
String effectivePassword = password == null ? "" : password;
|
||||||
SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword);
|
SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword);
|
||||||
|
|
||||||
if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) {
|
if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) {
|
||||||
VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback);
|
VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback);
|
||||||
try {
|
try {
|
||||||
IInArchive archive = SevenZip.openInArchive(null, volumed, callback);
|
IInArchive archive = SevenZip.openInArchive(null, volumed, callback);
|
||||||
return new SevenZipArchiveContext(archive, null, volumed, callback);
|
return new SevenZipArchiveContext(archive, null, volumed, callback);
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
callback.close();
|
SevenZipException volumeAccessError = callback.getVolumeAccessError();
|
||||||
throw error;
|
callback.close();
|
||||||
}
|
if (volumeAccessError != null) {
|
||||||
|
throw volumeAccessError;
|
||||||
|
}
|
||||||
|
if (callback.wasPasswordRequested()) {
|
||||||
|
throw new WrongPasswordException(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RandomAccessFile raf = new RandomAccessFile(archiveFile, "r");
|
RandomAccessFile raf = new RandomAccessFile(archiveFile, "r");
|
||||||
@@ -568,23 +735,34 @@ public final class JBindExtractorMain {
|
|||||||
try {
|
try {
|
||||||
IInArchive archive = SevenZip.openInArchive(null, stream, callback);
|
IInArchive archive = SevenZip.openInArchive(null, stream, callback);
|
||||||
return new SevenZipArchiveContext(archive, stream, null, callback);
|
return new SevenZipArchiveContext(archive, stream, null, callback);
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
|
SevenZipException volumeAccessError = callback.getVolumeAccessError();
|
||||||
try {
|
try {
|
||||||
stream.close();
|
stream.close();
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
raf.close();
|
raf.close();
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
}
|
}
|
||||||
throw error;
|
callback.close();
|
||||||
|
if (volumeAccessError != null) {
|
||||||
|
throw volumeAccessError;
|
||||||
|
}
|
||||||
|
if (callback.wasPasswordRequested()) {
|
||||||
|
throw new WrongPasswordException(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isWrongPassword(ZipException error, boolean encrypted) {
|
private static boolean isWrongPassword(ZipException error, boolean encrypted, boolean outputProduced) {
|
||||||
if (error == null) {
|
if (error == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (outputProduced) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (error.getType() == ZipException.Type.WRONG_PASSWORD) {
|
if (error.getType() == ZipException.Type.WRONG_PASSWORD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -592,13 +770,31 @@ public final class JBindExtractorMain {
|
|||||||
if (text.contains("wrong password") || text.contains("falsches passwort")) {
|
if (text.contains("wrong password") || text.contains("falsches passwort")) {
|
||||||
return true;
|
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) {
|
private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted, boolean outputProduced) {
|
||||||
if (!encrypted || result == null) {
|
if (result == ExtractOperationResult.WRONG_PASSWORD) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!encrypted || outputProduced || result == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR;
|
return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,10 +1227,9 @@ public final class JBindExtractorMain {
|
|||||||
System.out.println("RD_BACKEND " + backend.value);
|
System.out.println("RD_BACKEND " + backend.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void emitPassword(String password) {
|
private static void emitPasswordAttempt(int attempt, int total) {
|
||||||
String encoded = Base64.getEncoder().encodeToString((password == null ? "" : password).getBytes(StandardCharsets.UTF_8));
|
System.out.println("RD_PASSWORD_ATTEMPT " + attempt + " " + total);
|
||||||
System.out.println("RD_PASSWORD " + encoded);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static void emitDone() {
|
private static void emitDone() {
|
||||||
System.out.println("RD_DONE");
|
System.out.println("RD_DONE");
|
||||||
@@ -1141,16 +1336,18 @@ public final class JBindExtractorMain {
|
|||||||
private final FileOutputStream[] currentStream;
|
private final FileOutputStream[] currentStream;
|
||||||
private final boolean[] currentSuccess;
|
private final boolean[] currentSuccess;
|
||||||
private final long[] currentRemaining;
|
private final long[] currentRemaining;
|
||||||
private final int[] currentPos;
|
private final int[] currentPos;
|
||||||
private final Throwable[] firstError;
|
private final Throwable[] firstError;
|
||||||
|
private final boolean[] passwordRequested;
|
||||||
|
private final boolean[] outputProduced;
|
||||||
|
|
||||||
BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map<Integer, Integer> indexToPos,
|
BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map<Integer, Integer> indexToPos,
|
||||||
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
|
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
|
||||||
List<String> entryNames, List<String> dispositions,
|
List<String> entryNames, List<String> dispositions,
|
||||||
ProgressTracker progress, boolean encrypted, String password,
|
ProgressTracker progress, boolean encrypted, String password,
|
||||||
File[] currentOutput, FileOutputStream[] currentStream,
|
File[] currentOutput, FileOutputStream[] currentStream,
|
||||||
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
||||||
Throwable[] firstError) {
|
Throwable[] firstError, boolean[] passwordRequested, boolean[] outputProduced) {
|
||||||
this.archive = archive;
|
this.archive = archive;
|
||||||
this.archiveFile = archiveFile;
|
this.archiveFile = archiveFile;
|
||||||
this.targetDir = targetDir;
|
this.targetDir = targetDir;
|
||||||
@@ -1168,12 +1365,15 @@ public final class JBindExtractorMain {
|
|||||||
this.currentSuccess = currentSuccess;
|
this.currentSuccess = currentSuccess;
|
||||||
this.currentRemaining = currentRemaining;
|
this.currentRemaining = currentRemaining;
|
||||||
this.currentPos = currentPos;
|
this.currentPos = currentPos;
|
||||||
this.firstError = firstError;
|
this.firstError = firstError;
|
||||||
|
this.passwordRequested = passwordRequested;
|
||||||
|
this.outputProduced = outputProduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String cryptoGetTextPassword() {
|
public String cryptoGetTextPassword() {
|
||||||
return password;
|
passwordRequested[0] = true;
|
||||||
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1225,9 +1425,10 @@ public final class JBindExtractorMain {
|
|||||||
if (data == null || data.length == 0) {
|
if (data == null || data.length == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
currentStream[0].write(data);
|
currentStream[0].write(data);
|
||||||
} catch (IOException error) {
|
outputProduced[0] = true;
|
||||||
|
} catch (IOException error) {
|
||||||
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
|
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
|
||||||
}
|
}
|
||||||
long accounted = Math.min(currentRemaining[0], (long) data.length);
|
long accounted = Math.min(currentRemaining[0], (long) data.length);
|
||||||
@@ -1267,8 +1468,8 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
discardCurrentOutput();
|
discardCurrentOutput();
|
||||||
if (firstError[0] == null) {
|
if (firstError[0] == null) {
|
||||||
if (isPasswordFailure(result, encrypted)) {
|
if (isPasswordFailure(result, encrypted || passwordRequested[0], outputProduced[0])) {
|
||||||
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
|
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
|
||||||
} else {
|
} else {
|
||||||
firstError[0] = new IOException("7z-Fehler: " + result.name());
|
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;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
WrongPasswordException(Throwable cause) {
|
WrongPasswordException(Throwable cause) {
|
||||||
super(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 static final class ProgressTracker {
|
||||||
private final long total;
|
private final long total;
|
||||||
@@ -1401,7 +1610,9 @@ public final class JBindExtractorMain {
|
|||||||
private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable {
|
private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable {
|
||||||
private final File archiveDir;
|
private final File archiveDir;
|
||||||
private final String firstFileName;
|
private final String firstFileName;
|
||||||
private final String password;
|
private final String password;
|
||||||
|
private boolean passwordRequested;
|
||||||
|
private SevenZipException volumeAccessError;
|
||||||
private final Map<String, RandomAccessFile> openRafs = new HashMap<String, RandomAccessFile>();
|
private final Map<String, RandomAccessFile> openRafs = new HashMap<String, RandomAccessFile>();
|
||||||
|
|
||||||
SevenZipVolumeCallback(File archiveFile, String password) {
|
SevenZipVolumeCallback(File archiveFile, String password) {
|
||||||
@@ -1433,8 +1644,9 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
raf.seek(0L);
|
raf.seek(0L);
|
||||||
return new RandomAccessFileInStream(raf);
|
return new RandomAccessFileInStream(raf);
|
||||||
} catch (IOException error) {
|
} catch (IOException error) {
|
||||||
throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error);
|
volumeAccessError = new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error);
|
||||||
|
throw volumeAccessError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1449,9 +1661,18 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String cryptoGetTextPassword() {
|
public String cryptoGetTextPassword() {
|
||||||
return password;
|
passwordRequested = true;
|
||||||
}
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean wasPasswordRequested() {
|
||||||
|
return passwordRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
SevenZipException getVolumeAccessError() {
|
||||||
|
return volumeAccessError;
|
||||||
|
}
|
||||||
|
|
||||||
private File resolveVolumeFile(String filename) {
|
private File resolveVolumeFile(String filename) {
|
||||||
if (filename == null || filename.trim().length() == 0) {
|
if (filename == null || filename.trim().length() == 0) {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const defaultRuntimeRoot = path.resolve(scriptDir, "..", "resources", "extractor-jvm");
|
||||||
|
const requiredLibNames = [
|
||||||
|
"sevenzipjbinding.jar",
|
||||||
|
"sevenzipjbinding-all-platforms.jar",
|
||||||
|
"zip4j.jar"
|
||||||
|
];
|
||||||
|
const stampName = ".source.sha256";
|
||||||
|
|
||||||
|
function parseArguments(args) {
|
||||||
|
let runtimeRoot = defaultRuntimeRoot;
|
||||||
|
let checkOnly = false;
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const value = args[index];
|
||||||
|
if (value === "--check") {
|
||||||
|
checkOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value === "--runtime-root") {
|
||||||
|
const next = args[index + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
throw new Error("--runtime-root benötigt einen Pfad");
|
||||||
|
}
|
||||||
|
runtimeRoot = path.resolve(next);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unbekanntes Argument: ${value}`);
|
||||||
|
}
|
||||||
|
return { runtimeRoot, checkOnly };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFile(filePath, label) {
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
throw new Error(`${label} fehlt: ${filePath}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!stat.isFile() || stat.size === 0 || stat.isSymbolicLink()) {
|
||||||
|
throw new Error(`${label} ist keine reguläre, nicht leere Datei: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listJavaSources(sourceRoot) {
|
||||||
|
const sources = [];
|
||||||
|
const pending = [sourceRoot];
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const current = pending.pop();
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const entryPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
pending.push(entryPath);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".java")) {
|
||||||
|
sources.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources.sort((left, right) => left.localeCompare(right, "en"));
|
||||||
|
if (sources.length === 0) {
|
||||||
|
throw new Error(`Keine Java-Quellen gefunden: ${sourceRoot}`);
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDigest(hash, root, filePath) {
|
||||||
|
const relativePath = path.relative(root, filePath).replace(/\\/g, "/");
|
||||||
|
const content = fs.readFileSync(filePath);
|
||||||
|
hash.update(relativePath, "utf8");
|
||||||
|
hash.update("\0", "utf8");
|
||||||
|
hash.update(String(content.length), "utf8");
|
||||||
|
hash.update("\0", "utf8");
|
||||||
|
hash.update(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceDigest(runtimeRoot, sources, libs) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update("javac:-source=8:-target=8:-encoding=UTF-8:-g:none\0", "utf8");
|
||||||
|
for (const filePath of [...sources, ...libs]) {
|
||||||
|
updateDigest(hash, runtimeRoot, filePath);
|
||||||
|
}
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyClassesDirectory(classesDir, expectedDigest) {
|
||||||
|
const mainClass = path.join(classesDir, "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||||
|
const stampPath = path.join(classesDir, stampName);
|
||||||
|
requireFile(mainClass, "JVM-Extractor-Hauptklasse");
|
||||||
|
requireFile(stampPath, "JVM-Extractor-Quellhash");
|
||||||
|
const mainClassBytes = fs.readFileSync(mainClass);
|
||||||
|
if (mainClassBytes.length < 8 || mainClassBytes.readUInt16BE(6) !== 52) {
|
||||||
|
throw new Error("JVM-Extractor-Hauptklasse muss Java-8-Bytecode verwenden");
|
||||||
|
}
|
||||||
|
const actualDigest = fs.readFileSync(stampPath, "utf8").trim();
|
||||||
|
if (actualDigest !== expectedDigest) {
|
||||||
|
throw new Error("JVM-Extractor-Klassen sind veraltet; Java-Quelle oder Bibliotheken wurden seit dem letzten Build geändert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyClasses(runtimeRoot, expectedDigest) {
|
||||||
|
verifyClassesDirectory(path.join(runtimeRoot, "classes"), expectedDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveJavac() {
|
||||||
|
const javaHome = String(process.env.JAVA_HOME || "").trim();
|
||||||
|
const javaHomeCompiler = javaHome
|
||||||
|
? path.join(javaHome, "bin", process.platform === "win32" ? "javac.exe" : "javac")
|
||||||
|
: "";
|
||||||
|
if (javaHomeCompiler && fs.existsSync(javaHomeCompiler)) {
|
||||||
|
return javaHomeCompiler;
|
||||||
|
}
|
||||||
|
return "javac";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireJavac() {
|
||||||
|
const command = resolveJavac();
|
||||||
|
const probe = spawnSync(command, ["-version"], { encoding: "utf8", windowsHide: true });
|
||||||
|
if (probe.error?.code === "ENOENT" || probe.status === null) {
|
||||||
|
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||||
|
}
|
||||||
|
if (probe.status !== 0) {
|
||||||
|
throw new Error(`JDK-Compiler javac ist nicht verwendbar: ${String(probe.stderr || probe.stdout || `Exit ${probe.status}`).trim()}`);
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceClasses(runtimeRoot, temporaryClasses) {
|
||||||
|
const classesDir = path.join(runtimeRoot, "classes");
|
||||||
|
const backupDir = path.join(runtimeRoot, `.classes-previous-${process.pid}-${Date.now()}`);
|
||||||
|
let previousMoved = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(classesDir)) {
|
||||||
|
fs.renameSync(classesDir, backupDir);
|
||||||
|
previousMoved = true;
|
||||||
|
}
|
||||||
|
fs.renameSync(temporaryClasses, classesDir);
|
||||||
|
if (previousMoved) {
|
||||||
|
fs.rmSync(backupDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!fs.existsSync(classesDir) && previousMoved && fs.existsSync(backupDir)) {
|
||||||
|
fs.renameSync(backupDir, classesDir);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileClasses(runtimeRoot, sources, libs, digest) {
|
||||||
|
const javac = requireJavac();
|
||||||
|
const temporaryClasses = fs.mkdtempSync(path.join(runtimeRoot, ".classes-build-"));
|
||||||
|
try {
|
||||||
|
const result = spawnSync(javac, [
|
||||||
|
"-source", "8",
|
||||||
|
"-target", "8",
|
||||||
|
"-encoding", "UTF-8",
|
||||||
|
"-g:none",
|
||||||
|
"-classpath", libs.join(path.delimiter),
|
||||||
|
"-d", temporaryClasses,
|
||||||
|
...sources
|
||||||
|
], { encoding: "utf8", windowsHide: true });
|
||||||
|
if (result.error?.code === "ENOENT") {
|
||||||
|
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`javac fehlgeschlagen: ${String(result.stderr || result.stdout || `Exit ${result.status}`).trim()}`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(temporaryClasses, stampName), `${digest}\n`, "utf8");
|
||||||
|
verifyClassesDirectory(temporaryClasses, digest);
|
||||||
|
replaceClasses(runtimeRoot, temporaryClasses);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(temporaryClasses)) {
|
||||||
|
fs.rmSync(temporaryClasses, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const { runtimeRoot, checkOnly } = parseArguments(process.argv.slice(2));
|
||||||
|
const sourceRoot = path.join(runtimeRoot, "src");
|
||||||
|
const sources = listJavaSources(sourceRoot);
|
||||||
|
const libs = requiredLibNames.map((name) => path.join(runtimeRoot, "lib", name));
|
||||||
|
for (const source of sources) {
|
||||||
|
requireFile(source, "Java-Quelle");
|
||||||
|
}
|
||||||
|
for (const lib of libs) {
|
||||||
|
requireFile(lib, "JVM-Extractor-Bibliothek");
|
||||||
|
}
|
||||||
|
const digest = sourceDigest(runtimeRoot, sources, libs);
|
||||||
|
if (checkOnly) {
|
||||||
|
verifyClasses(runtimeRoot, digest);
|
||||||
|
process.stdout.write(`JVM-Extractor-Klassen aktuell: ${digest}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compileClasses(runtimeRoot, sources, libs, digest);
|
||||||
|
verifyClasses(runtimeRoot, digest);
|
||||||
|
process.stdout.write(`JVM-Extractor-Klassen gebaut: ${digest}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`JVM-Extractor-Build fehlgeschlagen: ${String(error?.message || error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader";
|
|||||||
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
||||||
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
||||||
const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh";
|
const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh";
|
||||||
|
const EXPECTED_JVM_ASAR_UNPACK = "resources/extractor-jvm/**/*";
|
||||||
const REQUIRED_BUILD_FILES = Object.freeze([
|
const REQUIRED_BUILD_FILES = Object.freeze([
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
@@ -158,6 +159,97 @@ function sha256File(filePath) {
|
|||||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
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) {
|
function verifyPackagedIcon(sourceRoot, packagedRoot, label) {
|
||||||
const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico");
|
const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||||
const packagedPath = path.join(packagedRoot, "resources", "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}`);
|
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));
|
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
||||||
if (missingBuildFiles.length > 0) {
|
if (missingBuildFiles.length > 0) {
|
||||||
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
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)) {
|
if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) {
|
||||||
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
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(absoluteRoot, "sourcePath", "source");
|
||||||
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
||||||
verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "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 {
|
return {
|
||||||
publish: {
|
publish: {
|
||||||
@@ -399,8 +500,9 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
|||||||
requireNonEmptyFile(archivePath, "release archive");
|
requireNonEmptyFile(archivePath, "release archive");
|
||||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
||||||
try {
|
try {
|
||||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
||||||
|
verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot);
|
||||||
verifiedArchives.push(archiveName);
|
verifiedArchives.push(archiveName);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||||
|
|||||||
+974
-413
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IpcMainInvokeEvent } from "electron";
|
||||||
|
import type { ExtractNowRequest } from "../shared/extract-now";
|
||||||
|
import { normalizeExtractNowRequest } from "../shared/extract-now";
|
||||||
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
|
|
||||||
|
export interface ExtractionIpcTarget {
|
||||||
|
retryExtraction(packageId: string): Promise<void>;
|
||||||
|
extractNow(request: ExtractNowRequest): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrustedIpcRegistrar = (
|
||||||
|
channel: string,
|
||||||
|
listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
function normalizeRetryPackageId(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error("packageId muss ein nicht-leerer String sein");
|
||||||
|
}
|
||||||
|
const packageId = value.trim();
|
||||||
|
if (packageId.length > 256) {
|
||||||
|
throw new Error("packageId darf höchstens 256 Zeichen lang sein");
|
||||||
|
}
|
||||||
|
return packageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerExtractionIpcHandlers(registerTrusted: TrustedIpcRegistrar, target: ExtractionIpcTarget): void {
|
||||||
|
registerTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event, ...args) => {
|
||||||
|
return target.retryExtraction(normalizeRetryPackageId(args[0]));
|
||||||
|
});
|
||||||
|
registerTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event, ...args) => {
|
||||||
|
return target.extractNow(normalizeExtractNowRequest(args[0]));
|
||||||
|
});
|
||||||
|
}
|
||||||
+389
-247
File diff suppressed because it is too large
Load Diff
+2
-8
@@ -26,8 +26,8 @@ import { migrateProductUserDataDirectory } from "./storage";
|
|||||||
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
|
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
|
||||||
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
|
||||||
import { forceDarkNativeTheme } from "./native-theme";
|
import { forceDarkNativeTheme } from "./native-theme";
|
||||||
import { normalizeExtractNowRequest } from "../shared/extract-now";
|
|
||||||
import { validateClipboardWriteText } from "./clipboard-write";
|
import { validateClipboardWriteText } from "./clipboard-write";
|
||||||
|
import { registerExtractionIpcHandlers } from "./extraction-ipc";
|
||||||
|
|
||||||
forceDarkNativeTheme(nativeTheme);
|
forceDarkNativeTheme(nativeTheme);
|
||||||
|
|
||||||
@@ -642,13 +642,7 @@ function registerIpcHandlers(): void {
|
|||||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
||||||
});
|
});
|
||||||
handleTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
|
registerExtractionIpcHandlers((channel, listener) => handleTrusted(channel, listener), controller);
|
||||||
validateString(packageId, "packageId");
|
|
||||||
return controller.retryExtraction(packageId);
|
|
||||||
});
|
|
||||||
handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => {
|
|
||||||
return controller.extractNow(normalizeExtractNowRequest(request));
|
|
||||||
});
|
|
||||||
handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
return controller.resetPackage(packageId);
|
return controller.resetPackage(packageId);
|
||||||
|
|||||||
+59
-29
@@ -30,7 +30,10 @@ const pairs = [
|
|||||||
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
["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"],
|
["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"],
|
["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."],
|
["Ü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"],
|
["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"],
|
["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 {
|
function translatePackageStatusParts(value: string, language: AppLanguage): string | null {
|
||||||
const parts = value.split(" · ");
|
const parts = value.split(/( · |\n)/);
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
const translated = parts.map((part): string | null => {
|
let changed = false;
|
||||||
if (language === "en") {
|
const translated = parts.map((part): string => {
|
||||||
const exact = deToEn.get(part);
|
if (part === " · " || part === "\n") return part;
|
||||||
if (exact) return exact;
|
const runtime = translateRuntimeStatusPart(part, language);
|
||||||
const extractionError = part.match(/^(\d+) Entpackfehler$/);
|
if (runtime !== null) {
|
||||||
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
|
if (runtime !== part) changed = true;
|
||||||
const retry = part.match(/^(\d+) Wiederholung(?:en)?$/);
|
return runtime;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
const exact = enToDe.get(part);
|
return 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 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 {
|
function translateDynamic(value: string, language: AppLanguage): string {
|
||||||
@@ -260,8 +286,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
const source = language === "en" ? german : english;
|
const source = language === "en" ? german : english;
|
||||||
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
|
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
|
||||||
}
|
}
|
||||||
const packageStatus = translatePackageStatusParts(value, language);
|
const runtimeStatus = translateRuntimeStatusPart(value, language);
|
||||||
if (packageStatus) return packageStatus;
|
if (runtimeStatus) return runtimeStatus;
|
||||||
if (language === "en") {
|
if (language === "en") {
|
||||||
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
||||||
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
|
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"]
|
[/^(.+) 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);
|
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
|
return value
|
||||||
.replace(/Automatisch entpacken/g, "Extract automatically")
|
.replace(/Automatisch entpacken/g, "Extract automatically")
|
||||||
.replace(/Wartet auf Wiederholung/g, "Waiting to retry")
|
.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"]
|
[/^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);
|
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
|
return value
|
||||||
.replace(/Extract automatically/g, "Automatisch entpacken")
|
.replace(/Extract automatically/g, "Automatisch entpacken")
|
||||||
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
|
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
|
||||||
|
|||||||
@@ -392,14 +392,9 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
||||||
? "Entpacken - Ausstehend"
|
? "Entpacken - Ausstehend"
|
||||||
: compactPostProcessLabel;
|
: compactPostProcessLabel;
|
||||||
const detailPostProcessLabel = rawPostProcessLabel || postProcessLabel;
|
const detailPostProcessLabel = presentation.activeOperationLabel ? rawPostProcessLabel || postProcessLabel : "";
|
||||||
const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||||
const status = presentation.extractFailureCount === 0
|
const status = presentation.status;
|
||||||
&& presentation.retryCount === 0
|
|
||||||
&& postProcessLabel
|
|
||||||
&& (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
|
||||||
? postProcessLabel
|
|
||||||
: presentation.status;
|
|
||||||
const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
|
const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
|
||||||
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
|
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
|
||||||
return <DownloadStatusCell status={status} title={title} />;
|
return <DownloadStatusCell status={status} title={title} />;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface PackagePresentation {
|
|||||||
progress: PackageProgressPresentation;
|
progress: PackageProgressPresentation;
|
||||||
status: string;
|
status: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
activeOperationLabel: string;
|
||||||
extractFailure?: DownloadItem;
|
extractFailure?: DownloadItem;
|
||||||
extractFailureCount: number;
|
extractFailureCount: number;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
@@ -46,6 +47,16 @@ function isLinkConversionRetry(item: DownloadItem): boolean {
|
|||||||
return /(?:Link-Umwandlung erneut|Retrying link conversion)/i.test(item.fullStatus || "");
|
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 {
|
function downloadFraction(item: DownloadItem): number {
|
||||||
if (item.status === "completed") {
|
if (item.status === "completed") {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -71,6 +82,9 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
|||||||
let retrying = 0;
|
let retrying = 0;
|
||||||
let linkConversionRetrying = 0;
|
let linkConversionRetrying = 0;
|
||||||
let waitsForDisk = 0;
|
let waitsForDisk = 0;
|
||||||
|
let integrityChecking = 0;
|
||||||
|
let extractionPending = 0;
|
||||||
|
let waitingForParts = 0;
|
||||||
const extractFailures: DownloadItem[] = [];
|
const extractFailures: DownloadItem[] = [];
|
||||||
|
|
||||||
for (const item of row.allItems) {
|
for (const item of row.allItems) {
|
||||||
@@ -79,12 +93,15 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
|||||||
else if (item.status === "cancelled") cancelled += 1;
|
else if (item.status === "cancelled") cancelled += 1;
|
||||||
downloadUnits += downloadFraction(item);
|
downloadUnits += downloadFraction(item);
|
||||||
const fullStatus = item.fullStatus || "";
|
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)) {
|
if (/^Entpackt\b/i.test(fullStatus)) {
|
||||||
extractionUnits += 1;
|
extractionUnits += 1;
|
||||||
extractionLifecycle = true;
|
extractionLifecycle = true;
|
||||||
} else {
|
} else {
|
||||||
const progress = extractionPercent(fullStatus);
|
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;
|
extracting += 1;
|
||||||
extractionUnits += progress;
|
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 details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`;
|
||||||
const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1);
|
const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1);
|
||||||
const packageExtractLabel = (row.package.postProcessLabel || "").trim();
|
const packageExtractLabel = (row.package.postProcessLabel || "").trim();
|
||||||
|
const activeOperationLabel = activePackageOperationLabel(row.package.status, packageExtractLabel);
|
||||||
const downloading = row.package.status === "downloading"
|
const downloading = row.package.status === "downloading"
|
||||||
|| row.package.status === "validating"
|
|| row.package.status === "validating"
|
||||||
|| row.allItems.some((item) => item.status === "downloading" || item.status === "validating");
|
|| row.allItems.some((item) => item.status === "downloading" || item.status === "validating");
|
||||||
|
|
||||||
let status = allExtracted ? "Entpackt" : details;
|
let status = allExtracted ? "Entpackt" : details;
|
||||||
if (extractFailures.length > 0 && retrying > 0) {
|
if (activeOperationLabel) {
|
||||||
status = `${extractFailures.length} Entpackfehler · ${retryLabel}`;
|
status = activeOperationLabel;
|
||||||
} else if (extractFailures.length > 0) {
|
|
||||||
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
|
|
||||||
} else if (waitsForDisk > 0) {
|
} else if (waitsForDisk > 0) {
|
||||||
status = "Warte auf Festplatte";
|
status = "Warte auf Festplatte";
|
||||||
} else if (extracting > 0 || row.package.status === "extracting") {
|
} else if (integrityChecking > 0) {
|
||||||
status = packageExtractLabel || "Entpacken";
|
status = "CRC-Check läuft";
|
||||||
|
} else if (extractFailures.length > 0 && retrying > 0) {
|
||||||
|
status = `${extractFailures.length} Entpackfehler · ${retryLabel}`;
|
||||||
} else if (retrying > 0) {
|
} else if (retrying > 0) {
|
||||||
status = retryLabel;
|
status = retryLabel;
|
||||||
} else if (downloading) {
|
} else if (downloading) {
|
||||||
status = "Download läuft";
|
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 {
|
return {
|
||||||
progress: { done, failed, cancelled, total, value },
|
progress: { done, failed, cancelled, total, value },
|
||||||
status,
|
status,
|
||||||
details,
|
details,
|
||||||
|
activeOperationLabel,
|
||||||
extractFailure: extractFailures[0],
|
extractFailure: extractFailures[0],
|
||||||
extractFailureCount: extractFailures.length,
|
extractFailureCount: extractFailures.length,
|
||||||
retryCount: retrying,
|
retryCount: retrying,
|
||||||
|
|||||||
+863
-20
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
|||||||
import { EventEmitter, once } from "node:events";
|
import { EventEmitter, once } from "node:events";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||||
import { ExtractionCoordinator } from "../src/main/extraction-coordinator";
|
import { ExtractionCoordinator } from "../src/main/extraction-coordinator";
|
||||||
@@ -113,6 +113,13 @@ describe("selected item run scope", () => {
|
|||||||
|
|
||||||
expect(internal.findNextQueuedItem()).toBeNull();
|
expect(internal.findNextQueuedItem()).toBeNull();
|
||||||
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
|
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 () => {
|
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.standalonePackageResults.has("foreign-package:1")).toBe(true);
|
||||||
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false);
|
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", () => {
|
describe("download live update cadence", () => {
|
||||||
@@ -7228,7 +7343,7 @@ describe("download manager", () => {
|
|||||||
errorText: "Checksum error in the encrypted file",
|
errorText: "Checksum error in the encrypted file",
|
||||||
category: "crc_error",
|
category: "crc_error",
|
||||||
suggestRedownload: true,
|
suggestRedownload: true,
|
||||||
jvmFailureReason: "Can not open the file as archive"
|
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||||
},
|
},
|
||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
@@ -7324,9 +7439,9 @@ describe("download manager", () => {
|
|||||||
archiveName: "show.s01e01.part1.rar",
|
archiveName: "show.s01e01.part1.rar",
|
||||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||||
errorText: "Checksum error in the encrypted file",
|
errorText: "Checksum error in the encrypted file",
|
||||||
category: "crc_error",
|
category: "crc_error",
|
||||||
suggestRedownload: true,
|
suggestRedownload: true,
|
||||||
jvmFailureReason: "Can not open the file as archive"
|
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||||
},
|
},
|
||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
@@ -7343,7 +7458,7 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@@ -7418,21 +7533,41 @@ describe("download manager", () => {
|
|||||||
archiveName: "show.s01e01.part1.rar",
|
archiveName: "show.s01e01.part1.rar",
|
||||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||||
errorText: "Checksum error in the encrypted file",
|
errorText: "Checksum error in the encrypted file",
|
||||||
category: "crc_error",
|
category: "crc_error",
|
||||||
suggestRedownload: true,
|
suggestRedownload: true,
|
||||||
jvmFailureReason: "Can not open the file as archive"
|
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||||
},
|
},
|
||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(changed).toBe(0);
|
expect(changed).toBe(0);
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = session.items[itemId]!;
|
const item = session.items[itemId]!;
|
||||||
expect(item.status).toBe("completed");
|
expect(item.status).toBe("completed");
|
||||||
expect(item.targetPath).toContain(".rar");
|
expect(item.targetPath).toContain(".rar");
|
||||||
expect(item.downloadedBytes).toBe(archiveSize);
|
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 () => {
|
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
@@ -7513,10 +7648,11 @@ describe("download manager", () => {
|
|||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
expect((manager as any).looksLikeArchivePart("show.s01e01.part2.rar", "show.s01e01.part1.rar")).toBe(true);
|
||||||
expect(Array.from(ready)).toHaveLength(0);
|
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 () => {
|
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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -16713,3 +16983,576 @@ describe("download health snapshot", () => {
|
|||||||
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
|
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("post-processing lifecycle audit", () => {
|
||||||
|
function createCompletedFileManager(
|
||||||
|
root: string,
|
||||||
|
entries: Array<{ packageId: string; itemId: string; fileName: string; url?: string; content?: Buffer }>,
|
||||||
|
settings: Partial<AppSettings> = {}
|
||||||
|
): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
|
||||||
|
const session = emptySession();
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const outputDir = path.join(root, "downloads", entry.packageId);
|
||||||
|
const extractDir = path.join(root, "extract", entry.packageId);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const targetPath = path.join(outputDir, entry.fileName);
|
||||||
|
fs.writeFileSync(targetPath, entry.content ?? Buffer.alloc(256, 1));
|
||||||
|
if (!session.packages[entry.packageId]) {
|
||||||
|
session.packageOrder.push(entry.packageId);
|
||||||
|
session.packages[entry.packageId] = {
|
||||||
|
id: entry.packageId,
|
||||||
|
name: entry.packageId,
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
session.packages[entry.packageId].itemIds.push(entry.itemId);
|
||||||
|
const bytes = fs.statSync(targetPath).size;
|
||||||
|
session.items[entry.itemId] = {
|
||||||
|
id: entry.itemId,
|
||||||
|
packageId: entry.packageId,
|
||||||
|
url: entry.url ?? `https://example.test/${entry.itemId}`,
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: bytes,
|
||||||
|
totalBytes: bytes,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: entry.fileName,
|
||||||
|
targetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Entpacken - Ausstehend",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoRename4sf4sj: false,
|
||||||
|
collectMkvToLibrary: false,
|
||||||
|
enableIntegrityCheck: false,
|
||||||
|
cleanupMode: "none",
|
||||||
|
...settings
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
return { manager, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("requeues an interrupted integrity check and clears transient package progress on restart", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "integrity-package", itemId: "integrity-item", fileName: "episode.mkv" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
session.items["integrity-item"].status = "integrity_check";
|
||||||
|
session.items["integrity-item"].fullStatus = "CRC-Check läuft";
|
||||||
|
session.packages["integrity-package"].status = "integrity_check";
|
||||||
|
session.packages["integrity-package"].postProcessLabel = "Finalisieren (1/1)";
|
||||||
|
|
||||||
|
internal.normalizeSessionStatuses();
|
||||||
|
|
||||||
|
expect(session.items["integrity-item"]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" }));
|
||||||
|
expect(session.packages["integrity-package"].status).toBe("queued");
|
||||||
|
expect(session.packages["integrity-package"].postProcessLabel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps duplicate-suffixed items with independently existing files separate on startup", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-distinct-urls-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [
|
||||||
|
{ packageId: "distinct-package", itemId: "first-url", fileName: "episode.rar", url: "https://same.test/file" },
|
||||||
|
{ packageId: "distinct-package", itemId: "second-url", fileName: "episode (1).rar", url: "https://same.test/file" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = manager.getSnapshot().session;
|
||||||
|
expect(snapshot.packages["distinct-package"].itemIds).toEqual(["first-url", "second-url"]);
|
||||||
|
expect(snapshot.items["first-url"]).toBeDefined();
|
||||||
|
expect(snapshot.items["second-url"]).toBeDefined();
|
||||||
|
expect(fs.existsSync(session.items["second-url"].targetPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves startup cleanup archives to the recoverable trash in trash mode", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-trash-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "trash-package", itemId: "trash-item", fileName: "episode.part1.rar" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
session.items["trash-item"].fullStatus = "Entpackt - Fertig";
|
||||||
|
internal.settings.cleanupMode = "trash";
|
||||||
|
const archivePath = session.items["trash-item"].targetPath;
|
||||||
|
|
||||||
|
await internal.cleanupExistingExtractedArchives();
|
||||||
|
|
||||||
|
expect(fs.existsSync(archivePath)).toBe(false);
|
||||||
|
expect(fs.readdirSync(path.join(path.dirname(archivePath), ".rd-trash"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let queued startup cleanup remove a newly replaced archive", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-cleanup-race-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "cleanup-race-package", itemId: "cleanup-race-item", fileName: "episode.rar" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const archivePath = session.items["cleanup-race-item"].targetPath;
|
||||||
|
session.items["cleanup-race-item"].fullStatus = "Entpackt - Fertig";
|
||||||
|
internal.settings.cleanupMode = "delete";
|
||||||
|
let releaseQueue = (): void => {};
|
||||||
|
const queueGate = new Promise<void>((resolve) => { releaseQueue = resolve; });
|
||||||
|
internal.cleanupQueue = queueGate;
|
||||||
|
|
||||||
|
const cleanup = internal.cleanupExistingExtractedArchives();
|
||||||
|
await waitFor(() => internal.cleanupQueue !== queueGate, 2_000);
|
||||||
|
fs.writeFileSync(archivePath, Buffer.from("replacement"));
|
||||||
|
releaseQueue();
|
||||||
|
await cleanup;
|
||||||
|
|
||||||
|
expect(fs.readFileSync(archivePath, "utf8")).toBe("replacement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never cleans an archive part claimed by another package in a shared output directory", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cleanup-shared-claim-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [
|
||||||
|
{ packageId: "cleanup-owner-a", itemId: "cleanup-owner-a-item", fileName: "show.part1.rar" },
|
||||||
|
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-item", fileName: "show.part2.rar" },
|
||||||
|
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-nfo", fileName: "show.nfo" }
|
||||||
|
], { cleanupMode: "delete" });
|
||||||
|
const internal = manager as any;
|
||||||
|
const sharedDir = session.packages["cleanup-owner-a"].outputDir;
|
||||||
|
const foreignOldPath = session.items["cleanup-owner-b-item"].targetPath;
|
||||||
|
const foreignNfoOldPath = session.items["cleanup-owner-b-nfo"].targetPath;
|
||||||
|
const foreignSharedPath = path.join(sharedDir, "show.part2.rar");
|
||||||
|
const foreignNfoSharedPath = path.join(sharedDir, "show.nfo");
|
||||||
|
fs.renameSync(foreignOldPath, foreignSharedPath);
|
||||||
|
fs.renameSync(foreignNfoOldPath, foreignNfoSharedPath);
|
||||||
|
session.packages["cleanup-owner-b"].outputDir = sharedDir;
|
||||||
|
session.items["cleanup-owner-b-item"].targetPath = foreignSharedPath;
|
||||||
|
session.items["cleanup-owner-b-nfo"].targetPath = foreignNfoSharedPath;
|
||||||
|
|
||||||
|
const removed = await internal.cleanupRemainingArchiveArtifacts(session.packages["cleanup-owner-a"]);
|
||||||
|
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(fs.existsSync(session.items["cleanup-owner-a-item"].targetPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(foreignSharedPath)).toBe(true);
|
||||||
|
expect(fs.existsSync(foreignNfoSharedPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an ambiguous pathless selection shared by multiple archive directories", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ambiguous-pathless-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const item = {
|
||||||
|
id: "pathless-item", packageId: "pathless-package", url: "https://example.test/pathless", provider: "realdebrid",
|
||||||
|
status: "completed", retries: 0, speedBps: 0, downloadedBytes: 1, totalBytes: 1, progressPercent: 100,
|
||||||
|
fileName: "episode.part1.rar", targetPath: "", resumable: true, attempts: 1, lastError: "",
|
||||||
|
fullStatus: "Entpacken - Ausstehend", createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
} satisfies DownloadItem;
|
||||||
|
|
||||||
|
const selection = resolveSelectedArchiveSetsFromCandidates(
|
||||||
|
[path.join(root, "set-a", "episode.part1.rar"), path.join(root, "set-b", "episode.part1.rar")],
|
||||||
|
[item],
|
||||||
|
new Set([item.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selection.archivePaths.size).toBe(0);
|
||||||
|
expect(selection.itemIds.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicitly named first multipart volume as the CRC target", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "crc-first", fileName: "show.part1.rar", targetPath: "C:\\Downloads\\show.part1.rar" },
|
||||||
|
{ id: "crc-second", fileName: "show.part2.rar", targetPath: "C:\\Downloads\\show.part2.rar" }
|
||||||
|
] as DownloadItem[];
|
||||||
|
|
||||||
|
expect(findCrcImplicatedArchiveItems("C:\\Downloads\\show.part1.rar - checksum error", items).map((item) => item.id)).toEqual(["crc-first"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an unexpected package post-process exception as failure but not an abort", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-exception-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "exception-package", itemId: "exception-item", fileName: "episode.rar" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("unexpected-postprocess-failure"); });
|
||||||
|
|
||||||
|
await internal.runPackagePostProcessing("exception-package");
|
||||||
|
|
||||||
|
expect(session.packages["exception-package"].status).toBe("failed");
|
||||||
|
expect(session.items["exception-item"].fullStatus).toMatch(/^Entpack-Fehler/);
|
||||||
|
|
||||||
|
session.packages["exception-package"].status = "queued";
|
||||||
|
session.items["exception-item"].fullStatus = "Entpacken - Ausstehend";
|
||||||
|
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
signal.addEventListener("abort", () => reject(new Error("aborted:extract")), { once: true });
|
||||||
|
queueMicrotask(() => internal.abortPackagePostProcessing("exception-package", "stop"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await internal.runPackagePostProcessing("exception-package");
|
||||||
|
expect(session.packages["exception-package"].status).not.toBe("failed");
|
||||||
|
expect(session.items["exception-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||||
|
|
||||||
|
session.packages["exception-package"].status = "queued";
|
||||||
|
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("aborted:extract"); });
|
||||||
|
await internal.runPackagePostProcessing("exception-package");
|
||||||
|
expect(session.packages["exception-package"].status).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a manual extraction plan across an automatic disk-capacity retry", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-disk-retry-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-disk-package", itemId: "manual-disk-item", fileName: "episode.zip" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const archivePath = session.items["manual-disk-item"].targetPath;
|
||||||
|
const archiveKey = path.resolve(archivePath).toLowerCase();
|
||||||
|
internal.manualExtractPackages.add("manual-disk-package");
|
||||||
|
internal.manualExtractArchiveFilters.set("manual-disk-package", new Set([archiveKey]));
|
||||||
|
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||||
|
internal.diskReservations = new DiskReservationCoordinator({
|
||||||
|
safetyBytes: 0,
|
||||||
|
retryDelayMs: 1_000,
|
||||||
|
statVolume: async (targetPath) => ({ path: targetPath, volumeKey: "manual-volume", freeBytes: 0, totalBytes: 1_024 })
|
||||||
|
});
|
||||||
|
const rerun = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = rerun;
|
||||||
|
|
||||||
|
await internal.handlePackagePostProcessing("manual-disk-package");
|
||||||
|
expect(manager.getSnapshot().canStop).toBe(true);
|
||||||
|
manager.togglePackage("manual-disk-package");
|
||||||
|
expect(internal.packageDiskRetryPlans.has("manual-disk-package")).toBe(true);
|
||||||
|
manager.togglePackage("manual-disk-package");
|
||||||
|
internal.manualExtractPackages.clear();
|
||||||
|
internal.manualExtractArchiveFilters.clear();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
|
||||||
|
expect(rerun).toHaveBeenCalledWith("manual-disk-package");
|
||||||
|
expect(internal.manualExtractPackages.has("manual-disk-package")).toBe(true);
|
||||||
|
expect(internal.manualExtractArchiveFilters.get("manual-disk-package")).toEqual(new Set([archiveKey]));
|
||||||
|
expect(internal.packageDiskRetryAfterByPackage.has("manual-disk-package")).toBe(false);
|
||||||
|
expect(manager.getSnapshot().diskWaitEvents?.some((entry) => entry.packageId === "manual-disk-package")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps disk retry generations and stale timer callbacks isolated", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-identity-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-identity-package", itemId: "disk-identity-item", fileName: "episode.zip" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const rerun = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = rerun;
|
||||||
|
const request = { retryAt: Date.now() + 60_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null };
|
||||||
|
|
||||||
|
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||||
|
const firstId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||||
|
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||||
|
const secondId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||||
|
internal.packagePostProcessVersions.set("disk-identity-package", 1);
|
||||||
|
expect(internal.schedulePackageDiskRetry("disk-identity-package", request)).toBe(false);
|
||||||
|
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||||
|
internal.packagePostProcessVersions.set("disk-identity-package", 0);
|
||||||
|
internal.executePackageDiskRetry("disk-identity-package", firstId);
|
||||||
|
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const getRunOwner = internal.getPackageResultRunOwner.bind(internal);
|
||||||
|
internal.getPackageResultRunOwner = () => "new-owner";
|
||||||
|
internal.executePackageDiskRetry("disk-identity-package", secondId);
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
internal.getPackageResultRunOwner = getRunOwner;
|
||||||
|
|
||||||
|
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||||
|
const thirdId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||||
|
internal.session.packages["disk-identity-package"].resultGeneration += 1;
|
||||||
|
internal.executePackageDiskRetry("disk-identity-package", thirdId);
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||||
|
internal.healthManualStop = false;
|
||||||
|
manager.prepareForShutdown();
|
||||||
|
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a paused run-owned disk retry when re-enable has no matching owner", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-owner-rebind-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-owner-package", itemId: "disk-owner-item", fileName: "episode.zip" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const rerun = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = rerun;
|
||||||
|
internal.session.running = true;
|
||||||
|
internal.runScopeKind = "selected";
|
||||||
|
internal.runPackageIds.add("disk-owner-package");
|
||||||
|
const context = internal.beginActiveRunContext(new Set(["disk-owner-package"]), Date.now());
|
||||||
|
expect(internal.schedulePackageDiskRetry("disk-owner-package", {
|
||||||
|
retryAt: Date.now() + 1_000,
|
||||||
|
manualRequested: true,
|
||||||
|
postProcessVersion: 0,
|
||||||
|
runOwnerId: context.id
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
manager.togglePackage("disk-owner-package");
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(true);
|
||||||
|
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||||
|
manager.togglePackage("disk-owner-package");
|
||||||
|
|
||||||
|
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(false);
|
||||||
|
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||||
|
expect(internal.packageDiskRetryAfterByPackage.has("disk-owner-package")).toBe(false);
|
||||||
|
expect(internal.getActivePostProcessingCount()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a due disk retry when stop wins the race", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-stop-race-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "disk-stop-package", itemId: "disk-stop-item", fileName: "episode.zip" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const rerun = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = rerun;
|
||||||
|
internal.session.running = true;
|
||||||
|
internal.runScopeKind = "selected";
|
||||||
|
internal.runPackageIds.add("disk-stop-package");
|
||||||
|
internal.runItemIds.add("disk-stop-item");
|
||||||
|
expect(internal.schedulePackageDiskRetry("disk-stop-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null })).toBe(true);
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
|
||||||
|
expect(rerun).not.toHaveBeenCalled();
|
||||||
|
expect(internal.packageDiskRetryPlans.size).toBe(0);
|
||||||
|
expect(internal.packageDiskRetryTimers.size).toBe(0);
|
||||||
|
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||||
|
expect(session.running).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes separate manual and partial flags to deferred extraction", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-scope-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-cleanup-package", itemId: "manual-cleanup-item", fileName: "episode.rar" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const archivePath = session.items["manual-cleanup-item"].targetPath;
|
||||||
|
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||||
|
internal.runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
|
||||||
|
const deferred = vi.fn(async () => {});
|
||||||
|
internal.runDeferredPostExtraction = deferred;
|
||||||
|
internal.manualExtractPackages.add("manual-cleanup-package");
|
||||||
|
|
||||||
|
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||||
|
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, false]);
|
||||||
|
|
||||||
|
session.packages["manual-cleanup-package"].status = "queued";
|
||||||
|
session.items["manual-cleanup-item"].fullStatus = "Entpacken - Ausstehend";
|
||||||
|
internal.manualExtractArchiveFilters.set("manual-cleanup-package", new Set([path.resolve(archivePath).toLowerCase()]));
|
||||||
|
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||||
|
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans full manual package archives but preserves partial manual state", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-behavior-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [
|
||||||
|
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-a", fileName: "episode-a.rar" },
|
||||||
|
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-b", fileName: "episode-b.rar" }
|
||||||
|
], { cleanupMode: "delete" });
|
||||||
|
const internal = manager as any;
|
||||||
|
const firstPath = session.items["cleanup-a"].targetPath;
|
||||||
|
const secondPath = session.items["cleanup-b"].targetPath;
|
||||||
|
|
||||||
|
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, true);
|
||||||
|
expect(fs.existsSync(firstPath)).toBe(true);
|
||||||
|
expect(fs.existsSync(secondPath)).toBe(true);
|
||||||
|
|
||||||
|
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, false);
|
||||||
|
expect(fs.existsSync(firstPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(secondPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects package extraction when no archive candidate exists", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-no-archive-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager } = createCompletedFileManager(root, [{ packageId: "no-archive-package", itemId: "no-archive-item", fileName: "episode.mkv" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const postProcess = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = postProcess;
|
||||||
|
|
||||||
|
await expect(manager.extractNow("no-archive-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||||
|
expect(manager.getSnapshot().session.items["no-archive-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||||
|
expect(manager.getSnapshot().session.packages["no-archive-package"].status).toBe("completed");
|
||||||
|
expect(internal.manualExtractPackages.has("no-archive-package")).toBe(false);
|
||||||
|
expect(postProcess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects retryExtraction when the failed package has no complete archive", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-no-archive-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "retry-no-archive", itemId: "retry-no-archive-item", fileName: "episode.mkv" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
session.items["retry-no-archive-item"].fullStatus = "Entpack-Fehler: vorheriger Fehler";
|
||||||
|
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await expect(manager.retryExtraction("retry-no-archive")).rejects.toThrow(/Kein .*entpackbarer Archivsatz ausgewählt/);
|
||||||
|
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||||
|
expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes and arms an opaque archive file by signature", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||||
|
const content = zip.toBuffer();
|
||||||
|
const { manager } = createCompletedFileManager(root, [{ packageId: "opaque-package", itemId: "opaque-item", fileName: "download.bin", content }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
|
||||||
|
await manager.extractNow("opaque-package");
|
||||||
|
const task = internal.packagePostProcessTasks.get("opaque-package");
|
||||||
|
expect(task).toBeDefined();
|
||||||
|
await task;
|
||||||
|
|
||||||
|
expect(manager.getSnapshot().session.items["opaque-item"].fileName).toMatch(/\.zip$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a uniquely resolvable pathless legacy item through the public API", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-pathless-public-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "pathless-public", itemId: "pathless-public-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const archivePath = session.items["pathless-public-item"].targetPath;
|
||||||
|
internal.releaseTargetPath("pathless-public-item");
|
||||||
|
session.items["pathless-public-item"].targetPath = "";
|
||||||
|
const postProcess = vi.fn(async () => {});
|
||||||
|
internal.runPackagePostProcessing = postProcess;
|
||||||
|
|
||||||
|
await manager.extractNow("pathless-public");
|
||||||
|
|
||||||
|
expect(session.items["pathless-public-item"].targetPath).toBe(archivePath);
|
||||||
|
expect(session.items["pathless-public-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||||
|
expect(postProcess).toHaveBeenCalledWith("pathless-public");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an incomplete multipart package before starting extraction", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-incomplete-multipart-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [{ packageId: "incomplete-package", itemId: "incomplete-part1", fileName: "show.part1.rar" }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
const part2Id = "incomplete-part2";
|
||||||
|
session.packages["incomplete-package"].itemIds.push(part2Id);
|
||||||
|
session.items[part2Id] = {
|
||||||
|
...session.items["incomplete-part1"],
|
||||||
|
id: part2Id,
|
||||||
|
status: "queued",
|
||||||
|
fileName: "show.part2.rar",
|
||||||
|
targetPath: "",
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
fullStatus: "Wartet"
|
||||||
|
};
|
||||||
|
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await expect(manager.extractNow("incomplete-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||||
|
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a mixed extraction batch before starting any package", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||||
|
const { manager } = createCompletedFileManager(root, [
|
||||||
|
{ packageId: "valid-package", itemId: "valid-item", fileName: "episode.zip", content: zip.toBuffer() },
|
||||||
|
{ packageId: "invalid-package", itemId: "invalid-item", fileName: "episode.mkv" }
|
||||||
|
]);
|
||||||
|
const internal = manager as any;
|
||||||
|
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i);
|
||||||
|
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package");
|
||||||
|
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps opaque archive files untouched when batch preflight rejects another target", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||||
|
const { manager, session } = createCompletedFileManager(root, [
|
||||||
|
{ packageId: "atomic-opaque", itemId: "atomic-opaque-item", fileName: "download.bin", content: zip.toBuffer() },
|
||||||
|
{ packageId: "atomic-invalid", itemId: "atomic-invalid-item", fileName: "episode.mkv" }
|
||||||
|
]);
|
||||||
|
const internal = manager as any;
|
||||||
|
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||||
|
const opaquePath = session.items["atomic-opaque-item"].targetPath;
|
||||||
|
|
||||||
|
await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i);
|
||||||
|
|
||||||
|
expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin");
|
||||||
|
expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath);
|
||||||
|
expect(fs.existsSync(opaquePath)).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false);
|
||||||
|
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes and drains stop for standalone manual extraction without starting a download run", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-standalone-stop-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||||
|
const { manager } = createCompletedFileManager(root, [{ packageId: "standalone-package", itemId: "standalone-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||||
|
const internal = manager as any;
|
||||||
|
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||||
|
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.extractNow("standalone-package");
|
||||||
|
await waitFor(() => internal.packagePostProcessTasks.size === 1, 2_000);
|
||||||
|
expect(manager.getSnapshot().canStop).toBe(true);
|
||||||
|
expect(manager.getSnapshot().session.running).toBe(false);
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
await waitFor(() => internal.packagePostProcessTasks.size === 0, 2_000);
|
||||||
|
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||||
|
expect(manager.getSnapshot().session.running).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2187,6 +2187,7 @@ describe("download table row contracts", () => {
|
|||||||
it("keeps the complete package status and audio details in the tooltip", () => {
|
it("keeps the complete package status and audio details in the tooltip", () => {
|
||||||
const audioPackage = {
|
const audioPackage = {
|
||||||
...pkg("audio-package", "Audio package", ["audio-item"]),
|
...pkg("audio-package", "Audio package", ["audio-item"]),
|
||||||
|
status: "extracting" as const,
|
||||||
postProcessLabel: "Entpacken 1%",
|
postProcessLabel: "Entpacken 1%",
|
||||||
audioStripSummary: {
|
audioStripSummary: {
|
||||||
at: now,
|
at: now,
|
||||||
@@ -2231,6 +2232,62 @@ describe("download table row contracts", () => {
|
|||||||
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows active extraction progress while retaining sibling extraction errors in the tooltip", () => {
|
||||||
|
const activePackage = {
|
||||||
|
...pkg("active-extraction-package", "Active extraction", ["failed-archive", "active-archive"]),
|
||||||
|
status: "extracting" as const,
|
||||||
|
postProcessLabel: "Entpacken 42% (1/1) · active.part01.rar"
|
||||||
|
};
|
||||||
|
const failedArchive = item("failed-archive", activePackage.id, "completed", {
|
||||||
|
fullStatus: "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"
|
||||||
|
});
|
||||||
|
const activeArchive = item("active-archive", activePackage.id, "completed", {
|
||||||
|
fullStatus: "Entpacken 42% · active.part01.rar"
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "220px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: activePackage, items: [failedArchive, activeArchive], allItems: [failedArchive, activeArchive], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html.match(/>Entpacken - 42%<\/span>/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain("1 Entpackfehler");
|
||||||
|
expect(html).toContain("Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not present a persisted extraction label as active progress after restart", () => {
|
||||||
|
const restoredItem = item("restored-archive", "restored-package", "completed", {
|
||||||
|
fullStatus: "Entpacken - Ausstehend"
|
||||||
|
});
|
||||||
|
const restoredPackage = {
|
||||||
|
...pkg("restored-package", "Restored extraction", [restoredItem.id]),
|
||||||
|
status: "queued" as const,
|
||||||
|
postProcessLabel: "Entpacken 100% (1/1) · release.part01.rar"
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "220px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: restoredPackage, items: [restoredItem], allItems: [restoredItem], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html.match(/>Entpacken - Ausstehend<\/span>/g)).toHaveLength(2);
|
||||||
|
expect(html).not.toContain(">Entpacken - 100%</span>");
|
||||||
|
expect(html).not.toContain("Entpacken 100%");
|
||||||
|
expect(html).not.toContain("release.part01.rar");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes redundant service suffixes from runtime statuses", () => {
|
it("removes redundant service suffixes from runtime statuses", () => {
|
||||||
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
||||||
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { IpcMainInvokeEvent } from "electron";
|
||||||
|
import { IPC_CHANNELS } from "../src/shared/ipc";
|
||||||
|
import type { ElectronApi } from "../src/shared/preload-api";
|
||||||
|
|
||||||
|
const electron = vi.hoisted(() => ({
|
||||||
|
api: undefined as ElectronApi | undefined,
|
||||||
|
ipcHandlers: new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>(),
|
||||||
|
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||||
|
appHandlers: new Map<string, (...args: unknown[]) => void>(),
|
||||||
|
app: {
|
||||||
|
isPackaged: false,
|
||||||
|
getPath: vi.fn(() => "C:\\MDD\\Test"),
|
||||||
|
getAppPath: vi.fn(() => "C:\\MDD\\App"),
|
||||||
|
requestSingleInstanceLock: vi.fn(() => true),
|
||||||
|
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
electron.appHandlers.set(name, handler);
|
||||||
|
}),
|
||||||
|
whenReady: vi.fn(() => new Promise<void>(() => {})),
|
||||||
|
quit: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
setPath: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: electron.app,
|
||||||
|
BrowserWindow: class {
|
||||||
|
public static getAllWindows(): unknown[] { return []; }
|
||||||
|
},
|
||||||
|
clipboard: { readText: vi.fn(() => ""), writeText: vi.fn() },
|
||||||
|
contextBridge: {
|
||||||
|
exposeInMainWorld: (_name: string, api: ElectronApi) => {
|
||||||
|
electron.api = api;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dialog: {},
|
||||||
|
ipcMain: {
|
||||||
|
handle: vi.fn((channel: string, handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown) => {
|
||||||
|
electron.ipcHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
on: vi.fn()
|
||||||
|
},
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke: electron.invoke,
|
||||||
|
on: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
send: vi.fn()
|
||||||
|
},
|
||||||
|
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||||
|
nativeTheme: { themeSource: "system" },
|
||||||
|
powerMonitor: { on: vi.fn(), removeListener: vi.fn() },
|
||||||
|
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||||
|
shell: {},
|
||||||
|
Tray: class {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AppController } from "../src/main/app-controller";
|
||||||
|
import { registerExtractionIpcHandlers } from "../src/main/extraction-ipc";
|
||||||
|
|
||||||
|
function trustedEvent(): IpcMainInvokeEvent {
|
||||||
|
return {} as IpcMainInvokeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerHandlers(target: Parameters<typeof registerExtractionIpcHandlers>[1]): void {
|
||||||
|
registerExtractionIpcHandlers((channel, handler) => {
|
||||||
|
electron.ipcHandlers.set(channel, handler);
|
||||||
|
}, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createController(manager: {
|
||||||
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
|
extractNow: (request: { packageIds: string[]; itemIds: string[] }) => Promise<void>;
|
||||||
|
}): AppController {
|
||||||
|
const controller = Object.create(AppController.prototype) as {
|
||||||
|
manager: typeof manager;
|
||||||
|
audit: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
controller.manager = manager;
|
||||||
|
controller.audit = vi.fn();
|
||||||
|
return controller as unknown as AppController;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("manual extraction error propagation", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import("../src/preload/preload");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
electron.ipcHandlers.clear();
|
||||||
|
electron.invoke.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["stale", "Paket existiert nicht mehr"],
|
||||||
|
["deleted", "Ausgewählte Datei wurde gelöscht"],
|
||||||
|
["non-extractable", "Kein vollständiger entpackbarer Archivsatz ausgewählt"]
|
||||||
|
])("keeps a %s manager rejection intact through AppController", async (_caseName, message) => {
|
||||||
|
const controller = createController({
|
||||||
|
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||||
|
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.retryExtraction("package-id")).rejects.toThrow(message);
|
||||||
|
await expect(controller.extractNow({ packageIds: [], itemIds: ["item-id"] })).rejects.toThrow(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty extract-now request at the trusted main-process IPC boundary", async () => {
|
||||||
|
const controller = {
|
||||||
|
retryExtraction: vi.fn(async () => undefined),
|
||||||
|
extractNow: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
registerHandlers(controller);
|
||||||
|
const handler = electron.ipcHandlers.get(IPC_CHANNELS.EXTRACT_NOW);
|
||||||
|
|
||||||
|
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), { packageIds: [], itemIds: [] })))
|
||||||
|
.rejects.toThrow("extractNow benötigt mindestens ein Ziel");
|
||||||
|
expect(controller.extractNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["", "packageId muss ein nicht-leerer String sein"],
|
||||||
|
[" ", "packageId muss ein nicht-leerer String sein"],
|
||||||
|
["p".repeat(257), "packageId darf höchstens 256 Zeichen lang sein"]
|
||||||
|
])("rejects an invalid retry package ID before calling the controller", async (packageId, message) => {
|
||||||
|
const controller = {
|
||||||
|
retryExtraction: vi.fn(async () => undefined),
|
||||||
|
extractNow: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
registerHandlers(controller);
|
||||||
|
const handler = electron.ipcHandlers.get(IPC_CHANNELS.RETRY_EXTRACTION);
|
||||||
|
|
||||||
|
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), packageId))).rejects.toThrow(message);
|
||||||
|
expect(controller.retryExtraction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[IPC_CHANNELS.RETRY_EXTRACTION, "Paket existiert nicht mehr", ["stale-package"]],
|
||||||
|
[IPC_CHANNELS.EXTRACT_NOW, "Ausgewählte Datei wurde gelöscht", [{ packageIds: [], itemIds: ["deleted-item"] }]],
|
||||||
|
[IPC_CHANNELS.EXTRACT_NOW, "Kein vollständiger entpackbarer Archivsatz ausgewählt", [{ packageIds: [], itemIds: ["plain-file"] }]]
|
||||||
|
])("returns the controller rejection from %s to ipcRenderer.invoke", async (channel, message, args) => {
|
||||||
|
const controller = {
|
||||||
|
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||||
|
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||||
|
};
|
||||||
|
registerHandlers(controller);
|
||||||
|
const handler = electron.ipcHandlers.get(channel);
|
||||||
|
|
||||||
|
await expect(Promise.resolve(handler?.(trustedEvent(), ...args))).rejects.toThrow(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes main-process extraction rejections unchanged to the renderer API", async () => {
|
||||||
|
electron.invoke
|
||||||
|
.mockRejectedValueOnce(new Error("Paket existiert nicht mehr"))
|
||||||
|
.mockRejectedValueOnce(new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"));
|
||||||
|
|
||||||
|
await expect(electron.api?.retryExtraction("stale-package")).rejects.toThrow("Paket existiert nicht mehr");
|
||||||
|
await expect(electron.api?.extractNow({ packageIds: [], itemIds: ["plain-file"] }))
|
||||||
|
.rejects.toThrow("Kein vollständiger entpackbarer Archivsatz ausgewählt");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { extractPackageArchives, shutdownDaemon, type ExtractArchiveFailureInfo } from "../src/main/extractor";
|
||||||
|
|
||||||
|
const hasJdk = spawnSync("javac", ["-version"], { stdio: "ignore" }).status === 0;
|
||||||
|
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||||
|
const originalJava = process.env.RD_JAVA_BIN;
|
||||||
|
const originalJvmRoot = process.env.RD_EXTRACTOR_JVM_DIR;
|
||||||
|
const originalFakeMode = process.env.RD_FAKE_JVM_MODE;
|
||||||
|
const originalFakeSecret = process.env.RD_FAKE_JVM_SECRET;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
let runtimeRoot = "";
|
||||||
|
|
||||||
|
function restoreEnv(name: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
} else {
|
||||||
|
process.env[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArchiveFixture(prefix: string): { packageDir: string; targetDir: string } {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c0004", "hex"));
|
||||||
|
return { packageDir, targetDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractionOptions(fixture: ReturnType<typeof createArchiveFixture>) {
|
||||||
|
return {
|
||||||
|
...fixture,
|
||||||
|
cleanupMode: "none" as const,
|
||||||
|
conflictMode: "skip" as const,
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
passwordList: "candidate-one\ncandidate-two"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasJdk).sequential("JVM protocol integration", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-protocol-runtime-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
runtimeRoot = path.join(root, "extractor-jvm");
|
||||||
|
const sourceDir = path.join(root, "source", "com", "sucukdeluxe", "extractor");
|
||||||
|
const classesDir = path.join(runtimeRoot, "classes");
|
||||||
|
const libDir = path.join(runtimeRoot, "lib");
|
||||||
|
fs.mkdirSync(sourceDir, { recursive: true });
|
||||||
|
fs.mkdirSync(classesDir, { recursive: true });
|
||||||
|
fs.mkdirSync(libDir, { recursive: true });
|
||||||
|
const javaSource = `package com.sucukdeluxe.extractor;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
public final class JBindExtractorMain {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String mode = System.getenv("RD_FAKE_JVM_MODE");
|
||||||
|
String secret = System.getenv("RD_FAKE_JVM_SECRET");
|
||||||
|
boolean daemon = args.length == 1 && "--daemon".equals(args[0]);
|
||||||
|
if (daemon && mode.startsWith("oneshot")) return;
|
||||||
|
if (daemon) {
|
||||||
|
System.out.println("RD_DAEMON_READY");
|
||||||
|
System.out.flush();
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||||
|
while (reader.readLine() != null) {
|
||||||
|
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||||
|
System.out.println("RD_BACKEND fake-daemon");
|
||||||
|
if ("daemon-success".equals(mode)) {
|
||||||
|
System.out.println("RD_DONE");
|
||||||
|
System.out.println("RD_REQUEST_DONE 0");
|
||||||
|
System.out.flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
System.out.print("RD_PASS");
|
||||||
|
System.out.flush();
|
||||||
|
Thread.sleep(25L);
|
||||||
|
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
System.out.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||||
|
System.out.println("RD_BACKEND fake-oneshot");
|
||||||
|
if ("oneshot-success".equals(mode)) {
|
||||||
|
System.out.println("RD_DONE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
System.out.print("RD_PASS");
|
||||||
|
System.out.flush();
|
||||||
|
Thread.sleep(25L);
|
||||||
|
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
System.out.flush();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const sourcePath = path.join(sourceDir, "JBindExtractorMain.java");
|
||||||
|
fs.writeFileSync(sourcePath, javaSource, "utf8");
|
||||||
|
const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-d", classesDir, sourcePath], { encoding: "utf8" });
|
||||||
|
expect(compiled.status, `${compiled.stdout}\n${compiled.stderr}`).toBe(0);
|
||||||
|
const sourceLibDir = path.join(process.cwd(), "resources", "extractor-jvm", "lib");
|
||||||
|
for (const name of ["sevenzipjbinding.jar", "sevenzipjbinding-all-platforms.jar", "zip4j.jar"]) {
|
||||||
|
fs.copyFileSync(path.join(sourceLibDir, name), path.join(libDir, name));
|
||||||
|
}
|
||||||
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||||
|
process.env.RD_JAVA_BIN = "java";
|
||||||
|
process.env.RD_EXTRACTOR_JVM_DIR = runtimeRoot;
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shutdownDaemon();
|
||||||
|
delete process.env.RD_FAKE_JVM_MODE;
|
||||||
|
delete process.env.RD_FAKE_JVM_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
shutdownDaemon();
|
||||||
|
for (const directory of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
restoreEnv("RD_EXTRACT_BACKEND", originalBackend);
|
||||||
|
restoreEnv("RD_JAVA_BIN", originalJava);
|
||||||
|
restoreEnv("RD_EXTRACTOR_JVM_DIR", originalJvmRoot);
|
||||||
|
restoreEnv("RD_FAKE_JVM_MODE", originalFakeMode);
|
||||||
|
restoreEnv("RD_FAKE_JVM_SECRET", originalFakeSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transports daemon attempts and isolates throwing log observers", async () => {
|
||||||
|
process.env.RD_FAKE_JVM_MODE = "daemon-success";
|
||||||
|
process.env.RD_FAKE_JVM_SECRET = "unused-daemon-secret";
|
||||||
|
const firstFixture = createArchiveFixture("rd-jvm-daemon-callback-");
|
||||||
|
const first = await extractPackageArchives({
|
||||||
|
...extractionOptions(firstFixture),
|
||||||
|
onLog: (_level, message) => {
|
||||||
|
if (message.startsWith("Passwort-Versuch ")) {
|
||||||
|
throw new Error("ui callback failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toEqual(expect.objectContaining({ extracted: 1, failed: 0, lastError: "" }));
|
||||||
|
|
||||||
|
const secondFixture = createArchiveFixture("rd-jvm-daemon-recovery-");
|
||||||
|
const logs: string[] = [];
|
||||||
|
const second = await extractPackageArchives({
|
||||||
|
...extractionOptions(secondFixture),
|
||||||
|
onLog: (_level, message) => logs.push(message)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
||||||
|
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("redacts a one-shot password payload when the JVM exits before an error event", async () => {
|
||||||
|
const secret = "one-shot-runtime-secret";
|
||||||
|
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||||
|
process.env.RD_FAKE_JVM_MODE = "oneshot-crash";
|
||||||
|
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||||
|
const fixture = createArchiveFixture("rd-jvm-oneshot-redaction-");
|
||||||
|
const failures: ExtractArchiveFailureInfo[] = [];
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
...extractionOptions(fixture),
|
||||||
|
onArchiveFailure: (failure) => failures.push(failure),
|
||||||
|
onLog: (_level, message) => logs.push(message)
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||||
|
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||||
|
expect(diagnosticText).not.toContain(secret);
|
||||||
|
expect(diagnosticText).not.toContain(encodedSecret);
|
||||||
|
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("redacts a daemon password payload when the JVM exits before request completion", async () => {
|
||||||
|
const secret = "daemon-runtime-secret";
|
||||||
|
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||||
|
process.env.RD_FAKE_JVM_MODE = "daemon-crash";
|
||||||
|
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||||
|
const fixture = createArchiveFixture("rd-jvm-daemon-redaction-");
|
||||||
|
const failures: ExtractArchiveFailureInfo[] = [];
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
...extractionOptions(fixture),
|
||||||
|
onArchiveFailure: (failure) => failures.push(failure),
|
||||||
|
onLog: (_level, message) => logs.push(message)
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||||
|
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||||
|
expect(diagnosticText).not.toContain(secret);
|
||||||
|
expect(diagnosticText).not.toContain(encodedSecret);
|
||||||
|
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
+762
-10
@@ -1,15 +1,21 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { extractPackageArchives } from "../src/main/extractor";
|
import { extractPackageArchives, shutdownDaemon } from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||||
|
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||||
const require = createRequire(import.meta.url);
|
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 };
|
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", "sevenzipjbinding-all-platforms.jar"),
|
||||||
path.join(root, "lib", "zip4j.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 {
|
function corruptFirstZipPayload(zipPath: string): void {
|
||||||
@@ -111,19 +195,687 @@ function corruptFirstZipPayload(zipPath: string): void {
|
|||||||
fs.writeFileSync(zipPath, bytes);
|
fs.writeFileSync(zipPath, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
shutdownDaemon();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
if (originalBackend === undefined) {
|
if (originalBackend === undefined) {
|
||||||
delete process.env.RD_EXTRACT_BACKEND;
|
delete process.env.RD_EXTRACT_BACKEND;
|
||||||
} else {
|
} else {
|
||||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||||
}
|
}
|
||||||
});
|
if (originalArchivePasswords === undefined) {
|
||||||
|
delete process.env.RD_ARCHIVE_PASSWORDS;
|
||||||
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
} else {
|
||||||
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
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";
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||||
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
||||||
|
|||||||
+326
-28
@@ -1,4 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -19,6 +21,10 @@ import {
|
|||||||
shouldSerialRetryParallelFailures,
|
shouldSerialRetryParallelFailures,
|
||||||
findArchiveCandidates,
|
findArchiveCandidates,
|
||||||
orderExtractorCandidatesForArchive,
|
orderExtractorCandidatesForArchive,
|
||||||
|
extractorCommandsShareIdentity,
|
||||||
|
parseJvmPasswordAttemptLine,
|
||||||
|
redactJvmDiagnosticLine,
|
||||||
|
summarizeJvmPasswordAttempts,
|
||||||
parseNativeExtractOutput,
|
parseNativeExtractOutput,
|
||||||
parseNativeArchiveEntryList,
|
parseNativeArchiveEntryList,
|
||||||
remapNativeSubstOutput,
|
remapNativeSubstOutput,
|
||||||
@@ -26,14 +32,44 @@ import {
|
|||||||
resolveExtractorBackendModeForArchive,
|
resolveExtractorBackendModeForArchive,
|
||||||
resolveExtractorBackendMode,
|
resolveExtractorBackendMode,
|
||||||
shouldFallbackLegacyRarToJvm,
|
shouldFallbackLegacyRarToJvm,
|
||||||
|
shouldRunAlternativeNativeExtractor,
|
||||||
|
shouldSuggestRedownloadAfterCrossBackendFailure,
|
||||||
validateNativeArchiveEntryCandidates,
|
validateNativeArchiveEntryCandidates,
|
||||||
validateNativeFlatArchiveEntryCandidates,
|
validateNativeFlatArchiveEntryCandidates,
|
||||||
} from "../src/main/extractor";
|
} from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
||||||
|
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||||
const originalStatfs = fs.promises.statfs;
|
const originalStatfs = fs.promises.statfs;
|
||||||
const require = createRequire(import.meta.url);
|
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 };
|
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
|
||||||
|
|
||||||
@@ -110,16 +146,21 @@ afterEach(() => {
|
|||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
if (originalExtractBackend === undefined) {
|
if (originalExtractBackend === undefined) {
|
||||||
delete process.env.RD_EXTRACT_BACKEND;
|
delete process.env.RD_EXTRACT_BACKEND;
|
||||||
} else {
|
} else {
|
||||||
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
|
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;
|
(fs.promises as any).statfs = originalStatfs;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractor", () => {
|
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");
|
const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite");
|
||||||
expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]);
|
expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]);
|
||||||
expect(overwriteArgs).toContain("-idc");
|
expect(overwriteArgs).toContain("-idc");
|
||||||
@@ -148,8 +189,69 @@ describe("extractor", () => {
|
|||||||
const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org");
|
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.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]);
|
||||||
expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar");
|
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 () => {
|
it("deletes only successfully extracted archives", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
@@ -1092,15 +1194,24 @@ describe("extractor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifyExtractionError", () => {
|
describe("classifyExtractionError", () => {
|
||||||
it("classifies CRC errors", () => {
|
it("classifies CRC errors", () => {
|
||||||
expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error");
|
expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error");
|
||||||
expect(classifyExtractionError("Checksum error in data")).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", () => {
|
it("classifies wrong password", () => {
|
||||||
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
||||||
expect(classifyExtractionError("Falsches Passwort")).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", () => {
|
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");
|
expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns unknown for unrecognized errors", () => {
|
it("returns unknown for unrecognized errors", () => {
|
||||||
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps important tail markers when long extractor output is trimmed", () => {
|
it("keeps important tail markers when long extractor output is trimmed", () => {
|
||||||
const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`;
|
const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`;
|
||||||
@@ -1152,17 +1263,19 @@ describe("extractor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldSerialRetryParallelFailures", () => {
|
describe("shouldSerialRetryParallelFailures", () => {
|
||||||
it("keeps serial recovery enabled after mixed parallel results", () => {
|
it("retries unknown failures that can result from parallel contention", () => {
|
||||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
|
expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true);
|
||||||
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
|
expect(shouldSerialRetryParallelFailures(0, ["unknown", "unknown"])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only retries a total parallel wipe-out for contention-like failures", () => {
|
it("does not retry deterministic archive failures after another archive succeeded", () => {
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
|
expect(shouldSerialRetryParallelFailures(1, ["crc_error"])).toBe(false);
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
|
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(false);
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).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", () => {
|
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<string, number>();
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
scheduleArchive: async (archivePath, execute) => {
|
||||||
|
const archiveName = path.basename(archivePath);
|
||||||
|
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||||
|
return execute(new AbortController().signal);
|
||||||
|
},
|
||||||
|
onOutput: (event) => {
|
||||||
|
const archiveName = path.basename(event.archivePath);
|
||||||
|
if (event.state === "opened" && (archiveName !== "b.zip" || attempts.get(archiveName) === 1)) {
|
||||||
|
throw new Error(`transient-${archiveName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 2 }));
|
||||||
|
expect(Object.fromEntries(attempts)).toEqual({ "a.zip": 1, "b.zip": 2, "c.zip": 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(process.platform !== "win32" || !rarCliPath)("isolates throwing Legacy password-log callbacks without retrying archives", async () => {
|
||||||
|
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-callback-isolation-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
for (const name of ["a", "b"]) {
|
||||||
|
const inputPath = path.join(root, `${name}.txt`);
|
||||||
|
fs.writeFileSync(inputPath, `${name} payload`, "utf8");
|
||||||
|
expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, `${name}.rar`), inputPath]).status).toBe(0);
|
||||||
|
}
|
||||||
|
const attempts = new Map<string, number>();
|
||||||
|
const failures: ExtractArchiveFailureInfo[] = [];
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
scheduleArchive: async (archivePath, execute) => {
|
||||||
|
const archiveName = path.basename(archivePath);
|
||||||
|
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||||
|
return execute(new AbortController().signal);
|
||||||
|
},
|
||||||
|
onArchiveFailure: (failure) => failures.push(failure),
|
||||||
|
onLog: (_level, message) => {
|
||||||
|
if (message.startsWith("Passwort-Versuch ")) {
|
||||||
|
throw new Error("observer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 2, failed: 0 }));
|
||||||
|
expect(Object.fromEntries(attempts)).toEqual({ "a.rar": 1, "b.rar": 1 });
|
||||||
|
expect(failures).toHaveLength(0);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldSuggestRedownloadAfterCrossBackendFailure", () => {
|
||||||
|
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("reports redownload only after real Legacy and JVM CRC failures exhaust candidates", async () => {
|
||||||
|
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||||
|
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-exhausted-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = createEncryptedCorruptRarFixture(root, "serienjunkies.org", "cross-backend-exhausted");
|
||||||
|
const failures: ExtractArchiveFailureInfo[] = [];
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir: path.join(root, "out"),
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
passwordList: "",
|
||||||
|
onArchiveFailure: (failure) => failures.push(failure)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]).toEqual(expect.objectContaining({
|
||||||
|
category: "crc_error",
|
||||||
|
suggestRedownload: true
|
||||||
|
}));
|
||||||
|
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("keeps real Cross-Backend CRC recovery disabled when JVM stops before the final candidate", async () => {
|
||||||
|
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||||
|
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-not-exhausted-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const actualPassword = "cross-backend-early-secret";
|
||||||
|
const packageDir = createEncryptedCorruptRarFixture(root, actualPassword, "cross-backend-not-exhausted");
|
||||||
|
const failures: ExtractArchiveFailureInfo[] = [];
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir: path.join(root, "out"),
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
passwordList: actualPassword,
|
||||||
|
onArchiveFailure: (failure) => failures.push(failure)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]).toEqual(expect.objectContaining({
|
||||||
|
category: "crc_error",
|
||||||
|
suggestRedownload: false
|
||||||
|
}));
|
||||||
|
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("suggests recovery when both backends report CRC failure after every password candidate", () => {
|
||||||
|
expect(shouldSuggestRedownloadAfterCrossBackendFailure("crc_error", "crc_error", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["wrong_password", "crc_error", true],
|
||||||
|
["crc_error", "wrong_password", true],
|
||||||
|
["crc_error", "unsupported_format", true],
|
||||||
|
["crc_error", "crc_error", false]
|
||||||
|
] as const)("does not suggest recovery for legacy=%s jvm=%s exhausted=%s", (legacyCategory, jvmCategory, exhausted) => {
|
||||||
|
expect(shouldSuggestRedownloadAfterCrossBackendFailure(legacyCategory, jvmCategory, exhausted)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseJvmPasswordAttemptLine", () => {
|
||||||
|
it("accepts only bounded attempt metadata without a password field", () => {
|
||||||
|
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5")).toEqual({ attempt: 2, total: 5 });
|
||||||
|
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 0 5")).toBeNull();
|
||||||
|
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5 secret")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives exhaustion only from a valid final JVM attempt", () => {
|
||||||
|
expect(summarizeJvmPasswordAttempts(1, 3, false)).toEqual({ attempts: 1, total: 3, exhausted: false });
|
||||||
|
expect(summarizeJvmPasswordAttempts(3, 3, false)).toEqual({ attempts: 3, total: 3, exhausted: true });
|
||||||
|
expect(summarizeJvmPasswordAttempts(3, 3, true)).toEqual({ attempts: 3, total: 3, exhausted: false });
|
||||||
|
expect(summarizeJvmPasswordAttempts(4, 3, false)).toEqual({ attempts: 0, total: 0, exhausted: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts successful password payloads from JVM diagnostics", () => {
|
||||||
|
expect(redactJvmDiagnosticLine("RD_PASSWORD c2VjcmV0")).toBe("RD_PASSWORD <redacted>");
|
||||||
|
expect(redactJvmDiagnosticLine("RD_PASSWORD_ATTEMPT 2 5")).toBe("RD_PASSWORD_ATTEMPT 2 5");
|
||||||
|
expect(redactJvmDiagnosticLine("RD_ERROR CRCERROR")).toBe("RD_ERROR CRCERROR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("direct output scope", () => {
|
describe("direct output scope", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["overwrite", "new", "overwritten", ["episode.mkv"]],
|
["overwrite", "new", "overwritten", ["episode.mkv"]],
|
||||||
|
|||||||
@@ -49,6 +49,35 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText(english, "de")).toBe(german);
|
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", () => {
|
it("translates the complete history surface including status values", () => {
|
||||||
const translations = new Map([
|
const translations = new Map([
|
||||||
["Alle Einträge", "All entries"],
|
["Alle Einträge", "All entries"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DownloadManager } from "../src/main/download-manager";
|
import { DownloadManager } from "../src/main/download-manager";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
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";
|
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
const sessionRoots = new WeakMap<object, string>();
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -36,6 +38,7 @@ function setup(settings: Partial<AppSettings> = {}): {
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
|
sessionRoots.set(session, root);
|
||||||
const events: NotificationEvent[] = [];
|
const events: NotificationEvent[] = [];
|
||||||
const history: HistoryEntry[] = [];
|
const history: HistoryEntry[] = [];
|
||||||
const manager = new DownloadManager(
|
const manager = new DownloadManager(
|
||||||
@@ -70,11 +73,15 @@ function addPackage(
|
|||||||
packageId = "pkg-1"
|
packageId = "pkg-1"
|
||||||
): PackageEntry {
|
): PackageEntry {
|
||||||
const startedAt = Date.now() - 30_000;
|
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 = {
|
const pkg: PackageEntry = {
|
||||||
id: packageId,
|
id: packageId,
|
||||||
name: `Test ${packageId}`,
|
name: `Test ${packageId}`,
|
||||||
outputDir: `C:/out/${packageId}`,
|
outputDir,
|
||||||
extractDir: `C:/extract/${packageId}`,
|
extractDir,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
|
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
@@ -90,6 +97,14 @@ function addPackage(
|
|||||||
session.packageOrder.push(packageId);
|
session.packageOrder.push(packageId);
|
||||||
statuses.forEach((status, index) => {
|
statuses.forEach((status, index) => {
|
||||||
const itemId = `${packageId}-item-${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] = {
|
session.items[itemId] = {
|
||||||
id: itemId,
|
id: itemId,
|
||||||
packageId,
|
packageId,
|
||||||
@@ -98,11 +113,11 @@ function addPackage(
|
|||||||
status,
|
status,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
speedBps: 0,
|
speedBps: 0,
|
||||||
downloadedBytes: status === "completed" ? 1_000 : 0,
|
downloadedBytes,
|
||||||
totalBytes: 1_000,
|
totalBytes: status === "completed" ? downloadedBytes : 1_000,
|
||||||
progressPercent: status === "completed" ? 100 : 0,
|
progressPercent: status === "completed" ? 100 : 0,
|
||||||
fileName: `${packageId}-${index}.rar`,
|
fileName,
|
||||||
targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`,
|
targetPath,
|
||||||
resumable: true,
|
resumable: true,
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
lastError: status === "failed" ? "offline" : "",
|
lastError: status === "failed" ? "offline" : "",
|
||||||
@@ -249,7 +264,7 @@ describe("authoritative package completion", () => {
|
|||||||
|
|
||||||
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||||
manager.retryExtraction(pkg.id);
|
await manager.retryExtraction(pkg.id);
|
||||||
expect(postProcess).toHaveBeenCalledWith(pkg.id);
|
expect(postProcess).toHaveBeenCalledWith(pkg.id);
|
||||||
pkg.archiveOperations = [{
|
pkg.archiveOperations = [{
|
||||||
id: "archive-2",
|
id: "archive-2",
|
||||||
@@ -293,7 +308,7 @@ describe("authoritative package completion", () => {
|
|||||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||||
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||||
|
|
||||||
manager.retryExtraction(pkg.id);
|
await manager.retryExtraction(pkg.id);
|
||||||
expect(pkg.resultGeneration).toBe(8);
|
expect(pkg.resultGeneration).toBe(8);
|
||||||
|
|
||||||
pkg.archiveOperations = [{
|
pkg.archiveOperations = [{
|
||||||
@@ -897,7 +912,7 @@ describe("authoritative run completion", () => {
|
|||||||
postProcessGate = new Promise<void>((resolve) => {
|
postProcessGate = new Promise<void>((resolve) => {
|
||||||
releasePostProcess = resolve;
|
releasePostProcess = resolve;
|
||||||
});
|
});
|
||||||
manager.retryExtraction(pkg.id);
|
await manager.retryExtraction(pkg.id);
|
||||||
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||||
expect(retriedPostProcess).toBeDefined();
|
expect(retriedPostProcess).toBeDefined();
|
||||||
releasePostProcess();
|
releasePostProcess();
|
||||||
|
|||||||
@@ -94,6 +94,104 @@ describe("download package presentation", () => {
|
|||||||
expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar");
|
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", () => {
|
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
|
||||||
const items = [
|
const items = [
|
||||||
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
|
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
|
||||||
|
|||||||
@@ -58,14 +58,22 @@ async function createFixtureAsar(version: string): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
const validAppAsar = await createFixtureAsar("1.7.233");
|
const validAppAsar = await createFixtureAsar("1.7.233");
|
||||||
const staleAppAsar = await createFixtureAsar("1.7.232");
|
const staleAppAsar = await createFixtureAsar("1.7.232");
|
||||||
const redistributionFiles = [
|
const redistributionFiles = [
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||||
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.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 {
|
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||||
@@ -73,7 +81,7 @@ function writeFile(rootDir: string, relativePath: string, content: string | Buff
|
|||||||
fs.writeFileSync(filePath, content);
|
fs.writeFileSync(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||||
for (const relativePath of redistributionFiles) {
|
for (const relativePath of redistributionFiles) {
|
||||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||||
let targetPath: string = relativePath;
|
let targetPath: string = relativePath;
|
||||||
@@ -104,21 +112,44 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
|||||||
if (omittedName !== "app_icon.ico") {
|
if (omittedName !== "app_icon.ico") {
|
||||||
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
|
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 => {
|
return (command: string, args: string[]): CommandResult => {
|
||||||
const archivePath = args[1] || "";
|
const archivePath = args[1] || "";
|
||||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||||
if (!outputArg) {
|
if (!outputArg) {
|
||||||
return { status: 2, stderr: "missing output directory" };
|
return { status: 2, stderr: "missing output directory" };
|
||||||
}
|
}
|
||||||
const outputDir = outputArg.slice(2);
|
const outputDir = outputArg.slice(2);
|
||||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
currentArchiveName = path.basename(archivePath);
|
||||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||||
writeArchivePayload(outputDir, omittedName);
|
} 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: "" };
|
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,15 +170,18 @@ function createReleaseFixture(): string {
|
|||||||
owner: "Sucukdeluxe",
|
owner: "Sucukdeluxe",
|
||||||
repo: "Multi-Debrid-Downloader"
|
repo: "Multi-Debrid-Downloader"
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
"build/main/**/*",
|
"build/main/**/*",
|
||||||
"build/renderer/**/*",
|
"build/renderer/**/*",
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
extraResources: [
|
asarUnpack: [
|
||||||
|
"resources/extractor-jvm/**/*"
|
||||||
|
],
|
||||||
|
extraResources: [
|
||||||
{
|
{
|
||||||
from: "LICENSE",
|
from: "LICENSE",
|
||||||
to: "LICENSE"
|
to: "LICENSE"
|
||||||
@@ -189,6 +223,8 @@ function createReleaseFixture(): string {
|
|||||||
writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||||
writeRedistributionFiles(rootDir);
|
writeRedistributionFiles(rootDir);
|
||||||
writeRedistributionFiles(rootDir, true);
|
writeRedistributionFiles(rootDir, true);
|
||||||
|
writeJvmRuntimeFiles(rootDir);
|
||||||
|
writeJvmRuntimeFiles(rootDir, true);
|
||||||
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||||
writeFile(rootDir, "win-unpacked/resources/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);
|
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);
|
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 rootDir = createReleaseFixture();
|
||||||
const packagePath = path.join(rootDir, "package.json");
|
const packagePath = path.join(rootDir, "package.json");
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
||||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
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", () => {
|
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||||
const rootDir = createReleaseFixture();
|
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();
|
const rootDir = createReleaseFixture();
|
||||||
|
|
||||||
expect(() => verifyReleaseArchives(rootDir, {
|
expect(() => verifyReleaseArchives(rootDir, {
|
||||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||||
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
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", () => {
|
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||||
const rootDir = createReleaseFixture();
|
const rootDir = createReleaseFixture();
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns single archive item when no pattern matches", () => {
|
it("does not guess a different single archive when no pattern matches", () => {
|
||||||
const items = makeItems(["totally-different-name.rar"]);
|
const items = makeItems(["totally-different-name.rar"]);
|
||||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty when items have no archive extensions", () => {
|
it("returns empty when items have no archive extensions", () => {
|
||||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
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.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]);
|
||||||
expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]);
|
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", () => {
|
describe("markPlannedHybridArchiveItemsPending", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user