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:
+762
-10
@@ -1,15 +1,21 @@
|
||||
import fs from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { extractPackageArchives } from "../src/main/extractor";
|
||||
import { extractPackageArchives, shutdownDaemon } from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||
const require = createRequire(import.meta.url);
|
||||
const rarCliPath = [
|
||||
"C:\\Program Files\\WinRAR\\Rar.exe",
|
||||
"C:\\Program Files (x86)\\WinRAR\\Rar.exe"
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
|
||||
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
|
||||
|
||||
@@ -91,7 +97,85 @@ function hasJvmExtractorRuntime(): boolean {
|
||||
path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"),
|
||||
path.join(root, "lib", "zip4j.jar")
|
||||
];
|
||||
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
|
||||
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
|
||||
}
|
||||
|
||||
function hasCommand(command: string, args: string[]): boolean {
|
||||
return spawnSync(command, args, { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function compileJvmExtractorSource(root: string): string {
|
||||
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const classesDir = path.join(root, "classes");
|
||||
const libs = [
|
||||
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
||||
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
||||
path.join(runtimeRoot, "lib", "zip4j.jar")
|
||||
];
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
const result = spawnSync("javac", [
|
||||
"-source", "8",
|
||||
"-target", "8",
|
||||
"-encoding", "UTF-8",
|
||||
"-cp", libs.join(path.delimiter),
|
||||
"-d", classesDir,
|
||||
path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java")
|
||||
], { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(String(result.stderr || result.stdout || "javac failed"));
|
||||
}
|
||||
return [classesDir, ...libs].join(path.delimiter);
|
||||
}
|
||||
|
||||
function findZipCryptoVerifierCollision(root: string, archivePath: string): string {
|
||||
const sourcePath = path.join(root, "ZipCryptoCollisionFinder.java");
|
||||
const classesDir = path.join(root, "collision-finder-classes");
|
||||
const zip4jPath = path.join(process.cwd(), "resources", "extractor-jvm", "lib", "zip4j.jar");
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
fs.writeFileSync(sourcePath, `import java.io.InputStream;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
public final class ZipCryptoCollisionFinder {
|
||||
public static void main(String[] args) throws Exception {
|
||||
for (int index = 0; index < 8192; index++) {
|
||||
String candidate = "collision-candidate-" + index;
|
||||
ZipFile zipFile = new ZipFile(args[0]);
|
||||
zipFile.setPassword(candidate.toCharArray());
|
||||
int produced = 0;
|
||||
try {
|
||||
FileHeader header = zipFile.getFileHeaders().get(0);
|
||||
InputStream input = zipFile.getInputStream(header);
|
||||
try {
|
||||
byte[] buffer = new byte[8192];
|
||||
while (true) {
|
||||
int read = input.read(buffer);
|
||||
if (read < 0) break;
|
||||
produced += read;
|
||||
}
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
} catch (Exception error) {
|
||||
if (produced > 0) {
|
||||
System.out.println(candidate);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
zipFile.close();
|
||||
}
|
||||
}
|
||||
System.exit(2);
|
||||
}
|
||||
}`, "utf8");
|
||||
const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-cp", zip4jPath, "-d", classesDir, sourcePath], { encoding: "utf8" });
|
||||
if (compiled.status !== 0) {
|
||||
throw new Error(String(compiled.stderr || compiled.stdout || "collision finder compile failed"));
|
||||
}
|
||||
const run = spawnSync("java", ["-cp", [classesDir, zip4jPath].join(path.delimiter), "ZipCryptoCollisionFinder", archivePath], { encoding: "utf8", timeout: 20_000 });
|
||||
if (run.status !== 0) {
|
||||
throw new Error(String(run.stderr || run.stdout || "collision finder failed"));
|
||||
}
|
||||
return String(run.stdout || "").trim();
|
||||
}
|
||||
|
||||
function corruptFirstZipPayload(zipPath: string): void {
|
||||
@@ -111,19 +195,687 @@ function corruptFirstZipPayload(zipPath: string): void {
|
||||
fs.writeFileSync(zipPath, bytes);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(() => {
|
||||
shutdownDaemon();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.RD_EXTRACT_BACKEND;
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
||||
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||
}
|
||||
if (originalArchivePasswords === undefined) {
|
||||
delete process.env.RD_ARCHIVE_PASSWORDS;
|
||||
} else {
|
||||
process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords;
|
||||
}
|
||||
});
|
||||
|
||||
describe("JVM extractor build pipeline", () => {
|
||||
it("compiles the JVM runtime before main and release builds", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
||||
|
||||
expect(packageJson.scripts["build:extractor-jvm"]).toBe("node scripts/build-extractor-jvm.mjs");
|
||||
expect(packageJson.scripts["build:main"]).toMatch(/^npm run build:extractor-jvm && /);
|
||||
expect(packageJson.scripts.build).toContain("npm run build:main");
|
||||
expect(packageJson.scripts["release:win"]).toContain("npm run build");
|
||||
});
|
||||
|
||||
it("keeps packaged JVM classes current with Java 8 bytecode and the password attempt protocol", () => {
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
|
||||
const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
|
||||
expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0);
|
||||
const mainClass = fs.readFileSync(path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"));
|
||||
expect(mainClass.readUInt16BE(6)).toBe(52);
|
||||
expect(mainClass.includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true);
|
||||
expect(mainClass.includes(Buffer.from("RD_PASSWORD ", "utf8"))).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]))("builds current source and rejects stale classes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-build-"));
|
||||
tempDirs.push(root);
|
||||
const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const runtimeRoot = path.join(root, "extractor-jvm");
|
||||
fs.mkdirSync(runtimeRoot, { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true });
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
|
||||
const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0);
|
||||
const mainClass = path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||
expect(fs.readFileSync(mainClass).includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true);
|
||||
|
||||
const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0);
|
||||
|
||||
const javaSource = path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java");
|
||||
fs.appendFileSync(javaSource, "\n", "utf8");
|
||||
const stale = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" });
|
||||
expect(stale.status).toBe(1);
|
||||
expect(`${stale.stdout}\n${stale.stderr}`).toMatch(/veraltet|stale/i);
|
||||
}, 30_000);
|
||||
|
||||
it("fails clearly when no JDK compiler is available", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-no-jdk-"));
|
||||
tempDirs.push(root);
|
||||
const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const runtimeRoot = path.join(root, "extractor-jvm");
|
||||
fs.mkdirSync(runtimeRoot, { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true });
|
||||
fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true });
|
||||
const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs");
|
||||
const env = { ...process.env, JAVA_HOME: "", PATH: "" };
|
||||
|
||||
const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8", env });
|
||||
|
||||
expect(build.status).toBe(1);
|
||||
expect(`${build.stdout}\n${build.stderr}`).toMatch(/JDK.*javac|javac.*JDK/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("emits password attempt indices without exposing candidate values", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-password-attempt-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.txt");
|
||||
const archivePath = path.join(root, "protected.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-secret-value";
|
||||
const wrongPassword = "wrong-secret-value";
|
||||
fs.writeFileSync(inputPath, "protected payload", "utf8");
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
const attemptLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "));
|
||||
expect(attemptLines).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(attemptLines.every((line) => !line.includes(actualPassword) && !line.includes(wrongPassword))).toBe(true);
|
||||
expect(String(run.stdout)).not.toContain("RD_PASSWORD ");
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("protected payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("keeps encrypted Zip4j corruption distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-encrypted-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected-corrupt.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "zip4j-corrupt-secret";
|
||||
const sentinelPassword = "must-not-run-after-zip-crc";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
corruptFirstZipPayload(archivePath);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toMatch(/CRC|checksum/i);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("validates encrypted Zip4j entries before extracting plain entries", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-mixed-password-"));
|
||||
tempDirs.push(root);
|
||||
const plainInput = path.join(root, "a-plain.txt");
|
||||
const secretInput = path.join(root, "b-secret.txt");
|
||||
const archivePath = path.join(root, "mixed.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "zip4j-mixed-secret";
|
||||
fs.writeFileSync(plainInput, "plain payload", "utf8");
|
||||
fs.writeFileSync(secretInput, "secret payload", "utf8");
|
||||
expect(spawnSync("7z", ["a", "-tzip", archivePath, plainInput], { encoding: "utf8" }).status).toBe(0);
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, secretInput], { encoding: "utf8" }).status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 2",
|
||||
"RD_PASSWORD_ATTEMPT 2 2"
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "a-plain.txt"), "utf8")).toBe("plain payload");
|
||||
expect(fs.readFileSync(path.join(targetDir, "b-secret.txt"), "utf8")).toBe("secret payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("continues after a ZipCrypto verifier collision reaches CRC", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zipcrypto-collision-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "collision.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-password";
|
||||
const payload = randomBytes(4096);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync("7z", ["a", "-tzip", "-mx=0", "-mem=ZipCrypto", `-p${actualPassword}`, archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const collisionPassword = findZipCryptoVerifierCollision(root, archivePath);
|
||||
expect(collisionPassword).toMatch(/^collision-candidate-\d+$/);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
collisionPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.bin"))).toEqual(payload);
|
||||
|
||||
const failedTargetDir = path.join(root, "failed-out");
|
||||
const failedRun = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
failedTargetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"zip4j",
|
||||
"--password",
|
||||
collisionPassword,
|
||||
"--password",
|
||||
"wrong-after-collision"
|
||||
], { encoding: "utf8" });
|
||||
expect(failedRun.status).not.toBe(0);
|
||||
expect(String(failedRun.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(String(failedRun.stderr)).toContain("zip4j-Fehler: CRCERROR");
|
||||
expect(String(failedRun.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("parses daemon password candidates with JSON metacharacters and Unicode", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-daemon-json-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.txt");
|
||||
const archivePath = path.join(root, "protected.zip");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "actual-daemon-secret";
|
||||
const bracketPassword = "wrong]candidate";
|
||||
const escapedPassword = "päss\\\"\\漢字ß";
|
||||
fs.writeFileSync(inputPath, "daemon protected payload", "utf8");
|
||||
const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
const request = JSON.stringify({
|
||||
archive: archivePath,
|
||||
target: targetDir,
|
||||
conflict: "overwrite",
|
||||
backend: "zip4j",
|
||||
passwords: [bracketPassword, escapedPassword, actualPassword]
|
||||
});
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--daemon"
|
||||
], { encoding: "utf8", input: `${request}\n` });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout)).toContain("RD_REQUEST_DONE 0");
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 4",
|
||||
"RD_PASSWORD_ATTEMPT 2 4",
|
||||
"RD_PASSWORD_ATTEMPT 3 4",
|
||||
"RD_PASSWORD_ATTEMPT 4 4"
|
||||
]);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(bracketPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(escapedPassword);
|
||||
expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("daemon protected payload");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("tries the real RAR5 password after unreliable encrypted metadata", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-password-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-actual-secret";
|
||||
const wrongPassword = "rar5-wrong-secret";
|
||||
const payload = randomBytes(192 * 1024);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const firstPart = fs.readdirSync(root).find((name) => /^protected\.part0*1\.rar$/i.test(name));
|
||||
expect(firstPart).toBeTruthy();
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, firstPart!),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin"));
|
||||
expect(extractedPayload?.bytes).toBe(payload.toString("base64"));
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("continues after an explicit RAR5 WRONG_PASSWORD result", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-data-password-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "protected-data.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-data-actual";
|
||||
const wrongPassword = "rar5-data-wrong";
|
||||
const payload = randomBytes(192 * 1024);
|
||||
fs.writeFileSync(inputPath, payload);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-p${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const firstPart = fs.readdirSync(root).find((name) => /^protected-data\.part0*1\.rar$/i.test(name));
|
||||
expect(firstPart).toBeTruthy();
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, firstPart!),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
wrongPassword,
|
||||
"--password",
|
||||
actualPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3",
|
||||
"RD_PASSWORD_ATTEMPT 3 3"
|
||||
]);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword);
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(wrongPassword);
|
||||
const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin"));
|
||||
expect(extractedPayload?.bytes).toBe(payload.toString("base64"));
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps encrypted RAR5 corruption distinct from an exhausted password list", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-encrypted-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "encrypted-corrupt.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-corrupt-secret";
|
||||
const sentinelPassword = "must-not-be-attempted-after-crc";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^encrypted-corrupt\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const corruptPath = path.join(root, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toMatch(/RD_ERROR 7z-Fehler: (?:CRCERROR|DATAERROR)/);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
expect(String(run.stderr).toLowerCase()).not.toContain("wrong_password");
|
||||
expect(String(run.stderr).toLowerCase()).not.toContain("wrong password");
|
||||
expect(`${run.stdout}\n${run.stderr}`).not.toContain(sentinelPassword);
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an unencrypted RAR5 CRC failure terminal", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-crc-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "corrupt.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.writeFileSync(inputPath, randomBytes(192 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^corrupt\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const corruptPath = path.join(root, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
"unused-one",
|
||||
"--password",
|
||||
"unused-two"
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toContain("CRCERROR");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted missing RAR5 volume distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-missing-volume-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "missing-volume.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-missing-volume-secret";
|
||||
const sentinelPassword = "must-not-run-after-missing-volume";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^missing-volume\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
fs.unlinkSync(path.join(root, parts[1]));
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
path.join(root, parts[0]),
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stdout)).not.toContain("RD_OUTPUT ");
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
expect(String(run.stderr)).toMatch(/Missing volume|Volume fehlt/i);
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted locked RAR5 volume distinct from a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-locked-volume-"));
|
||||
tempDirs.push(root);
|
||||
const inputDir = path.join(root, "inputs");
|
||||
const archivePath = path.join(root, "locked.rar");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "rar5-locked-volume-secret";
|
||||
const sentinelPassword = "must-not-run-after-volume-io";
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
const inputPaths: string[] = [];
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const inputPath = path.join(inputDir, `payload-${index.toString().padStart(2, "0")}.bin`);
|
||||
fs.writeFileSync(inputPath, randomBytes(48 * 1024));
|
||||
inputPaths.push(inputPath);
|
||||
}
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, ...inputPaths], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(root).filter((name) => /^locked\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(4);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
const script = `$lock = [IO.File]::Open($env:LOCK_PATH, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
|
||||
try {
|
||||
& $env:JAVA_BIN '-cp' $env:CLASS_PATH 'com.sucukdeluxe.extractor.JBindExtractorMain' '--archive' $env:ARCHIVE_PATH '--target' $env:TARGET_PATH '--conflict' 'overwrite' '--backend' '7zjbinding' '--password' $env:ACTUAL_PASSWORD '--password' $env:SENTINEL_PASSWORD
|
||||
exit $LASTEXITCODE
|
||||
} finally {
|
||||
$lock.Dispose()
|
||||
}`;
|
||||
|
||||
const run = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
LOCK_PATH: path.join(root, parts[3]),
|
||||
JAVA_BIN: "java",
|
||||
CLASS_PATH: classPath,
|
||||
ARCHIVE_PATH: path.join(root, parts[0]),
|
||||
TARGET_PATH: targetDir,
|
||||
ACTUAL_PASSWORD: actualPassword,
|
||||
SENTINEL_PASSWORD: sentinelPassword
|
||||
}
|
||||
});
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3",
|
||||
"RD_PASSWORD_ATTEMPT 2 3"
|
||||
]);
|
||||
expect(String(run.stderr)).toContain("Volume konnte nicht geoffnet");
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("does not convert an encrypted 7z open failure into a wrong password", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-7z-open-failure-"));
|
||||
tempDirs.push(root);
|
||||
const inputPath = path.join(root, "payload.bin");
|
||||
const archivePath = path.join(root, "truncated.7z");
|
||||
const targetDir = path.join(root, "out");
|
||||
const actualPassword = "sevenzip-open-secret";
|
||||
const sentinelPassword = "must-not-run-after-open-failure";
|
||||
fs.writeFileSync(inputPath, randomBytes(256 * 1024));
|
||||
const created = spawnSync("7z", ["a", "-t7z", `-p${actualPassword}`, "-mhe=on", archivePath, inputPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const archiveSize = fs.statSync(archivePath).size;
|
||||
expect(archiveSize).toBeGreaterThan(256);
|
||||
fs.truncateSync(archivePath, archiveSize - 128);
|
||||
const classPath = compileJvmExtractorSource(root);
|
||||
|
||||
const run = spawnSync("java", [
|
||||
"-cp",
|
||||
classPath,
|
||||
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
||||
"--archive",
|
||||
archivePath,
|
||||
"--target",
|
||||
targetDir,
|
||||
"--conflict",
|
||||
"overwrite",
|
||||
"--backend",
|
||||
"7zjbinding",
|
||||
"--password",
|
||||
actualPassword,
|
||||
"--password",
|
||||
sentinelPassword
|
||||
], { encoding: "utf8" });
|
||||
|
||||
expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0);
|
||||
expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([
|
||||
"RD_PASSWORD_ATTEMPT 1 3"
|
||||
]);
|
||||
expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort");
|
||||
}, 20_000);
|
||||
|
||||
it.skipIf(!hasCommand("7z", ["i"]))("routes the emitted German archive-password failure through fallback and cache invalidation", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-german-password-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const firstInput = path.join(root, "first.txt");
|
||||
const secondInput = path.join(root, "second.txt");
|
||||
const learnedPassword = "learned-package-secret";
|
||||
const unavailablePassword = "unavailable-archive-secret";
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(firstInput, "first payload", "utf8");
|
||||
fs.writeFileSync(secondInput, "second payload", "utf8");
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${learnedPassword}`, "-mem=AES256", path.join(packageDir, "a-first.zip"), firstInput]).status).toBe(0);
|
||||
expect(spawnSync("7z", ["a", "-tzip", `-p${unavailablePassword}`, "-mem=AES256", path.join(packageDir, "b-second.zip"), secondInput]).status).toBe(0);
|
||||
const failures: import("../src/main/extractor").ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: learnedPassword,
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
archiveName: "b-second.zip",
|
||||
category: "wrong_password",
|
||||
suggestRedownload: false
|
||||
}));
|
||||
expect(String(failures[0]?.jvmFailureReason || "")).toContain("Falsches Archiv-Passwort");
|
||||
expect(logs.some((message) => message.includes("JVM-Extractor Fallback-Analyse:") && message.includes("wrongPassword=true"))).toBe(true);
|
||||
expect(logs.some((message) => message.startsWith("Legacy-Extractor Start: archive=b-second.zip"))).toBe(true);
|
||||
expect(logs.some((message) => message.includes("Passwort-Cache Update"))).toBe(true);
|
||||
expect(logs.some((message) => message.includes("Passwort-Cache verworfen"))).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
||||
|
||||
Reference in New Issue
Block a user