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:
+863
-20
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, findCrcImplicatedArchiveItems, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveSelectedArchiveSetsFromCandidates, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||
import { ExtractionCoordinator } from "../src/main/extraction-coordinator";
|
||||
@@ -113,6 +113,13 @@ describe("selected item run scope", () => {
|
||||
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
|
||||
|
||||
internal.session.items[itemIds[1]].status = "reconnect_wait";
|
||||
internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF";
|
||||
internal.retryAfterByItem.set(itemIds[1], 99_000);
|
||||
manager.stop();
|
||||
expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" }));
|
||||
expect(internal.retryAfterByItem.get(itemIds[1])).toBe(99_000);
|
||||
});
|
||||
|
||||
it("stops only selected run items without erasing sibling wait state", async () => {
|
||||
@@ -150,6 +157,114 @@ describe("selected item run scope", () => {
|
||||
expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true);
|
||||
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not widen an exhausted selected scope to an unselected sibling", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
internal.runItemIds.delete(itemIds[0]);
|
||||
internal.runPackageIds.clear();
|
||||
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false });
|
||||
|
||||
internal.session.items[itemIds[1]].status = "reconnect_wait";
|
||||
internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF";
|
||||
internal.retryAfterByItem.set(itemIds[1], Date.now() + 60_000);
|
||||
manager.stop();
|
||||
expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" }));
|
||||
expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true);
|
||||
});
|
||||
|
||||
it("resuming preserves item, disk and provider cooldown state", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
internal.session.paused = true;
|
||||
const now = Date.now();
|
||||
internal.retryAfterByItem.set(itemIds[0], now + 11_000);
|
||||
internal.providerStartReservations.set("provider-key", now + 12_000);
|
||||
internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000);
|
||||
internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 });
|
||||
|
||||
manager.togglePause();
|
||||
|
||||
expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000);
|
||||
expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000);
|
||||
expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000);
|
||||
expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000);
|
||||
expect(internal.findNextQueuedItem()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a newly added package outside a selected running scope", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-add-package-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
|
||||
manager.addPackages([{ name: "added-later", links: ["https://dummy/added-later"] }]);
|
||||
const addedPackageId = internal.session.packageOrder.at(-1);
|
||||
const addedItemId = internal.session.packages[addedPackageId].itemIds[0];
|
||||
|
||||
expect(internal.runPackageIds.has(addedPackageId)).toBe(false);
|
||||
expect(internal.runItemIds.has(addedItemId)).toBe(false);
|
||||
expect(internal.findNextQueuedItem()?.itemId).not.toBe(addedItemId);
|
||||
});
|
||||
|
||||
it("computes provider retry only from selected run items", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-provider-retry-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, itemIds } = createSelectedItemManager(root);
|
||||
const internal = manager as any;
|
||||
internal.ensureScheduler = async () => {};
|
||||
internal.triggerPendingExtractions = () => {};
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
const deadline = Date.now() + 30_000;
|
||||
internal.debridService.getBlockingProviderRetryAt = (url: string) => url.endsWith("/first") ? deadline : null;
|
||||
|
||||
expect(internal.getEarliestProviderRetryAt(Date.now())).toBe(deadline);
|
||||
});
|
||||
|
||||
it.each(["items", "packages"] as const)("does not trigger foreign post-processing from a selected %s start", async (mode) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-selected-postprocess-${mode}-`));
|
||||
tempDirs.push(root);
|
||||
const { manager, packageId, itemIds } = createSelectedItemManager(root);
|
||||
manager.addPackages([{ name: "foreign-completed", links: ["https://dummy/foreign.rar"] }]);
|
||||
const internal = manager as any;
|
||||
const foreignPackageId = internal.session.packageOrder.find((id: string) => id !== packageId);
|
||||
const foreignItemId = internal.session.packages[foreignPackageId].itemIds[0];
|
||||
internal.session.items[foreignItemId].status = "completed";
|
||||
internal.session.items[foreignItemId].downloadedBytes = 100;
|
||||
internal.session.items[foreignItemId].totalBytes = 100;
|
||||
internal.session.items[foreignItemId].progressPercent = 100;
|
||||
internal.session.items[foreignItemId].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.session.packages[foreignPackageId].status = "completed";
|
||||
internal.ensureScheduler = async () => {};
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
if (mode === "items") {
|
||||
await internal.startItemsNow([itemIds[0]]);
|
||||
} else {
|
||||
await internal.startPackagesNow([packageId]);
|
||||
}
|
||||
|
||||
expect(postProcess).not.toHaveBeenCalledWith(foreignPackageId);
|
||||
expect(internal.runPackageIds.has(foreignPackageId)).toBe(false);
|
||||
expect(internal.runItemIds.has(foreignItemId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download live update cadence", () => {
|
||||
@@ -7228,7 +7343,7 @@ describe("download manager", () => {
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
@@ -7324,9 +7439,9 @@ describe("download manager", () => {
|
||||
archiveName: "show.s01e01.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
@@ -7343,7 +7458,7 @@ describe("download manager", () => {
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => {
|
||||
it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -7418,21 +7533,41 @@ describe("download manager", () => {
|
||||
archiveName: "show.s01e01.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||
errorText: "Checksum error in the encrypted file",
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "Can not open the file as archive"
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
|
||||
expect(changed).toBe(0);
|
||||
for (const itemId of itemIds) {
|
||||
const item = session.items[itemId]!;
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.targetPath).toContain(".rar");
|
||||
expect(item.downloadedBytes).toBe(archiveSize);
|
||||
}
|
||||
});
|
||||
for (const itemId of itemIds) {
|
||||
const item = session.items[itemId]!;
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.targetPath).toContain(".rar");
|
||||
expect(item.downloadedBytes).toBe(archiveSize);
|
||||
}
|
||||
|
||||
for (const archiveName of archiveNames) {
|
||||
fs.writeFileSync(path.join(outputDir, archiveName), Buffer.alloc(archiveSize, 0x7f));
|
||||
}
|
||||
const wrongPasswordChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]!),
|
||||
{
|
||||
archiveName: "show.s01e01.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
|
||||
errorText: "Wrong password",
|
||||
category: "wrong_password",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "WRONG_PASSWORD"
|
||||
},
|
||||
"hybrid"
|
||||
);
|
||||
expect(wrongPasswordChanged).toBe(0);
|
||||
expect(itemIds.map((itemId) => session.items[itemId]!.status)).toEqual(["completed", "completed"]);
|
||||
expect(archiveNames.map((archiveName) => fs.existsSync(path.join(outputDir, archiveName)))).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
@@ -7513,10 +7648,11 @@ describe("download manager", () => {
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
||||
expect(Array.from(ready)).toHaveLength(0);
|
||||
);
|
||||
|
||||
expect((manager as any).looksLikeArchivePart("show.s01e01.part2.rar", "show.s01e01.part1.rar")).toBe(true);
|
||||
const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]);
|
||||
expect(Array.from(ready)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows disk fallback when queued archive parts are fully present on disk", async () => {
|
||||
@@ -9804,6 +9940,140 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requeues confirmed corrupt volumes independently when one part is locked", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crc-specific-volume-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "crc-specific-volume-pkg";
|
||||
const outputDir = path.join(root, "downloads", "crc-specific-volume");
|
||||
const extractDir = path.join(root, "extract", "crc-specific-volume");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const archiveNames = ["show.part1.rar", "show.part2.rar", "show.part3.rar"];
|
||||
const itemIds = archiveNames.map((_, index) => `crc-specific-${index + 1}`);
|
||||
const createdAt = Date.now() - 1000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "crc-specific-volume",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "extracting",
|
||||
itemIds,
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
resultGeneration: 4,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
for (const [index, archiveName] of archiveNames.entries()) {
|
||||
const targetPath = path.join(outputDir, archiveName);
|
||||
const bytes = Buffer.alloc(64 * 1024, index + 1);
|
||||
Buffer.from("526172211a070100", "hex").copy(bytes, 0);
|
||||
fs.writeFileSync(targetPath, bytes);
|
||||
session.items[itemIds[index]] = {
|
||||
id: itemIds[index],
|
||||
packageId,
|
||||
url: `https://dummy/${archiveName}`,
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: bytes.length,
|
||||
totalBytes: bytes.length,
|
||||
progressPercent: 100,
|
||||
fileName: archiveName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const failure = {
|
||||
archiveName: "show.part1.rar",
|
||||
archivePath: path.join(outputDir, "show.part1.rar"),
|
||||
errorText: `Prüfsummenfehler der gepackten Daten in Volume ${path.join(outputDir, "show.part3.rar")} Corrupt file or wrong password`,
|
||||
category: "crc_error",
|
||||
suggestRedownload: true,
|
||||
jvmFailureReason: "7z-Fehler: CRCERROR"
|
||||
} as const;
|
||||
|
||||
session.items[itemIds[0]].downloadedBytes = 0;
|
||||
const lockedPath = path.join(outputDir, "show.part1.rar");
|
||||
const implicatedPath = path.join(outputDir, "show.part3.rar");
|
||||
const originalRmSync = fs.rmSync.bind(fs);
|
||||
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((targetPath: fs.PathLike, options?: fs.RmDirOptions) => {
|
||||
if (path.resolve(String(targetPath)) === path.resolve(lockedPath)) {
|
||||
throw Object.assign(new Error("locked"), { code: "EPERM" });
|
||||
}
|
||||
return originalRmSync(targetPath, options as never);
|
||||
}) as typeof fs.rmSync);
|
||||
const blockedChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
rmSpy.mockRestore();
|
||||
expect(blockedChanged).toBe(1);
|
||||
expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "completed", targetPath: lockedPath }));
|
||||
expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" }));
|
||||
expect(fs.existsSync(lockedPath)).toBe(true);
|
||||
expect(fs.existsSync(implicatedPath)).toBe(false);
|
||||
|
||||
fs.writeFileSync(implicatedPath, Buffer.alloc(64 * 1024, 3));
|
||||
Object.assign(session.items[itemIds[2]], {
|
||||
status: "completed",
|
||||
targetPath: implicatedPath,
|
||||
downloadedBytes: 64 * 1024,
|
||||
totalBytes: 64 * 1024,
|
||||
progressPercent: 100,
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
const secondChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
|
||||
expect(secondChanged).toBe(1);
|
||||
expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" }));
|
||||
expect(session.items[itemIds[1]].status).toBe("completed");
|
||||
expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "completed", targetPath: implicatedPath }));
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[0]))).toBe(false);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, archiveNames[2]))).toBe(true);
|
||||
|
||||
fs.writeFileSync(lockedPath, Buffer.alloc(64 * 1024, 1));
|
||||
Object.assign(session.items[itemIds[0]], {
|
||||
status: "completed",
|
||||
targetPath: lockedPath,
|
||||
downloadedBytes: 64 * 1024,
|
||||
totalBytes: 64 * 1024,
|
||||
progressPercent: 100,
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
const repeatedChanged = (manager as any).autoRecoverArchiveCrcFailure(
|
||||
session.packages[packageId],
|
||||
itemIds.map((itemId) => session.items[itemId]),
|
||||
failure,
|
||||
"full"
|
||||
);
|
||||
expect(repeatedChanged).toBe(0);
|
||||
expect(fs.existsSync(lockedPath)).toBe(true);
|
||||
expect(fs.existsSync(implicatedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -16713,3 +16983,576 @@ describe("download health snapshot", () => {
|
||||
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-processing lifecycle audit", () => {
|
||||
function createCompletedFileManager(
|
||||
root: string,
|
||||
entries: Array<{ packageId: string; itemId: string; fileName: string; url?: string; content?: Buffer }>,
|
||||
settings: Partial<AppSettings> = {}
|
||||
): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
|
||||
const session = emptySession();
|
||||
const createdAt = Date.now() - 10_000;
|
||||
for (const entry of entries) {
|
||||
const outputDir = path.join(root, "downloads", entry.packageId);
|
||||
const extractDir = path.join(root, "extract", entry.packageId);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const targetPath = path.join(outputDir, entry.fileName);
|
||||
fs.writeFileSync(targetPath, entry.content ?? Buffer.alloc(256, 1));
|
||||
if (!session.packages[entry.packageId]) {
|
||||
session.packageOrder.push(entry.packageId);
|
||||
session.packages[entry.packageId] = {
|
||||
id: entry.packageId,
|
||||
name: entry.packageId,
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
session.packages[entry.packageId].itemIds.push(entry.itemId);
|
||||
const bytes = fs.statSync(targetPath).size;
|
||||
session.items[entry.itemId] = {
|
||||
id: entry.itemId,
|
||||
packageId: entry.packageId,
|
||||
url: entry.url ?? `https://example.test/${entry.itemId}`,
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: bytes,
|
||||
totalBytes: bytes,
|
||||
progressPercent: 100,
|
||||
fileName: entry.fileName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
autoRename4sf4sj: false,
|
||||
collectMkvToLibrary: false,
|
||||
enableIntegrityCheck: false,
|
||||
cleanupMode: "none",
|
||||
...settings
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
return { manager, session };
|
||||
}
|
||||
|
||||
it("requeues an interrupted integrity check and clears transient package progress on restart", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "integrity-package", itemId: "integrity-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
session.items["integrity-item"].status = "integrity_check";
|
||||
session.items["integrity-item"].fullStatus = "CRC-Check läuft";
|
||||
session.packages["integrity-package"].status = "integrity_check";
|
||||
session.packages["integrity-package"].postProcessLabel = "Finalisieren (1/1)";
|
||||
|
||||
internal.normalizeSessionStatuses();
|
||||
|
||||
expect(session.items["integrity-item"]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" }));
|
||||
expect(session.packages["integrity-package"].status).toBe("queued");
|
||||
expect(session.packages["integrity-package"].postProcessLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps duplicate-suffixed items with independently existing files separate on startup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-distinct-urls-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "distinct-package", itemId: "first-url", fileName: "episode.rar", url: "https://same.test/file" },
|
||||
{ packageId: "distinct-package", itemId: "second-url", fileName: "episode (1).rar", url: "https://same.test/file" }
|
||||
]);
|
||||
|
||||
const snapshot = manager.getSnapshot().session;
|
||||
expect(snapshot.packages["distinct-package"].itemIds).toEqual(["first-url", "second-url"]);
|
||||
expect(snapshot.items["first-url"]).toBeDefined();
|
||||
expect(snapshot.items["second-url"]).toBeDefined();
|
||||
expect(fs.existsSync(session.items["second-url"].targetPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("moves startup cleanup archives to the recoverable trash in trash mode", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-trash-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "trash-package", itemId: "trash-item", fileName: "episode.part1.rar" }]);
|
||||
const internal = manager as any;
|
||||
session.items["trash-item"].fullStatus = "Entpackt - Fertig";
|
||||
internal.settings.cleanupMode = "trash";
|
||||
const archivePath = session.items["trash-item"].targetPath;
|
||||
|
||||
await internal.cleanupExistingExtractedArchives();
|
||||
|
||||
expect(fs.existsSync(archivePath)).toBe(false);
|
||||
expect(fs.readdirSync(path.join(path.dirname(archivePath), ".rd-trash"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let queued startup cleanup remove a newly replaced archive", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-cleanup-race-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "cleanup-race-package", itemId: "cleanup-race-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["cleanup-race-item"].targetPath;
|
||||
session.items["cleanup-race-item"].fullStatus = "Entpackt - Fertig";
|
||||
internal.settings.cleanupMode = "delete";
|
||||
let releaseQueue = (): void => {};
|
||||
const queueGate = new Promise<void>((resolve) => { releaseQueue = resolve; });
|
||||
internal.cleanupQueue = queueGate;
|
||||
|
||||
const cleanup = internal.cleanupExistingExtractedArchives();
|
||||
await waitFor(() => internal.cleanupQueue !== queueGate, 2_000);
|
||||
fs.writeFileSync(archivePath, Buffer.from("replacement"));
|
||||
releaseQueue();
|
||||
await cleanup;
|
||||
|
||||
expect(fs.readFileSync(archivePath, "utf8")).toBe("replacement");
|
||||
});
|
||||
|
||||
it("never cleans an archive part claimed by another package in a shared output directory", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cleanup-shared-claim-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "cleanup-owner-a", itemId: "cleanup-owner-a-item", fileName: "show.part1.rar" },
|
||||
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-item", fileName: "show.part2.rar" },
|
||||
{ packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-nfo", fileName: "show.nfo" }
|
||||
], { cleanupMode: "delete" });
|
||||
const internal = manager as any;
|
||||
const sharedDir = session.packages["cleanup-owner-a"].outputDir;
|
||||
const foreignOldPath = session.items["cleanup-owner-b-item"].targetPath;
|
||||
const foreignNfoOldPath = session.items["cleanup-owner-b-nfo"].targetPath;
|
||||
const foreignSharedPath = path.join(sharedDir, "show.part2.rar");
|
||||
const foreignNfoSharedPath = path.join(sharedDir, "show.nfo");
|
||||
fs.renameSync(foreignOldPath, foreignSharedPath);
|
||||
fs.renameSync(foreignNfoOldPath, foreignNfoSharedPath);
|
||||
session.packages["cleanup-owner-b"].outputDir = sharedDir;
|
||||
session.items["cleanup-owner-b-item"].targetPath = foreignSharedPath;
|
||||
session.items["cleanup-owner-b-nfo"].targetPath = foreignNfoSharedPath;
|
||||
|
||||
const removed = await internal.cleanupRemainingArchiveArtifacts(session.packages["cleanup-owner-a"]);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(fs.existsSync(session.items["cleanup-owner-a-item"].targetPath)).toBe(false);
|
||||
expect(fs.existsSync(foreignSharedPath)).toBe(true);
|
||||
expect(fs.existsSync(foreignNfoSharedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an ambiguous pathless selection shared by multiple archive directories", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ambiguous-pathless-"));
|
||||
tempDirs.push(root);
|
||||
const item = {
|
||||
id: "pathless-item", packageId: "pathless-package", url: "https://example.test/pathless", provider: "realdebrid",
|
||||
status: "completed", retries: 0, speedBps: 0, downloadedBytes: 1, totalBytes: 1, progressPercent: 100,
|
||||
fileName: "episode.part1.rar", targetPath: "", resumable: true, attempts: 1, lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend", createdAt: Date.now(), updatedAt: Date.now()
|
||||
} satisfies DownloadItem;
|
||||
|
||||
const selection = resolveSelectedArchiveSetsFromCandidates(
|
||||
[path.join(root, "set-a", "episode.part1.rar"), path.join(root, "set-b", "episode.part1.rar")],
|
||||
[item],
|
||||
new Set([item.id])
|
||||
);
|
||||
|
||||
expect(selection.archivePaths.size).toBe(0);
|
||||
expect(selection.itemIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts an explicitly named first multipart volume as the CRC target", () => {
|
||||
const items = [
|
||||
{ id: "crc-first", fileName: "show.part1.rar", targetPath: "C:\\Downloads\\show.part1.rar" },
|
||||
{ id: "crc-second", fileName: "show.part2.rar", targetPath: "C:\\Downloads\\show.part2.rar" }
|
||||
] as DownloadItem[];
|
||||
|
||||
expect(findCrcImplicatedArchiveItems("C:\\Downloads\\show.part1.rar - checksum error", items).map((item) => item.id)).toEqual(["crc-first"]);
|
||||
});
|
||||
|
||||
it("marks an unexpected package post-process exception as failure but not an abort", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-exception-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "exception-package", itemId: "exception-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("unexpected-postprocess-failure"); });
|
||||
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
|
||||
expect(session.packages["exception-package"].status).toBe("failed");
|
||||
expect(session.items["exception-item"].fullStatus).toMatch(/^Entpack-Fehler/);
|
||||
|
||||
session.packages["exception-package"].status = "queued";
|
||||
session.items["exception-item"].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted:extract")), { once: true });
|
||||
queueMicrotask(() => internal.abortPackagePostProcessing("exception-package", "stop"));
|
||||
});
|
||||
});
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
expect(session.packages["exception-package"].status).not.toBe("failed");
|
||||
expect(session.items["exception-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
|
||||
session.packages["exception-package"].status = "queued";
|
||||
internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("aborted:extract"); });
|
||||
await internal.runPackagePostProcessing("exception-package");
|
||||
expect(session.packages["exception-package"].status).toBe("failed");
|
||||
});
|
||||
|
||||
it("preserves a manual extraction plan across an automatic disk-capacity retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-disk-retry-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-disk-package", itemId: "manual-disk-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["manual-disk-item"].targetPath;
|
||||
const archiveKey = path.resolve(archivePath).toLowerCase();
|
||||
internal.manualExtractPackages.add("manual-disk-package");
|
||||
internal.manualExtractArchiveFilters.set("manual-disk-package", new Set([archiveKey]));
|
||||
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||
internal.diskReservations = new DiskReservationCoordinator({
|
||||
safetyBytes: 0,
|
||||
retryDelayMs: 1_000,
|
||||
statVolume: async (targetPath) => ({ path: targetPath, volumeKey: "manual-volume", freeBytes: 0, totalBytes: 1_024 })
|
||||
});
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
|
||||
await internal.handlePackagePostProcessing("manual-disk-package");
|
||||
expect(manager.getSnapshot().canStop).toBe(true);
|
||||
manager.togglePackage("manual-disk-package");
|
||||
expect(internal.packageDiskRetryPlans.has("manual-disk-package")).toBe(true);
|
||||
manager.togglePackage("manual-disk-package");
|
||||
internal.manualExtractPackages.clear();
|
||||
internal.manualExtractArchiveFilters.clear();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(rerun).toHaveBeenCalledWith("manual-disk-package");
|
||||
expect(internal.manualExtractPackages.has("manual-disk-package")).toBe(true);
|
||||
expect(internal.manualExtractArchiveFilters.get("manual-disk-package")).toEqual(new Set([archiveKey]));
|
||||
expect(internal.packageDiskRetryAfterByPackage.has("manual-disk-package")).toBe(false);
|
||||
expect(manager.getSnapshot().diskWaitEvents?.some((entry) => entry.packageId === "manual-disk-package")).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps disk retry generations and stale timer callbacks isolated", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-identity-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-identity-package", itemId: "disk-identity-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
const request = { retryAt: Date.now() + 60_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null };
|
||||
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const firstId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const secondId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.packagePostProcessVersions.set("disk-identity-package", 1);
|
||||
expect(internal.schedulePackageDiskRetry("disk-identity-package", request)).toBe(false);
|
||||
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||
internal.packagePostProcessVersions.set("disk-identity-package", 0);
|
||||
internal.executePackageDiskRetry("disk-identity-package", firstId);
|
||||
expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
|
||||
const getRunOwner = internal.getPackageResultRunOwner.bind(internal);
|
||||
internal.getPackageResultRunOwner = () => "new-owner";
|
||||
internal.executePackageDiskRetry("disk-identity-package", secondId);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
internal.getPackageResultRunOwner = getRunOwner;
|
||||
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
const thirdId = internal.packageDiskRetryPlans.get("disk-identity-package").id;
|
||||
internal.session.packages["disk-identity-package"].resultGeneration += 1;
|
||||
internal.executePackageDiskRetry("disk-identity-package", thirdId);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
|
||||
manager.stop();
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
internal.healthManualStop = false;
|
||||
manager.prepareForShutdown();
|
||||
internal.schedulePackageDiskRetry("disk-identity-package", request);
|
||||
expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears a paused run-owned disk retry when re-enable has no matching owner", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-owner-rebind-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "disk-owner-package", itemId: "disk-owner-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
internal.session.running = true;
|
||||
internal.runScopeKind = "selected";
|
||||
internal.runPackageIds.add("disk-owner-package");
|
||||
const context = internal.beginActiveRunContext(new Set(["disk-owner-package"]), Date.now());
|
||||
expect(internal.schedulePackageDiskRetry("disk-owner-package", {
|
||||
retryAt: Date.now() + 1_000,
|
||||
manualRequested: true,
|
||||
postProcessVersion: 0,
|
||||
runOwnerId: context.id
|
||||
})).toBe(true);
|
||||
|
||||
manager.togglePackage("disk-owner-package");
|
||||
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(true);
|
||||
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||
manager.togglePackage("disk-owner-package");
|
||||
|
||||
expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.packageDiskRetryAfterByPackage.has("disk-owner-package")).toBe(false);
|
||||
expect(internal.getActivePostProcessingCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels a due disk retry when stop wins the race", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-stop-race-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "disk-stop-package", itemId: "disk-stop-item", fileName: "episode.zip" }]);
|
||||
const internal = manager as any;
|
||||
const rerun = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = rerun;
|
||||
internal.session.running = true;
|
||||
internal.runScopeKind = "selected";
|
||||
internal.runPackageIds.add("disk-stop-package");
|
||||
internal.runItemIds.add("disk-stop-item");
|
||||
expect(internal.schedulePackageDiskRetry("disk-stop-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null })).toBe(true);
|
||||
|
||||
manager.stop();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(rerun).not.toHaveBeenCalled();
|
||||
expect(internal.packageDiskRetryPlans.size).toBe(0);
|
||||
expect(internal.packageDiskRetryTimers.size).toBe(0);
|
||||
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||
expect(session.running).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes separate manual and partial flags to deferred extraction", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-scope-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-cleanup-package", itemId: "manual-cleanup-item", fileName: "episode.rar" }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["manual-cleanup-item"].targetPath;
|
||||
internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath]));
|
||||
internal.runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
|
||||
const deferred = vi.fn(async () => {});
|
||||
internal.runDeferredPostExtraction = deferred;
|
||||
internal.manualExtractPackages.add("manual-cleanup-package");
|
||||
|
||||
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, false]);
|
||||
|
||||
session.packages["manual-cleanup-package"].status = "queued";
|
||||
session.items["manual-cleanup-item"].fullStatus = "Entpacken - Ausstehend";
|
||||
internal.manualExtractArchiveFilters.set("manual-cleanup-package", new Set([path.resolve(archivePath).toLowerCase()]));
|
||||
await internal.handlePackagePostProcessing("manual-cleanup-package");
|
||||
expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("cleans full manual package archives but preserves partial manual state", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-behavior-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-a", fileName: "episode-a.rar" },
|
||||
{ packageId: "manual-cleanup-behavior", itemId: "cleanup-b", fileName: "episode-b.rar" }
|
||||
], { cleanupMode: "delete" });
|
||||
const internal = manager as any;
|
||||
const firstPath = session.items["cleanup-a"].targetPath;
|
||||
const secondPath = session.items["cleanup-b"].targetPath;
|
||||
|
||||
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, true);
|
||||
expect(fs.existsSync(firstPath)).toBe(true);
|
||||
expect(fs.existsSync(secondPath)).toBe(true);
|
||||
|
||||
await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, false);
|
||||
expect(fs.existsSync(firstPath)).toBe(false);
|
||||
expect(fs.existsSync(secondPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects package extraction when no archive candidate exists", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-no-archive-"));
|
||||
tempDirs.push(root);
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "no-archive-package", itemId: "no-archive-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
await expect(manager.extractNow("no-archive-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||
expect(manager.getSnapshot().session.items["no-archive-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
expect(manager.getSnapshot().session.packages["no-archive-package"].status).toBe("completed");
|
||||
expect(internal.manualExtractPackages.has("no-archive-package")).toBe(false);
|
||||
expect(postProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects retryExtraction when the failed package has no complete archive", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-no-archive-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "retry-no-archive", itemId: "retry-no-archive-item", fileName: "episode.mkv" }]);
|
||||
const internal = manager as any;
|
||||
session.items["retry-no-archive-item"].fullStatus = "Entpack-Fehler: vorheriger Fehler";
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.retryExtraction("retry-no-archive")).rejects.toThrow(/Kein .*entpackbarer Archivsatz ausgewählt/);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler");
|
||||
});
|
||||
|
||||
it("recognizes and arms an opaque archive file by signature", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const content = zip.toBuffer();
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "opaque-package", itemId: "opaque-item", fileName: "download.bin", content }]);
|
||||
const internal = manager as any;
|
||||
|
||||
await manager.extractNow("opaque-package");
|
||||
const task = internal.packagePostProcessTasks.get("opaque-package");
|
||||
expect(task).toBeDefined();
|
||||
await task;
|
||||
|
||||
expect(manager.getSnapshot().session.items["opaque-item"].fileName).toMatch(/\.zip$/i);
|
||||
});
|
||||
|
||||
it("extracts a uniquely resolvable pathless legacy item through the public API", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-pathless-public-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "pathless-public", itemId: "pathless-public-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||
const internal = manager as any;
|
||||
const archivePath = session.items["pathless-public-item"].targetPath;
|
||||
internal.releaseTargetPath("pathless-public-item");
|
||||
session.items["pathless-public-item"].targetPath = "";
|
||||
const postProcess = vi.fn(async () => {});
|
||||
internal.runPackagePostProcessing = postProcess;
|
||||
|
||||
await manager.extractNow("pathless-public");
|
||||
|
||||
expect(session.items["pathless-public-item"].targetPath).toBe(archivePath);
|
||||
expect(session.items["pathless-public-item"].fullStatus).toBe("Entpacken - Ausstehend");
|
||||
expect(postProcess).toHaveBeenCalledWith("pathless-public");
|
||||
});
|
||||
|
||||
it("rejects an incomplete multipart package before starting extraction", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-incomplete-multipart-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, session } = createCompletedFileManager(root, [{ packageId: "incomplete-package", itemId: "incomplete-part1", fileName: "show.part1.rar" }]);
|
||||
const internal = manager as any;
|
||||
const part2Id = "incomplete-part2";
|
||||
session.packages["incomplete-package"].itemIds.push(part2Id);
|
||||
session.items[part2Id] = {
|
||||
...session.items["incomplete-part1"],
|
||||
id: part2Id,
|
||||
status: "queued",
|
||||
fileName: "show.part2.rar",
|
||||
targetPath: "",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fullStatus: "Wartet"
|
||||
};
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.extractNow("incomplete-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt");
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a mixed extraction batch before starting any package", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||
const { manager } = createCompletedFileManager(root, [
|
||||
{ packageId: "valid-package", itemId: "valid-item", fileName: "episode.zip", content: zip.toBuffer() },
|
||||
{ packageId: "invalid-package", itemId: "invalid-item", fileName: "episode.mkv" }
|
||||
]);
|
||||
const internal = manager as any;
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
|
||||
await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package");
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package");
|
||||
});
|
||||
|
||||
it("keeps opaque archive files untouched when batch preflight rejects another target", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024));
|
||||
const { manager, session } = createCompletedFileManager(root, [
|
||||
{ packageId: "atomic-opaque", itemId: "atomic-opaque-item", fileName: "download.bin", content: zip.toBuffer() },
|
||||
{ packageId: "atomic-invalid", itemId: "atomic-invalid-item", fileName: "episode.mkv" }
|
||||
]);
|
||||
const internal = manager as any;
|
||||
internal.runPackagePostProcessing = vi.fn(async () => {});
|
||||
const opaquePath = session.items["atomic-opaque-item"].targetPath;
|
||||
|
||||
await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i);
|
||||
|
||||
expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin");
|
||||
expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath);
|
||||
expect(fs.existsSync(opaquePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false);
|
||||
expect(internal.runPackagePostProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes and drains stop for standalone manual extraction without starting a download run", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-standalone-stop-"));
|
||||
tempDirs.push(root);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from("video"));
|
||||
const { manager } = createCompletedFileManager(root, [{ packageId: "standalone-package", itemId: "standalone-item", fileName: "episode.zip", content: zip.toBuffer() }]);
|
||||
const internal = manager as any;
|
||||
internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => {
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
||||
});
|
||||
|
||||
await manager.extractNow("standalone-package");
|
||||
await waitFor(() => internal.packagePostProcessTasks.size === 1, 2_000);
|
||||
expect(manager.getSnapshot().canStop).toBe(true);
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
|
||||
manager.stop();
|
||||
await waitFor(() => internal.packagePostProcessTasks.size === 0, 2_000);
|
||||
expect(manager.getSnapshot().lifecycle?.phase).toBe("idle");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2187,6 +2187,7 @@ describe("download table row contracts", () => {
|
||||
it("keeps the complete package status and audio details in the tooltip", () => {
|
||||
const audioPackage = {
|
||||
...pkg("audio-package", "Audio package", ["audio-item"]),
|
||||
status: "extracting" as const,
|
||||
postProcessLabel: "Entpacken 1%",
|
||||
audioStripSummary: {
|
||||
at: now,
|
||||
@@ -2231,6 +2232,62 @@ describe("download table row contracts", () => {
|
||||
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||||
});
|
||||
|
||||
it("shows active extraction progress while retaining sibling extraction errors in the tooltip", () => {
|
||||
const activePackage = {
|
||||
...pkg("active-extraction-package", "Active extraction", ["failed-archive", "active-archive"]),
|
||||
status: "extracting" as const,
|
||||
postProcessLabel: "Entpacken 42% (1/1) · active.part01.rar"
|
||||
};
|
||||
const failedArchive = item("failed-archive", activePackage.id, "completed", {
|
||||
fullStatus: "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"
|
||||
});
|
||||
const activeArchive = item("active-archive", activePackage.id, "completed", {
|
||||
fullStatus: "Entpacken 42% · active.part01.rar"
|
||||
});
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: activePackage, items: [failedArchive, activeArchive], allItems: [failedArchive, activeArchive], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html.match(/>Entpacken - 42%<\/span>/g)).toHaveLength(2);
|
||||
expect(html).toContain("1 Entpackfehler");
|
||||
expect(html).toContain("Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv");
|
||||
});
|
||||
|
||||
it("does not present a persisted extraction label as active progress after restart", () => {
|
||||
const restoredItem = item("restored-archive", "restored-package", "completed", {
|
||||
fullStatus: "Entpacken - Ausstehend"
|
||||
});
|
||||
const restoredPackage = {
|
||||
...pkg("restored-package", "Restored extraction", [restoredItem.id]),
|
||||
status: "queued" as const,
|
||||
postProcessLabel: "Entpacken 100% (1/1) · release.part01.rar"
|
||||
};
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: restoredPackage, items: [restoredItem], allItems: [restoredItem], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html.match(/>Entpacken - Ausstehend<\/span>/g)).toHaveLength(2);
|
||||
expect(html).not.toContain(">Entpacken - 100%</span>");
|
||||
expect(html).not.toContain("Entpacken 100%");
|
||||
expect(html).not.toContain("release.part01.rar");
|
||||
});
|
||||
|
||||
it("removes redundant service suffixes from runtime statuses", () => {
|
||||
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
||||
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { IPC_CHANNELS } from "../src/shared/ipc";
|
||||
import type { ElectronApi } from "../src/shared/preload-api";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
api: undefined as ElectronApi | undefined,
|
||||
ipcHandlers: new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>(),
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||
appHandlers: new Map<string, (...args: unknown[]) => void>(),
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => "C:\\MDD\\Test"),
|
||||
getAppPath: vi.fn(() => "C:\\MDD\\App"),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => {
|
||||
electron.appHandlers.set(name, handler);
|
||||
}),
|
||||
whenReady: vi.fn(() => new Promise<void>(() => {})),
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
setPath: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: electron.app,
|
||||
BrowserWindow: class {
|
||||
public static getAllWindows(): unknown[] { return []; }
|
||||
},
|
||||
clipboard: { readText: vi.fn(() => ""), writeText: vi.fn() },
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: ElectronApi) => {
|
||||
electron.api = api;
|
||||
}
|
||||
},
|
||||
dialog: {},
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown) => {
|
||||
electron.ipcHandlers.set(channel, handler);
|
||||
}),
|
||||
on: vi.fn()
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: electron.invoke,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn()
|
||||
},
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
nativeTheme: { themeSource: "system" },
|
||||
powerMonitor: { on: vi.fn(), removeListener: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { registerExtractionIpcHandlers } from "../src/main/extraction-ipc";
|
||||
|
||||
function trustedEvent(): IpcMainInvokeEvent {
|
||||
return {} as IpcMainInvokeEvent;
|
||||
}
|
||||
|
||||
function registerHandlers(target: Parameters<typeof registerExtractionIpcHandlers>[1]): void {
|
||||
registerExtractionIpcHandlers((channel, handler) => {
|
||||
electron.ipcHandlers.set(channel, handler);
|
||||
}, target);
|
||||
}
|
||||
|
||||
function createController(manager: {
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (request: { packageIds: string[]; itemIds: string[] }) => Promise<void>;
|
||||
}): AppController {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: typeof manager;
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
controller.manager = manager;
|
||||
controller.audit = vi.fn();
|
||||
return controller as unknown as AppController;
|
||||
}
|
||||
|
||||
describe("manual extraction error propagation", () => {
|
||||
beforeAll(async () => {
|
||||
await import("../src/preload/preload");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
electron.ipcHandlers.clear();
|
||||
electron.invoke.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["stale", "Paket existiert nicht mehr"],
|
||||
["deleted", "Ausgewählte Datei wurde gelöscht"],
|
||||
["non-extractable", "Kein vollständiger entpackbarer Archivsatz ausgewählt"]
|
||||
])("keeps a %s manager rejection intact through AppController", async (_caseName, message) => {
|
||||
const controller = createController({
|
||||
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||
});
|
||||
|
||||
await expect(controller.retryExtraction("package-id")).rejects.toThrow(message);
|
||||
await expect(controller.extractNow({ packageIds: [], itemIds: ["item-id"] })).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("rejects an empty extract-now request at the trusted main-process IPC boundary", async () => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => undefined),
|
||||
extractNow: vi.fn(async () => undefined)
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(IPC_CHANNELS.EXTRACT_NOW);
|
||||
|
||||
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), { packageIds: [], itemIds: [] })))
|
||||
.rejects.toThrow("extractNow benötigt mindestens ein Ziel");
|
||||
expect(controller.extractNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "packageId muss ein nicht-leerer String sein"],
|
||||
[" ", "packageId muss ein nicht-leerer String sein"],
|
||||
["p".repeat(257), "packageId darf höchstens 256 Zeichen lang sein"]
|
||||
])("rejects an invalid retry package ID before calling the controller", async (packageId, message) => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => undefined),
|
||||
extractNow: vi.fn(async () => undefined)
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(IPC_CHANNELS.RETRY_EXTRACTION);
|
||||
|
||||
await expect(Promise.resolve().then(() => handler?.(trustedEvent(), packageId))).rejects.toThrow(message);
|
||||
expect(controller.retryExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[IPC_CHANNELS.RETRY_EXTRACTION, "Paket existiert nicht mehr", ["stale-package"]],
|
||||
[IPC_CHANNELS.EXTRACT_NOW, "Ausgewählte Datei wurde gelöscht", [{ packageIds: [], itemIds: ["deleted-item"] }]],
|
||||
[IPC_CHANNELS.EXTRACT_NOW, "Kein vollständiger entpackbarer Archivsatz ausgewählt", [{ packageIds: [], itemIds: ["plain-file"] }]]
|
||||
])("returns the controller rejection from %s to ipcRenderer.invoke", async (channel, message, args) => {
|
||||
const controller = {
|
||||
retryExtraction: vi.fn(async () => { throw new Error(message); }),
|
||||
extractNow: vi.fn(async () => { throw new Error(message); })
|
||||
};
|
||||
registerHandlers(controller);
|
||||
const handler = electron.ipcHandlers.get(channel);
|
||||
|
||||
await expect(Promise.resolve(handler?.(trustedEvent(), ...args))).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("exposes main-process extraction rejections unchanged to the renderer API", async () => {
|
||||
electron.invoke
|
||||
.mockRejectedValueOnce(new Error("Paket existiert nicht mehr"))
|
||||
.mockRejectedValueOnce(new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"));
|
||||
|
||||
await expect(electron.api?.retryExtraction("stale-package")).rejects.toThrow("Paket existiert nicht mehr");
|
||||
await expect(electron.api?.extractNow({ packageIds: [], itemIds: ["plain-file"] }))
|
||||
.rejects.toThrow("Kein vollständiger entpackbarer Archivsatz ausgewählt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { extractPackageArchives, shutdownDaemon, type ExtractArchiveFailureInfo } from "../src/main/extractor";
|
||||
|
||||
const hasJdk = spawnSync("javac", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalJava = process.env.RD_JAVA_BIN;
|
||||
const originalJvmRoot = process.env.RD_EXTRACTOR_JVM_DIR;
|
||||
const originalFakeMode = process.env.RD_FAKE_JVM_MODE;
|
||||
const originalFakeSecret = process.env.RD_FAKE_JVM_SECRET;
|
||||
const tempDirs: string[] = [];
|
||||
let runtimeRoot = "";
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveFixture(prefix: string): { packageDir: string; targetDir: string } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c0004", "hex"));
|
||||
return { packageDir, targetDir };
|
||||
}
|
||||
|
||||
function extractionOptions(fixture: ReturnType<typeof createArchiveFixture>) {
|
||||
return {
|
||||
...fixture,
|
||||
cleanupMode: "none" as const,
|
||||
conflictMode: "skip" as const,
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: "candidate-one\ncandidate-two"
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJdk).sequential("JVM protocol integration", () => {
|
||||
beforeAll(() => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-protocol-runtime-"));
|
||||
tempDirs.push(root);
|
||||
runtimeRoot = path.join(root, "extractor-jvm");
|
||||
const sourceDir = path.join(root, "source", "com", "sucukdeluxe", "extractor");
|
||||
const classesDir = path.join(runtimeRoot, "classes");
|
||||
const libDir = path.join(runtimeRoot, "lib");
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.mkdirSync(classesDir, { recursive: true });
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
const javaSource = `package com.sucukdeluxe.extractor;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
public final class JBindExtractorMain {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String mode = System.getenv("RD_FAKE_JVM_MODE");
|
||||
String secret = System.getenv("RD_FAKE_JVM_SECRET");
|
||||
boolean daemon = args.length == 1 && "--daemon".equals(args[0]);
|
||||
if (daemon && mode.startsWith("oneshot")) return;
|
||||
if (daemon) {
|
||||
System.out.println("RD_DAEMON_READY");
|
||||
System.out.flush();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
while (reader.readLine() != null) {
|
||||
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||
System.out.println("RD_BACKEND fake-daemon");
|
||||
if ("daemon-success".equals(mode)) {
|
||||
System.out.println("RD_DONE");
|
||||
System.out.println("RD_REQUEST_DONE 0");
|
||||
System.out.flush();
|
||||
continue;
|
||||
}
|
||||
System.out.print("RD_PASS");
|
||||
System.out.flush();
|
||||
Thread.sleep(25L);
|
||||
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||
System.out.flush();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
System.out.println("RD_PASSWORD_ATTEMPT 1 4");
|
||||
System.out.println("RD_BACKEND fake-oneshot");
|
||||
if ("oneshot-success".equals(mode)) {
|
||||
System.out.println("RD_DONE");
|
||||
return;
|
||||
}
|
||||
System.out.print("RD_PASS");
|
||||
System.out.flush();
|
||||
Thread.sleep(25L);
|
||||
System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8)));
|
||||
System.out.flush();
|
||||
System.exit(1);
|
||||
}
|
||||
}`;
|
||||
const sourcePath = path.join(sourceDir, "JBindExtractorMain.java");
|
||||
fs.writeFileSync(sourcePath, javaSource, "utf8");
|
||||
const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-d", classesDir, sourcePath], { encoding: "utf8" });
|
||||
expect(compiled.status, `${compiled.stdout}\n${compiled.stderr}`).toBe(0);
|
||||
const sourceLibDir = path.join(process.cwd(), "resources", "extractor-jvm", "lib");
|
||||
for (const name of ["sevenzipjbinding.jar", "sevenzipjbinding-all-platforms.jar", "zip4j.jar"]) {
|
||||
fs.copyFileSync(path.join(sourceLibDir, name), path.join(libDir, name));
|
||||
}
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
process.env.RD_JAVA_BIN = "java";
|
||||
process.env.RD_EXTRACTOR_JVM_DIR = runtimeRoot;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDaemon();
|
||||
delete process.env.RD_FAKE_JVM_MODE;
|
||||
delete process.env.RD_FAKE_JVM_SECRET;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
shutdownDaemon();
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
restoreEnv("RD_EXTRACT_BACKEND", originalBackend);
|
||||
restoreEnv("RD_JAVA_BIN", originalJava);
|
||||
restoreEnv("RD_EXTRACTOR_JVM_DIR", originalJvmRoot);
|
||||
restoreEnv("RD_FAKE_JVM_MODE", originalFakeMode);
|
||||
restoreEnv("RD_FAKE_JVM_SECRET", originalFakeSecret);
|
||||
});
|
||||
|
||||
it("transports daemon attempts and isolates throwing log observers", async () => {
|
||||
process.env.RD_FAKE_JVM_MODE = "daemon-success";
|
||||
process.env.RD_FAKE_JVM_SECRET = "unused-daemon-secret";
|
||||
const firstFixture = createArchiveFixture("rd-jvm-daemon-callback-");
|
||||
const first = await extractPackageArchives({
|
||||
...extractionOptions(firstFixture),
|
||||
onLog: (_level, message) => {
|
||||
if (message.startsWith("Passwort-Versuch ")) {
|
||||
throw new Error("ui callback failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(first).toEqual(expect.objectContaining({ extracted: 1, failed: 0, lastError: "" }));
|
||||
|
||||
const secondFixture = createArchiveFixture("rd-jvm-daemon-recovery-");
|
||||
const logs: string[] = [];
|
||||
const second = await extractPackageArchives({
|
||||
...extractionOptions(secondFixture),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("redacts a one-shot password payload when the JVM exits before an error event", async () => {
|
||||
const secret = "one-shot-runtime-secret";
|
||||
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||
process.env.RD_FAKE_JVM_MODE = "oneshot-crash";
|
||||
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||
const fixture = createArchiveFixture("rd-jvm-oneshot-redaction-");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
...extractionOptions(fixture),
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||
expect(diagnosticText).not.toContain(secret);
|
||||
expect(diagnosticText).not.toContain(encodedSecret);
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("redacts a daemon password payload when the JVM exits before request completion", async () => {
|
||||
const secret = "daemon-runtime-secret";
|
||||
const encodedSecret = Buffer.from(secret, "utf8").toString("base64");
|
||||
process.env.RD_FAKE_JVM_MODE = "daemon-crash";
|
||||
process.env.RD_FAKE_JVM_SECRET = secret;
|
||||
const fixture = createArchiveFixture("rd-jvm-daemon-redaction-");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
...extractionOptions(fixture),
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n");
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(diagnosticText).toContain("RD_PASSWORD <redacted>");
|
||||
expect(diagnosticText).not.toContain(secret);
|
||||
expect(diagnosticText).not.toContain(encodedSecret);
|
||||
expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true);
|
||||
}, 20_000);
|
||||
});
|
||||
+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-"));
|
||||
|
||||
+326
-28
@@ -1,4 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +21,10 @@ import {
|
||||
shouldSerialRetryParallelFailures,
|
||||
findArchiveCandidates,
|
||||
orderExtractorCandidatesForArchive,
|
||||
extractorCommandsShareIdentity,
|
||||
parseJvmPasswordAttemptLine,
|
||||
redactJvmDiagnosticLine,
|
||||
summarizeJvmPasswordAttempts,
|
||||
parseNativeExtractOutput,
|
||||
parseNativeArchiveEntryList,
|
||||
remapNativeSubstOutput,
|
||||
@@ -26,14 +32,44 @@ import {
|
||||
resolveExtractorBackendModeForArchive,
|
||||
resolveExtractorBackendMode,
|
||||
shouldFallbackLegacyRarToJvm,
|
||||
shouldRunAlternativeNativeExtractor,
|
||||
shouldSuggestRedownloadAfterCrossBackendFailure,
|
||||
validateNativeArchiveEntryCandidates,
|
||||
validateNativeFlatArchiveEntryCandidates,
|
||||
} from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS;
|
||||
const originalStatfs = fs.promises.statfs;
|
||||
const require = createRequire(import.meta.url);
|
||||
const rarCliPath = [
|
||||
"C:\\Program Files\\WinRAR\\Rar.exe",
|
||||
"C:\\Program Files (x86)\\WinRAR\\Rar.exe"
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
const javaAvailable = spawnSync("java", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
const sevenZipAvailable = spawnSync("7z", ["i"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
function createEncryptedCorruptRarFixture(root: string, password: string, stem: string): string {
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const payloadPath = path.join(root, "payload.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(payloadPath, randomBytes(256 * 1024));
|
||||
const archivePath = path.join(packageDir, `${stem}.rar`);
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${password}`, "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" });
|
||||
if (created.status !== 0) {
|
||||
throw new Error(String(created.stderr || created.stdout || `Rar Exit ${created.status}`));
|
||||
}
|
||||
const parts = fs.readdirSync(packageDir).filter((name) => new RegExp(`^${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.part\\d+\\.rar$`, "i").test(name)).sort();
|
||||
if (parts.length < 3) {
|
||||
throw new Error("RAR fixture has fewer than three volumes");
|
||||
}
|
||||
const corruptPath = path.join(packageDir, parts[2]);
|
||||
const bytes = fs.readFileSync(corruptPath);
|
||||
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
||||
fs.writeFileSync(corruptPath, bytes);
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
|
||||
|
||||
@@ -110,16 +146,21 @@ afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalExtractBackend === undefined) {
|
||||
if (originalExtractBackend === undefined) {
|
||||
delete process.env.RD_EXTRACT_BACKEND;
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
|
||||
}
|
||||
if (originalArchivePasswords === undefined) {
|
||||
delete process.env.RD_ARCHIVE_PASSWORDS;
|
||||
} else {
|
||||
process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords;
|
||||
}
|
||||
(fs.promises as any).statfs = originalStatfs;
|
||||
});
|
||||
|
||||
describe("extractor", () => {
|
||||
it("maps external extractor args by conflict mode", () => {
|
||||
it("maps external extractor args by conflict mode", () => {
|
||||
const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite");
|
||||
expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]);
|
||||
expect(overwriteArgs).toContain("-idc");
|
||||
@@ -148,8 +189,69 @@ describe("extractor", () => {
|
||||
const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org");
|
||||
expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]);
|
||||
expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar");
|
||||
expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\");
|
||||
});
|
||||
expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !sevenZipAvailable)("runs one five-candidate legacy pass for a deterministic multipart CRC failure", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-crc-pass-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const payloadPath = path.join(root, "payload.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(payloadPath, randomBytes(256 * 1024));
|
||||
const archivePath = path.join(packageDir, "release.test.rar");
|
||||
const created = spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" });
|
||||
expect(created.status).toBe(0);
|
||||
const parts = fs.readdirSync(packageDir).filter((name) => /^release\.test\.part\d+\.rar$/i.test(name)).sort();
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start:"))).toHaveLength(1);
|
||||
expect(logs.filter((message) => /^Legacy-Passwort-Versuch \d\/5:/.test(message))).toHaveLength(5);
|
||||
expect(logs.some((message) => message.startsWith("Legacy-Fallback:"))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath)("does not serially retry a deterministic CRC archive after another package archive succeeded", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parallel-crc-pass-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
const validPayload = path.join(root, "valid.bin");
|
||||
const failedPayload = path.join(root, "failed.bin");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(validPayload, randomBytes(96 * 1024));
|
||||
fs.writeFileSync(failedPayload, randomBytes(96 * 1024));
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, "a.valid.rar"), validPayload]).status).toBe(0);
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-idq", path.join(packageDir, "b.failed.rar"), failedPayload]).status).toBe(0);
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onLog: (_level, message) => logs.push(message)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 }));
|
||||
expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start: archive=b.failed.rar"))).toHaveLength(1);
|
||||
}, 30_000);
|
||||
|
||||
it("deletes only successfully extracted archives", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
@@ -1092,15 +1194,24 @@ describe("extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyExtractionError", () => {
|
||||
it("classifies CRC errors", () => {
|
||||
expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Checksum error in data")).toBe("crc_error");
|
||||
});
|
||||
describe("classifyExtractionError", () => {
|
||||
it("classifies CRC errors", () => {
|
||||
expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Checksum error in data")).toBe("crc_error");
|
||||
expect(classifyExtractionError("7z-Fehler: CRCERROR")).toBe("crc_error");
|
||||
expect(classifyExtractionError("7z-Fehler: DATAERROR")).toBe("crc_error");
|
||||
expect(classifyExtractionError("CRC-Fehler in release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Pr�fsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error");
|
||||
});
|
||||
|
||||
it("classifies wrong password", () => {
|
||||
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password");
|
||||
it("classifies wrong password", () => {
|
||||
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Archiv-Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches Archiv Passwort")).toBe("wrong_password");
|
||||
expect(classifyExtractionError("Falsches-Archiv-Passwort")).toBe("wrong_password");
|
||||
});
|
||||
|
||||
it("classifies missing parts", () => {
|
||||
@@ -1140,9 +1251,9 @@ describe("extractor", () => {
|
||||
expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error");
|
||||
});
|
||||
|
||||
it("returns unknown for unrecognized errors", () => {
|
||||
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
||||
});
|
||||
it("returns unknown for unrecognized errors", () => {
|
||||
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("keeps important tail markers when long extractor output is trimmed", () => {
|
||||
const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`;
|
||||
@@ -1152,17 +1263,19 @@ describe("extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSerialRetryParallelFailures", () => {
|
||||
it("keeps serial recovery enabled after mixed parallel results", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
|
||||
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("only retries a total parallel wipe-out for contention-like failures", () => {
|
||||
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
|
||||
});
|
||||
describe("shouldSerialRetryParallelFailures", () => {
|
||||
it("retries unknown failures that can result from parallel contention", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["unknown", "unknown"])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retry deterministic archive failures after another archive succeeded", () => {
|
||||
expect(shouldSerialRetryParallelFailures(1, ["crc_error"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(1, ["unsupported_format"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
|
||||
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password discovery", () => {
|
||||
@@ -1358,6 +1471,191 @@ describe("extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractorCommandsShareIdentity", () => {
|
||||
it("deduplicates aliases of the same native extraction engine", () => {
|
||||
expect(extractorCommandsShareIdentity("Rar.exe", "UnRAR.exe", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("C:\\Program Files\\WinRAR\\Rar.exe", "rar", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("7z.exe", "7za", "win32")).toBe(true);
|
||||
expect(extractorCommandsShareIdentity("Rar.exe", "7z.exe", "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("budgets automatic RAR recovery to one native engine before JVM", () => {
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "auto", "legacy", "win32")).toBe(false);
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "jvm", "jvm", "win32")).toBe(false);
|
||||
expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "legacy", "legacy", "win32")).toBe(true);
|
||||
expect(shouldRunAlternativeNativeExtractor("7z.exe", "C:\\release.zip", "auto", "auto", "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs one deduplicated serial recovery pass after parallel unknown failures", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-serial-recovery-once-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
for (const name of ["a.zip", "b.zip", "c.zip"]) {
|
||||
writeZipFixture(path.join(packageDir, name), [{ name: `${name}.txt`, content: name }]);
|
||||
}
|
||||
const attempts = new Map<string, number>();
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
scheduleArchive: async (archivePath, execute) => {
|
||||
const archiveName = path.basename(archivePath);
|
||||
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||
return execute(new AbortController().signal);
|
||||
},
|
||||
onOutput: (event) => {
|
||||
const archiveName = path.basename(event.archivePath);
|
||||
if (event.state === "opened" && (archiveName !== "b.zip" || attempts.get(archiveName) === 1)) {
|
||||
throw new Error(`transient-${archiveName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 2 }));
|
||||
expect(Object.fromEntries(attempts)).toEqual({ "a.zip": 1, "b.zip": 2, "c.zip": 2 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath)("isolates throwing Legacy password-log callbacks without retrying archives", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-callback-isolation-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
for (const name of ["a", "b"]) {
|
||||
const inputPath = path.join(root, `${name}.txt`);
|
||||
fs.writeFileSync(inputPath, `${name} payload`, "utf8");
|
||||
expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, `${name}.rar`), inputPath]).status).toBe(0);
|
||||
}
|
||||
const attempts = new Map<string, number>();
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
scheduleArchive: async (archivePath, execute) => {
|
||||
const archiveName = path.basename(archivePath);
|
||||
attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1);
|
||||
return execute(new AbortController().signal);
|
||||
},
|
||||
onArchiveFailure: (failure) => failures.push(failure),
|
||||
onLog: (_level, message) => {
|
||||
if (message.startsWith("Passwort-Versuch ")) {
|
||||
throw new Error("observer failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 2, failed: 0 }));
|
||||
expect(Object.fromEntries(attempts)).toEqual({ "a.rar": 1, "b.rar": 1 });
|
||||
expect(failures).toHaveLength(0);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("shouldSuggestRedownloadAfterCrossBackendFailure", () => {
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("reports redownload only after real Legacy and JVM CRC failures exhaust candidates", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = createEncryptedCorruptRarFixture(root, "serienjunkies.org", "cross-backend-exhausted");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir: path.join(root, "out"),
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: "",
|
||||
onArchiveFailure: (failure) => failures.push(failure)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
category: "crc_error",
|
||||
suggestRedownload: true
|
||||
}));
|
||||
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("keeps real Cross-Backend CRC recovery disabled when JVM stops before the final candidate", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "auto";
|
||||
process.env.RD_ARCHIVE_PASSWORDS = "";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-not-exhausted-"));
|
||||
tempDirs.push(root);
|
||||
const actualPassword = "cross-backend-early-secret";
|
||||
const packageDir = createEncryptedCorruptRarFixture(root, actualPassword, "cross-backend-not-exhausted");
|
||||
const failures: ExtractArchiveFailureInfo[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir: path.join(root, "out"),
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: actualPassword,
|
||||
onArchiveFailure: (failure) => failures.push(failure)
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toEqual(expect.objectContaining({
|
||||
category: "crc_error",
|
||||
suggestRedownload: false
|
||||
}));
|
||||
expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/);
|
||||
}, 30_000);
|
||||
|
||||
it("suggests recovery when both backends report CRC failure after every password candidate", () => {
|
||||
expect(shouldSuggestRedownloadAfterCrossBackendFailure("crc_error", "crc_error", true)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong_password", "crc_error", true],
|
||||
["crc_error", "wrong_password", true],
|
||||
["crc_error", "unsupported_format", true],
|
||||
["crc_error", "crc_error", false]
|
||||
] as const)("does not suggest recovery for legacy=%s jvm=%s exhausted=%s", (legacyCategory, jvmCategory, exhausted) => {
|
||||
expect(shouldSuggestRedownloadAfterCrossBackendFailure(legacyCategory, jvmCategory, exhausted)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJvmPasswordAttemptLine", () => {
|
||||
it("accepts only bounded attempt metadata without a password field", () => {
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5")).toEqual({ attempt: 2, total: 5 });
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 0 5")).toBeNull();
|
||||
expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5 secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("derives exhaustion only from a valid final JVM attempt", () => {
|
||||
expect(summarizeJvmPasswordAttempts(1, 3, false)).toEqual({ attempts: 1, total: 3, exhausted: false });
|
||||
expect(summarizeJvmPasswordAttempts(3, 3, false)).toEqual({ attempts: 3, total: 3, exhausted: true });
|
||||
expect(summarizeJvmPasswordAttempts(3, 3, true)).toEqual({ attempts: 3, total: 3, exhausted: false });
|
||||
expect(summarizeJvmPasswordAttempts(4, 3, false)).toEqual({ attempts: 0, total: 0, exhausted: false });
|
||||
});
|
||||
|
||||
it("redacts successful password payloads from JVM diagnostics", () => {
|
||||
expect(redactJvmDiagnosticLine("RD_PASSWORD c2VjcmV0")).toBe("RD_PASSWORD <redacted>");
|
||||
expect(redactJvmDiagnosticLine("RD_PASSWORD_ATTEMPT 2 5")).toBe("RD_PASSWORD_ATTEMPT 2 5");
|
||||
expect(redactJvmDiagnosticLine("RD_ERROR CRCERROR")).toBe("RD_ERROR CRCERROR");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("direct output scope", () => {
|
||||
it.each([
|
||||
["overwrite", "new", "overwritten", ["episode.mkv"]],
|
||||
|
||||
@@ -49,6 +49,35 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CRC-Check läuft", "CRC check running"],
|
||||
["Entpacken - Ausstehend", "Extracting - Pending"],
|
||||
["Entpacken - Warten auf Parts", "Extracting - Waiting for parts"],
|
||||
["Archive stabilisieren...", "Stabilizing archives..."],
|
||||
["Entpacken vorbereiten...", "Preparing extraction..."],
|
||||
["Entpacken wird neu gestartet...", "Restarting extraction..."],
|
||||
["Nested Entpacken...", "Nested extraction..."],
|
||||
["Umbenennen...", "Renaming..."],
|
||||
["Tonspur...", "Audio track..."],
|
||||
["Aufräumen...", "Cleaning up..."],
|
||||
["Verschiebe Videos...", "Moving videos..."]
|
||||
])("translates package runtime state %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Entpacken - 42%", "Extracting - 42%"],
|
||||
["Passwort gefunden", "Password found"],
|
||||
["Passwort knacken: 50% (2/4)", "Cracking password: 50% (2/4)"],
|
||||
["Entpacken (1/3) - Nächstes Archiv...", "Extracting (1/3) - Next archive..."]
|
||||
])("translates compact runtime text %s for visible and attribute surfaces", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
expect(translateUiText(`Bereit · ${german}`, "en")).toBe(`Ready · ${english}`);
|
||||
expect(translateUiText(`Ready · ${english}`, "de")).toBe(`Bereit · ${german}`);
|
||||
});
|
||||
|
||||
it("translates the complete history surface including status values", () => {
|
||||
const translations = new Map([
|
||||
["Alle Einträge", "All entries"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
@@ -13,6 +14,7 @@ import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const sessionRoots = new WeakMap<object, string>();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -36,6 +38,7 @@ function setup(settings: Partial<AppSettings> = {}): {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
sessionRoots.set(session, root);
|
||||
const events: NotificationEvent[] = [];
|
||||
const history: HistoryEntry[] = [];
|
||||
const manager = new DownloadManager(
|
||||
@@ -70,11 +73,15 @@ function addPackage(
|
||||
packageId = "pkg-1"
|
||||
): PackageEntry {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const root = sessionRoots.get(session) || os.tmpdir();
|
||||
const outputDir = path.join(root, "out", packageId);
|
||||
const extractDir = path.join(root, "extract", packageId);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const pkg: PackageEntry = {
|
||||
id: packageId,
|
||||
name: `Test ${packageId}`,
|
||||
outputDir: `C:/out/${packageId}`,
|
||||
extractDir: `C:/extract/${packageId}`,
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "queued",
|
||||
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
|
||||
cancelled: false,
|
||||
@@ -90,6 +97,14 @@ function addPackage(
|
||||
session.packageOrder.push(packageId);
|
||||
statuses.forEach((status, index) => {
|
||||
const itemId = `${packageId}-item-${index}`;
|
||||
const fileName = `${packageId}-${index}.zip`;
|
||||
const targetPath = path.join(outputDir, fileName);
|
||||
if (status === "completed") {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.mkv", Buffer.from(`video-${packageId}-${index}`));
|
||||
zip.writeZip(targetPath);
|
||||
}
|
||||
const downloadedBytes = status === "completed" ? fs.statSync(targetPath).size : 0;
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
@@ -98,11 +113,11 @@ function addPackage(
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: status === "completed" ? 1_000 : 0,
|
||||
totalBytes: 1_000,
|
||||
downloadedBytes,
|
||||
totalBytes: status === "completed" ? downloadedBytes : 1_000,
|
||||
progressPercent: status === "completed" ? 100 : 0,
|
||||
fileName: `${packageId}-${index}.rar`,
|
||||
targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`,
|
||||
fileName,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: status === "failed" ? "offline" : "",
|
||||
@@ -249,7 +264,7 @@ describe("authoritative package completion", () => {
|
||||
|
||||
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
expect(postProcess).toHaveBeenCalledWith(pkg.id);
|
||||
pkg.archiveOperations = [{
|
||||
id: "archive-2",
|
||||
@@ -293,7 +308,7 @@ describe("authoritative package completion", () => {
|
||||
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
|
||||
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
|
||||
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
expect(pkg.resultGeneration).toBe(8);
|
||||
|
||||
pkg.archiveOperations = [{
|
||||
@@ -897,7 +912,7 @@ describe("authoritative run completion", () => {
|
||||
postProcessGate = new Promise<void>((resolve) => {
|
||||
releasePostProcess = resolve;
|
||||
});
|
||||
manager.retryExtraction(pkg.id);
|
||||
await manager.retryExtraction(pkg.id);
|
||||
const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id);
|
||||
expect(retriedPostProcess).toBeDefined();
|
||||
releasePostProcess();
|
||||
|
||||
@@ -94,6 +94,104 @@ describe("download package presentation", () => {
|
||||
expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar");
|
||||
});
|
||||
|
||||
it("shows the active CRC check instead of a completed fraction", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("archive", "CRC-Check läuft", { status: "integrity_check" })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("CRC-Check läuft");
|
||||
});
|
||||
|
||||
it("keeps an active CRC check ahead of historical sibling extraction errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "CRC-Check läuft", { status: "integrity_check" })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("CRC-Check läuft");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["extracting", "Archive stabilisieren..."],
|
||||
["extracting", "Entpacken vorbereiten..."],
|
||||
["queued", "Entpacken wird neu gestartet..."],
|
||||
["completed", "Nested Entpacken..."],
|
||||
["completed", "Renaming..."],
|
||||
["completed", "Tonspur..."],
|
||||
["completed", "Aufräumen..."],
|
||||
["completed", "Verschiebe Videos..."]
|
||||
] as const)("keeps the active package phase %s / %s ahead of historical sibling errors", (packageStatus, postProcessLabel) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Fertig")
|
||||
], { status: packageStatus, postProcessLabel }));
|
||||
|
||||
expect(presentation.status).toBe(postProcessLabel);
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
expect(presentation.extractFailure?.id).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps a running download ahead of historical sibling extraction errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("Download läuft");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Entpacken - Ausstehend",
|
||||
"Entpacken - Warten auf Parts"
|
||||
])("keeps a running download ahead of the sibling state %s", (fullStatus) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("pending", fullStatus),
|
||||
item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 })
|
||||
], { status: "downloading" }));
|
||||
|
||||
expect(presentation.status).toBe("Download läuft");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "Entpacken - Ausstehend", "Entpacken - Ausstehend"],
|
||||
["extracting", "Entpacken - Ausstehend", "Entpacken - Ausstehend"],
|
||||
["queued", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"],
|
||||
["extracting", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"]
|
||||
] as const)("keeps the pending extraction state %s / %s visible on the package", (packageStatus, fullStatus, expectedStatus) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("archive", fullStatus)
|
||||
], { status: packageStatus }));
|
||||
|
||||
expect(presentation.status).toBe(expectedStatus);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Entpacken 42% (1/1) · release.part01.rar",
|
||||
"Passwort knacken: 50% (2/4)",
|
||||
"Finalisieren - 99% (0/1) · release.part01.rar"
|
||||
])("keeps the active extraction phase %s ahead of historical sibling errors", (postProcessLabel) => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", postProcessLabel)
|
||||
], { status: "extracting", postProcessLabel }));
|
||||
|
||||
expect(presentation.status).toBe(postProcessLabel);
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
expect(presentation.extractFailure?.id).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps the active disk wait ahead of historical sibling errors", () => {
|
||||
const presentation = buildPackagePresentation(row([
|
||||
item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"),
|
||||
item("active", "Warte auf Festplatte")
|
||||
], { status: "queued" }));
|
||||
|
||||
expect(presentation.status).toBe("Warte auf Festplatte");
|
||||
expect(presentation.details).toContain("1 Entpackfehler");
|
||||
});
|
||||
|
||||
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
|
||||
const items = [
|
||||
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
|
||||
|
||||
@@ -58,14 +58,22 @@ async function createFixtureAsar(version: string): Promise<Buffer> {
|
||||
}
|
||||
const validAppAsar = await createFixtureAsar("1.7.233");
|
||||
const staleAppAsar = await createFixtureAsar("1.7.232");
|
||||
const redistributionFiles = [
|
||||
const redistributionFiles = [
|
||||
"LICENSE",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
||||
] as const;
|
||||
] as const;
|
||||
const jvmRuntimeFiles = Object.freeze({
|
||||
"resources/extractor-jvm/classes/.source.sha256": "source-digest\n",
|
||||
"resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x01]),
|
||||
"resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x02]),
|
||||
"resources/extractor-jvm/lib/sevenzipjbinding.jar": Buffer.from("sevenzip-binding"),
|
||||
"resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar": Buffer.from("sevenzip-platforms"),
|
||||
"resources/extractor-jvm/lib/zip4j.jar": Buffer.from("zip4j")
|
||||
});
|
||||
|
||||
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||
@@ -73,7 +81,7 @@ function writeFile(rootDir: string, relativePath: string, content: string | Buff
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||
for (const relativePath of redistributionFiles) {
|
||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||
let targetPath: string = relativePath;
|
||||
@@ -104,21 +112,44 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||
if (omittedName !== "app_icon.ico") {
|
||||
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
|
||||
}
|
||||
for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) {
|
||||
if (path.basename(relativePath) !== omittedName) {
|
||||
writeFile(outputDir, `resources/app.asar.unpacked/${relativePath}`, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeJvmRuntimeFiles(rootDir: string, packaged = false): void {
|
||||
for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) {
|
||||
const targetPath = packaged
|
||||
? `win-unpacked/resources/app.asar.unpacked/${relativePath}`
|
||||
: relativePath;
|
||||
writeFile(rootDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "") {
|
||||
function createArchiveCommandRunner(omittedName = "", corruptArchiveName = "") {
|
||||
let currentArchiveName = "";
|
||||
return (command: string, args: string[]): CommandResult => {
|
||||
const archivePath = args[1] || "";
|
||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||
if (!outputArg) {
|
||||
return { status: 2, stderr: "missing output directory" };
|
||||
}
|
||||
const outputDir = outputArg.slice(2);
|
||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||
writeArchivePayload(outputDir, omittedName);
|
||||
}
|
||||
const outputDir = outputArg.slice(2);
|
||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||
currentArchiveName = path.basename(archivePath);
|
||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||
writeArchivePayload(outputDir, omittedName);
|
||||
if (currentArchiveName === corruptArchiveName) {
|
||||
writeFile(
|
||||
outputDir,
|
||||
"resources/app.asar.unpacked/resources/extractor-jvm/lib/zip4j.jar",
|
||||
"corrupt-zip4j"
|
||||
);
|
||||
}
|
||||
}
|
||||
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||
};
|
||||
}
|
||||
@@ -139,15 +170,18 @@ function createReleaseFixture(): string {
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "Multi-Debrid-Downloader"
|
||||
},
|
||||
files: [
|
||||
files: [
|
||||
"build/main/**/*",
|
||||
"build/renderer/**/*",
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
"package.json"
|
||||
],
|
||||
asarUnpack: [
|
||||
"resources/extractor-jvm/**/*"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "LICENSE",
|
||||
to: "LICENSE"
|
||||
@@ -189,6 +223,8 @@ function createReleaseFixture(): string {
|
||||
writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||
writeRedistributionFiles(rootDir);
|
||||
writeRedistributionFiles(rootDir, true);
|
||||
writeJvmRuntimeFiles(rootDir);
|
||||
writeJvmRuntimeFiles(rootDir, true);
|
||||
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||
writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon");
|
||||
writeFile(rootDir, "win-unpacked/resources/app.asar", validAppAsar);
|
||||
@@ -347,15 +383,74 @@ describe("public release metadata", () => {
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||
});
|
||||
|
||||
it("rejects build metadata that omits the project license", () => {
|
||||
it("rejects build metadata that omits the project license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||
});
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not unpack the JVM runtime", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
delete packageJson.build.asarUnpack;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/asarUnpack|JVM runtime/i);
|
||||
});
|
||||
|
||||
it("rejects a missing JVM class in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"classes",
|
||||
"com",
|
||||
"sucukdeluxe",
|
||||
"extractor",
|
||||
"JBindExtractorMain$Backend.class"
|
||||
));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Backend\.class|missing/i);
|
||||
});
|
||||
|
||||
it("rejects changed JVM bytecode in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"classes",
|
||||
"com",
|
||||
"sucukdeluxe",
|
||||
"extractor",
|
||||
"JBindExtractorMain.class"
|
||||
), "stale-bytecode");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|JBindExtractorMain\.class|SHA-?256|content/i);
|
||||
});
|
||||
|
||||
it("rejects stale extra JVM bytecode in the unpacked application", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
writeFile(
|
||||
rootDir,
|
||||
"win-unpacked/resources/app.asar.unpacked/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/Stale.class",
|
||||
"stale-bytecode"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Stale\.class|unexpected/i);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
@@ -429,14 +524,26 @@ describe("public release metadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an archive whose nested application payload omits a license", () => {
|
||||
it("rejects an archive whose nested application payload omits a license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
expect(() => verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||
});
|
||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||
});
|
||||
|
||||
it("rejects a portable archive whose JVM runtime differs from the repository", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
expect(() => verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner(
|
||||
"",
|
||||
"Multi-Debrid-Downloader-1.7.233-portable.exe"
|
||||
)
|
||||
})).toThrow(/portable|JVM runtime|zip4j\.jar|SHA-?256|content/i);
|
||||
});
|
||||
|
||||
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
@@ -122,11 +122,11 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns single archive item when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
it("does not guess a different single archive when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when items have no archive extensions", () => {
|
||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||
@@ -217,6 +217,17 @@ describe("resolveSelectedArchiveSetsFromCandidates", () => {
|
||||
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]);
|
||||
expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]);
|
||||
});
|
||||
|
||||
it("resolves a uniquely matching pathless legacy item", () => {
|
||||
const selected = resolveSelectedArchiveSetsFromCandidates(
|
||||
["C:\\Downloads\\Episode.E01.rar"],
|
||||
[{ id: "legacy", fileName: "Episode.E01.rar", targetPath: "", status: "completed" }] as any,
|
||||
new Set(["legacy"])
|
||||
);
|
||||
|
||||
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.rar"]);
|
||||
expect([...selected.itemIds]).toEqual(["legacy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markPlannedHybridArchiveItemsPending", () => {
|
||||
|
||||
Reference in New Issue
Block a user