From 291c80c7fc3d150791d54f594e2f5d5dc27c902e Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 7 Mar 2026 22:33:10 +0100 Subject: [PATCH] Fix auto-recovery re-download loop: check archive magic bytes before forcing re-download Files with valid RAR/7z/ZIP signature are not corrupt (wrong password), only files with invalid signature get force-redownloaded. Co-Authored-By: Claude Opus 4.6 --- src/main/download-manager.ts | 37 +++++++++-- tests/download-manager.test.ts | 110 ++++++++++++++++++++++++++++++--- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index ccd0b04..8c95ae4 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4232,12 +4232,39 @@ export class DownloadManager extends EventEmitter { .filter(({ state }) => state.reason !== "ok"); if (corruptArchiveItems.length === 0) { - // The extractor confirmed corruption (CRC error) but all files look - // correct by size. This happens when content is corrupt despite having - // the right byte count (e.g. network corruption during download). - // Trust the extractor verdict and force re-download of ALL archive parts. + // All files have the expected size on disk. This can mean either: + // (a) content is corrupt despite correct size (network corruption), or + // (b) archive is valid but password is wrong (e.g. header-encrypted RAR). + // Check the RAR magic bytes of the first part to distinguish: + // valid signature → password issue → don't waste traffic re-downloading. + // invalid signature → genuine corruption → force re-download. + const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); + let hasValidSignature = false; + if (firstPart?.state.diskPath) { + try { + const fd = fs.openSync(firstPart.state.diskPath, "r"); + try { + const header = Buffer.alloc(8); + fs.readSync(fd, header, 0, 8, 0); + // RAR4: 52 61 72 21 1a 07 00, RAR5: 52 61 72 21 1a 07 01 00 + // 7z: 37 7a bc af 27 1c, ZIP: 50 4b 03 04 + hasValidSignature = + (header[0] === 0x52 && header[1] === 0x61 && header[2] === 0x72 && header[3] === 0x21 && header[4] === 0x1a && header[5] === 0x07) || + (header[0] === 0x37 && header[1] === 0x7a && header[2] === 0xbc && header[3] === 0xaf) || + (header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04); + } finally { + fs.closeSync(fd); + } + } catch { /* can't read → treat as corrupt */ } + } + + if (hasValidSignature) { + logger.warn(`Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - Dateien korrekte Groesse, Archiv-Signatur gueltig (vermutlich falsches Passwort)`); + return 0; + } + logger.warn( - `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber Extractor meldet CRC-Fehler, ` + + `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` + `erzwinge Re-Download aller ${archiveItems.length} Parts` ); corruptArchiveItems.push(...inspectedArchiveItems); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index fdee9ff..ab4377a 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2042,25 +2042,25 @@ describe("download manager", () => { expect(session.packages[packageId]?.status).toBe("queued"); }); - it("requeues completed archive parts on CRC error even when file size matches", () => { + it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const session = emptySession(); - const packageId = "crc-clean-pkg"; + const packageId = "crc-corrupt-sig-pkg"; const createdAt = Date.now() - 10_000; - const outputDir = path.join(root, "downloads", "crc-clean"); - const extractDir = path.join(root, "extract", "crc-clean"); + const outputDir = path.join(root, "downloads", "crc-corrupt-sig"); + const extractDir = path.join(root, "extract", "crc-corrupt-sig"); fs.mkdirSync(outputDir, { recursive: true }); const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; - const itemIds = archiveNames.map((_, index) => `crc-clean-item-${index}`); + const itemIds = archiveNames.map((_, index) => `crc-corrupt-sig-item-${index}`); const archiveSize = 64 * 1024; session.packageOrder = [packageId]; session.packages[packageId] = { id: packageId, - name: "crc-clean", + name: "crc-corrupt-sig", outputDir, extractDir, status: "extracting", @@ -2073,7 +2073,8 @@ describe("download manager", () => { for (const [index, archiveName] of archiveNames.entries()) { const targetPath = path.join(outputDir, archiveName); - fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, index + 1)); + // Write garbage content (no valid archive signature) — simulates corrupt download + fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA)); session.items[itemIds[index]!] = { id: itemIds[index]!, packageId, @@ -2121,8 +2122,7 @@ describe("download manager", () => { "hybrid" ); - // CRC error from extractor IS evidence of corruption — even when files - // have the right size, content may be corrupt. Must force re-download. + // Invalid archive signature = genuine corruption → force re-download expect(changed).toBe(2); for (const itemId of itemIds) { const item = session.items[itemId]!; @@ -2135,6 +2135,98 @@ 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)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "crc-valid-sig-pkg"; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "crc-valid-sig"); + const extractDir = path.join(root, "extract", "crc-valid-sig"); + fs.mkdirSync(outputDir, { recursive: true }); + + const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-valid-sig-item-${index}`); + const archiveSize = 64 * 1024; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc-valid-sig", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + // Write file with valid RAR5 signature — simulates wrong password, not corruption + const content = Buffer.alloc(archiveSize, 0); + Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content); + fs.writeFileSync(targetPath, content); + session.items[itemIds[index]!] = { + id: itemIds[index]!, + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + 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: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error in the encrypted file", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "Can not open the file as archive" + }, + "hybrid" + ); + + // Valid RAR signature = file is structurally intact → wrong password, don't re-download + 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); + } + }); + it("does not treat rev files as ready archive parts during disk fallback", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);