From 808c2a432ccf8763a449702e74f4ff2c33bace00 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 23 Aug 2026 03:56:24 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 44 + package-lock.json | 4 +- package.json | 7 +- .../extractor-jvm/classes/.source.sha256 | 1 + .../extractor/JBindExtractorMain$1.class | Bin 254 -> 207 bytes ...mbiguousPasswordOrIntegrityException.class | Bin 0 -> 482 bytes .../JBindExtractorMain$Backend.class | Bin 2310 -> 2113 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 1996 -> 1978 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 7083 -> 6817 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2059 -> 1882 bytes .../JBindExtractorMain$DaemonJsonParser.class | Bin 0 -> 2662 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2330 bytes .../JBindExtractorMain$OutputTarget.class | Bin 885 -> 756 bytes .../JBindExtractorMain$ProgressTracker.class | Bin 1387 -> 1186 bytes ...indExtractorMain$RawArchivePlanEntry.class | Bin 1106 -> 965 bytes ...xtractorMain$RawArchivePlanInvariant.class | Bin 3780 -> 3555 bytes ...BindExtractorMain$RawArchivePlanNode.class | Bin 1786 -> 1637 bytes ...ExtractorMain$SevenZipArchiveContext.class | Bin 1664 -> 1487 bytes ...ExtractorMain$SevenZipVolumeCallback.class | Bin 3667 -> 3645 bytes ...indExtractorMain$TargetPlanInvariant.class | Bin 2491 -> 2298 bytes .../JBindExtractorMain$TargetPlanNode.class | Bin 1284 -> 1131 bytes ...ExtractorMain$WrongPasswordException.class | Bin 458 -> 377 bytes .../extractor/JBindExtractorMain.class | Bin 28842 -> 27807 bytes .../extractor/JBindExtractorMain.java | 551 +++++-- scripts/build-extractor-jvm.mjs | 213 +++ scripts/verify_public_release.mjs | 112 +- src/main/download-manager.ts | 1387 ++++++++++++----- src/main/extraction-ipc.ts | 34 + src/main/extractor.ts | 636 +++++--- src/main/main.ts | 10 +- src/renderer/i18n.ts | 88 +- .../views/downloads/DownloadsTable.tsx | 9 +- .../views/downloads/package-presentation.ts | 43 +- tests/download-manager.test.ts | 883 ++++++++++- tests/downloads-view.test.tsx | 57 + tests/extraction-ipc-contract.test.ts | 161 ++ tests/extractor-jvm-protocol.test.ts | 206 +++ tests/extractor-jvm.test.ts | 772 ++++++++- tests/extractor.test.ts | 354 ++++- tests/i18n.test.ts | 29 + tests/notify-hooks.test.ts | 33 +- tests/package-presentation.test.ts | 98 ++ tests/public-release-metadata.test.ts | 147 +- tests/resolve-archive-items.test.ts | 21 +- 44 files changed, 4922 insertions(+), 978 deletions(-) create mode 100644 resources/extractor-jvm/classes/.source.sha256 create mode 100644 resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$AmbiguousPasswordOrIntegrityException.class create mode 100644 resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class create mode 100644 scripts/build-extractor-jvm.mjs create mode 100644 src/main/extraction-ipc.ts create mode 100644 tests/extraction-ipc-contract.test.ts create mode 100644 tests/extractor-jvm-protocol.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index afdaac1..c09874f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.63] - 2026-08-23 + +### Archive recovery and password handling + +- Detect English, German, and misdecoded CRC and checksum failures from native and JVM extractors. +- Corroborate archive corruption across independent extraction backends and re-download only the named damaged multipart volume once per package generation. +- Keep encrypted archives and genuine wrong-password results out of destructive re-download recovery. +- Try every real RAR5 password candidate even when archive metadata does not reliably report encryption. +- Distinguish corrupted encrypted RAR and ZIP data, missing volumes, locked files, and open failures from incorrect passwords. +- Remove redundant full password passes and bound package recovery to one deduplicated retry set. +- Report password-attempt progress without exposing candidate values and isolate failing progress observers from the extraction process. +- Parse JVM daemon requests safely for passwords containing JSON metacharacters and Unicode. +- Remove successful-password payloads from the JVM protocol and redact diagnostic buffers before any error can reach logs. + +### Extraction lifecycle and data safety + +- Resume extraction automatically after temporary disk-capacity waits, including manual archive selections, with generation-, owner-, and timer-safe retry plans. +- Keep Stop, shutdown, disabled packages, stale callbacks, and changed run owners from restarting old extraction work. +- Preserve retry, pacing, disk, and provider cooldowns across Pause and Resume. +- Make standalone manual extraction stoppable through the normal toolbar lifecycle. +- Reject missing, incomplete, ambiguous, or non-archive manual targets before starting any selected batch. +- Resolve opaque archive files by signature without mutating files during preflight and commit valid batch plans atomically. +- Keep full-package cleanup enabled for complete manual extraction while protecting partial selections and packages with open downloads. +- Convert unexpected post-processing exceptions into deterministic extraction failures instead of successful package results. +- Requeue interrupted integrity checks after restart and clear stale transient post-processing labels. + +### Cleanup and queue isolation + +- Preserve independent downloads that happen to share the same package filename during startup recovery. +- Honor recycle-bin cleanup for previously extracted archives instead of deleting them permanently. +- Protect files owned by other packages in shared output folders, including archive companions, and verify file identity immediately before cleanup. +- Reset a corrupted download only after its old file was successfully removed or quarantined. +- Keep selected download runs scoped when packages are disabled, added later, or waiting for unrelated extraction work. +- Prevent selective starts from launching or stopping post-processing for other packages. + +### Status, IPC, and release integrity + +- Prioritize active downloads, integrity checks, extraction, password search, finalization, cleanup, and disk waits over historical sibling errors while retaining full details in tooltips. +- Keep pending, waiting-for-parts, active extraction, and historical failure states visually distinct after restart. +- Translate the complete dynamic extraction status set in German and English. +- Validate manual extraction IPC requests in a side-effect-free trusted handler and propagate stale or invalid selections as actionable errors. +- Compile the bundled JVM extractor before every main and Windows release build and reject stale source/class combinations. +- Verify every shipped JVM class, source digest, and library byte-for-byte in unpacked, Setup, and Portable release artifacts. + ## [2.0.62] - 2026-08-23 ### Manual extraction diff --git a/package-lock.json b/package-lock.json index f3cfa67..e1a9483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.62", + "version": "2.0.63", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.62", + "version": "2.0.63", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 3162f80..6280c81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.62", + "version": "2.0.63", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", @@ -11,8 +11,9 @@ "visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort", "dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch", "dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts", - "build": "npm run build:main && npm run build:renderer", - "build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap", + "build": "npm run build:main && npm run build:renderer", + "build:extractor-jvm": "node scripts/build-extractor-jvm.mjs", + "build:main": "npm run build:extractor-jvm && tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap", "build:renderer": "vite build", "start": "cross-env NODE_ENV=production electron .", "test": "npm run test:client && npm run test:backup-api", diff --git a/resources/extractor-jvm/classes/.source.sha256 b/resources/extractor-jvm/classes/.source.sha256 new file mode 100644 index 0000000..fc0c605 --- /dev/null +++ b/resources/extractor-jvm/classes/.source.sha256 @@ -0,0 +1 @@ +77818e292a3c70c1cf1cf8ab12f02e8e05e0e9a5e4140108465d62cf18ecf30f diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class index e3c3b5e77c4124c4d2a231e300c1e89a158d4025..3ec1fcf0cabaabe16927dc7caedb1092ae802064 100644 GIT binary patch delta 69 zcmeyzc%G5#)W2Q(7#J8#7&s?#`3Up7<|XIk7iZ?B`=*v;5afS8Mc5eR`mfCB)#)ebcP delta 122 zcmX@l_>Yn6)W2Q(7#J8#7`P{L`Pgy==a&{Gr@Ce4q%tyydpTw1rMOm<6eT8?&rSj{5XQgqZvnv|ATgXgXricD51ho9=!ywMgISe(*=?Y>w506{`c|GyJoo@U zl(8I)d(=7f`(`qe`L+H0dj9~>Mk9|DLF0wb+tZ^fHi?)z+4g*|>-z)0hzzn0awRy( z6V#^kiMkOLW4F5u*xYa_2y&-faC1h`+PnU3xHF22aVJr^pD4MYqlk6(hYpGaejsPA zj)V9yWKq0iE?XK!10$8&zu+S5{-igQi=Fe?h>v3#>szYzLaK0}dcv@=;^sL)Q_)Ou z8V#A!mhPc=7fmVMu);Tj3D%V9ORXrz5CK~3 zVrTfAsX4PDq;}N?Pc#jAk%YxIF2^!mW%!-xJIT7@L_CjDx$Cg}OI7~Bu6Qn|oaJO) wG`*u3zM!%bJk1{axxHvM-Bt}5eoim1bKP@dT0ssI2 delta 496 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 AzW@LL literal 7083 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 Du-xhr diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index 0317fda16a330f76625ce820265cf01eaf170bd6..8a7b69f4f43a81a99231f62d5c22b8a9bc175bff 100644 GIT binary patch delta 264 zcmeAcxW&hH>ff$?3=9k=3~3v=99bCsCm&=MnOwwjS(wK&FE6#oIVZ8WIJKCaA%&4a zXQF`4npw=oDFU=Z2>=5sPgF+?*kF+>4*Vn7}|U{_>Ro*c!l#LvLM!H@t{2vK1*c{;nLG)ODN UfJBBQpg~+fSw@CrhExYB0H-=Ay#N3J delta 456 zcmZWlO-lk%6g_X|Iir$gW&~zmZUU7e3c^}srk0`!eQdJQQKy=)5k`%6wQo5;BHCqv zL5rwW&^Fq%>7PW`v5mBN@7{OLx%V92SO2x!{r>ax3}6A841pnHDm>Ck34CZEqv`5q z^Q@#AJ4&glGUUux(Q6j@$C7iq)-=j$QtQAY>6oTh5*H0aDK~0HR?+mCQ{`M?=u7Fk zYOGb2dR?u{IA#db`mkx(Hs%6gWD7X&;c8>>1)e*4G34o%Iviht13v6Sz#d@%^V>Fj z*~=dgZX@XxNHmTKh?cZdMWW?ZkR7vj+=3GZ6oPNC zdnRw-3brXjUfK-_R>lCy!4vTEB*yqUrg#eTJdH)3L6m0^=Q$M32Wg7Unx9f&v^Rkr u3K@2-woFNmEO&Vi*@8#m;;ZoS7^dkL#J_+gl&zo@s8pRa&2WU1sP7l@MM?+& diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$DaemonJsonParser.class new file mode 100644 index 0000000000000000000000000000000000000000..fd2089ba41f6a36dbfe2dcce966860da96d74038 GIT binary patch literal 2662 zcmb7G+fNi%82_E!#bJj52UHXnEaIiG16^yiUbs|5tRUWsmwKy?yW=vl%&xPu4W?<* zr0rv0`qF5cptK1OwFxE)wWjSuADZ;7?|tc?VMC?AGc(Jg(1((IdoJ@`e&6@~&f)z2 z;>Vu>>_JII1W^Ss4OLhppx-h-G*da#$)---xMih1fi*q0V|#r9(e{q>)uabZIQ> zqU8!y*-oLvNDU*E=%b!%JK1iPk#r695?fty+%8-gvpuU=Fw>U&v`)i%Y#>H!vfzV_ zwGVWhSFll_-uG4V>|E-YSsXiI7F0AMDy=qaNT69jN#~tnX~GhaW|!orEgD{vu~gT( zU9w$k(82wQiZ|IOh&lVbmo!|)6?WYx!a2uI=QGw| zF>MyeLLM;2`T*AyT-RXYM!9f)+5?2dL**ZsjGeWKuBv@tsAEV0BezxR^Iu)9#!Z1O z=bUWG%6WFyY7I_e*YN16lU@DJP1nrwP^B>@?*jW3W(BEqcbsQU28fxo9QDlfI2n1? zypiKNI2+|#qrOZupVrjsCJ{&NtYdm5m)iSY{ob>pZr#gvLSpyP%Myi`4$La1Qc)Xn z9LpWZnZ=@2R8SP?{67~J8T0B@gsw*|)ey91F$`iLk{6;f4jT;z6nT^@lPCg@H2okbKej-S)-+NA!` zJQ|*%@!~u-jRZR%&!g!s;#{;Cv;1v65$qqNW-x1;$M(BW<+m9q99}46gbaR2j2|g!mLakus2_FUe&f{#*hPmqA+;^frY-3kvOO%F*qhW@0+y|xK?g>Xj|t;3;&V7QEjG$o@*=k`#jZcWkvW{~ZPwfFquQ9m zd$Y}Y>vUM7X8p<(G^2SA=Pt}F{V}(CSvyFQ86A{{jr5)-^2f@`;CkHO~=~o#hP| z6rG9qMaM~5AmzHO2@CygSfXf8w6|HA#;%pM()Ay3wXfytP&1)SwtUj_Swze%{*n0d z;S!p)h;8@>Q6We_@tlV+BL97d!MnKPV+EUh9#{H26*{e)#3;l@D)W^QnqK8OlML;b zxI@&_*z4ou4tdd&&|9a_n9$=>&=Y#?6x4*SP9gTl=Z{aHnZ;i#F;u!-z;5w3G`i;V zAPjYoD;a9rOQ9Nj&W$ks!Gc6`T#evA-uV=}=Leh*dAhZQ8TiYarI zcNm{dq2aa=K%F#fy0=oY6AC$dZ-b`K#xG2fNBRj z%m`BkGpGWP8ySJxShcn=Fv6tG87#0!!(C{JRT}8NY={@F7_6~qhg)vLV2eeX8*DjH j!VYMN5(5h`XzUpr8KfALfwYr@6oVX)b_UWe5KjUCk{vbF delta 636 zcmZwE%}N4M7zW_?Oh-q?U__xp$e>nP+Mi{AmT6i_wJ|DM1f$JjVj5x8sMWM=8}7P@ zNGgIBT|jryx_gMeN{HxNy$8;7e%^C#!tW97_2c;kKnkUfcA@C{mgSf`t<$RM?iyA5 z{!)p_v{tFN+?rXmY*V1WoU@$c!gm z`6+2xD1Zq}cIgm$zNs1?JA6dsj=@vE*!!b(PETWo-8#tO6lPhha(E7DudBZbQWq-t z0KwULEHIR6V$RxtsU R$pL9Dpe`l`c?LxWDF9!uE|LHM diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 8c8a0c761be0b6a863f3b62ccdddc6ba3a011d63..c5e5778883cbb68ada2aef628717df4e1f108ebb 100644 GIT binary patch delta 211 zcmaFOwTP4J)W2Q(7#J8#7y>qO&1agto5@g^$1^W4wa7UqvA8(3n4Q6okwIsor_SUc zW@ko~$1~x6`?F>>Ifh>@`CxaVQK5DWEi@y^i zgBMVog@K7(7AWft*DcM!4A#xBrL~1Ycq@armOu*J7$yc823e>v0h1rHILkwf0b2cs WL4*+~;{!C53rKS?_%ir2NC5yL?I9rm delta 405 zcmY+9%}N4c6o%jV#^cWvS}6@lLKtm^&;rAvKYvp5zp`Dl74X500HrVM{6J zTwXqtaLA5%5I`}wXcaz+V~*CK(mIwF| z7pU4y`=8#=0nh=wl!Kr0TxJoGNrfvoX;-E&?hs}ssE8>lAxLF}sEP%uAxZU-oB#QZ Vki>v0Hxa0z&QoEO7e_cj_!oc*KDht@ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class index 363011931ea644b06a37605c877afa77d62b8057..4ac22ebfd8f6685dd9c04fbd016038b240cdfbc7 100644 GIT binary patch delta 227 zcmcb_ag?3w)W2Q(7#J8#7*sZLDKc)hWmIDn=JCwSOD%HFNh~f-EoNs>U}VslY`~;5 z`6QDBi!vjF9+0CqS(w?7QDL$xvwS^+00)BvgCqkZgE-J6K_JZtq*=ALFz~GbGC(iWw1)KL!^O5F~~x7i!iVNc?=93TG~3Y+Zh-)0@>0GKuI|uLx_P12;~_R Kfz~NOOa=feZ5%WJ delta 359 zcmX@geu;zY)W2Q(7#J8#7}Pg%DKhf&`();&`jzG;r51%GCgp6_VANzZ=L*g*ElN&x z%gjk-WDxgq%FIh~ttcro-~Ss={_q*=ALFz~GbG9X&nK!VJDK&8?QGC(%EQtk~{l*+=C=A#(Pj;R)C zAA=m!SOo?akUJSTw6t|(w=*zqL^qj%i9sGH#>Ak&pa`UefGj2kB?c9sA5?*S1_2HL DqS`eC diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class index be482e331045897b829be2b95e756c93b8e40678..e0d910df107b86277a7e6ba048065b0822880385 100644 GIT binary patch delta 216 zcmX>i`&gRm)W2Q(7#J8#7^ZLJy2d&A6Q@37&txU8Xki}Dyu8#R=bXgi;?!bxhKY;} zI+Gb$bS6LMGGv*`$e;)0=uUR!wqTT>T*ocP4>W_JgQ1gwiJ_f=fk|%jZtk6o%!~|O zlYjH7aCNgYn6NW&u`_f}*5uQOhjJMh)D*QDdVv~Q7#JBe1sE7u8Tx=MpaBemK$;0i vvubT;VB82~FaQPmfus^pF%!cChDi)k3|v5ZGLW7Ek`~}#umSR?F-QRbWnL;{ delta 465 zcmYL_&r4KM6vw~kow29aF~?~UDT7!MI-?^Kvxtfont_sS6e?8o&CDyF&b-n0X37Xn zS8Dz!yJ@C7p-tdILZn1jty;7Z+=&bS0{?-*Qv_}9_kO?UaPRq?)4HD#^{e#b06-G! z+v=#Iy85Bx+K-C4lJc1q0VV_<6<~$C06em1}GxktopRRC`B5cz& zen~MNr%rxFUHqD|JV6sYNo(aiMO!?Lc^$91t`?4)W2Q(7#J8#7z{RY-DTYTld+yjfX6d0FSW=yC$YFVwV0hjbMkr?Hx?a6 z28YQTnXM-mu&_?HWR+miXJpU=3g}L5VzpvaoV=G+p`Jm2fkBW#j)9Ru7HFmr0}}%? zkY>};-p;_k5y)Z?;9!tvP=Lw{0%=B|Agk6E2JQ_|X+;JlEYj>SX=Mf#EYge+X(tW_ tRR%SvyfDyiCZM70TG~-tq2>dn)PX`$U?~j-Eg(yqL4!dTNb5oD2LP2CCV~I} delta 420 zcmZvWy-LGi7=_Q5FKJ4wB#KZF+SGqd()hdBNfd>Oy6B|S*P0;`Q%G8I6Ql_0m}_vi zs0e~yfcN00H{l?@Xst`8b2#UDIqygAvtN7tdU^q{gvE~b#&mtl551k%am|bNL@l^` zXXjEy+ikR>y0_s69z%X})ejHXPn(gbHyhEm@I&`VoCtff$?3=9k=45k~oW-(9R#@r*!b1j@5&ZDC;C b0Azr)830Kk1|}dhWH4rsVo(RtCJa&l8hsd< delta 308 zcmXX?xlY4S5S-_D6JY}8l2C%ufr1dkoC#1M4iO*%3ZNhy#f$TV92^^LC!)+BP`E=h z`~gT*6!g&W1^fzPIhvi(Xl7@%AKjfE6?4!Zs)vvGVk)Aify7x8L4j9p`cBas$+*Q(cnh4eC*P( z8+*ierI3pE>DZ40B2V?Hhjbjq5mBd3aUH{PjuScrPKq{lwzD0lInL;ai8tybM?22y zIEVA%hgw*5QO6~y3`#>V95Fa9GmKp0Z}Izr{!pWD@oF<1^{)&XF3iJK9oI6YhTr6f zi#lFrzsXQ8zR-m)@I3o%hSK!Q2R@eF72kM31RR6q45Om{NZru;k7_iUmJHos$u#lF zktQTtDuG=3GzZ>v{p5Tp^JPkY$+5Yi^Ij?<|GIsH$|DhLbi04Qh0!enIV%zm*$yh z4aox2Dcp@7VjvM-5Sl`^O$=%)SdplA<+b)8RMd@7KNaSp9?R)i-IHPeiE)I^exZyQ za4bv_dkdf5+>%l~k~GgwEi35s;t@S1(l9z%beOyh8T9=p)k?X?n3!oBLqr+XYen`R zn8a(U943EHFf&s_xak^&(&im2i>G+@k95fDwxJv|GU*RQ)=5n}y{|FMo5ZY^BsO@U#;pZk`hB9reHccGXx8yx;Y%}lADFw ZP6|s+I|qP)v=A={QlkbJs_+uIzX8W)^dA5K delta 1241 zcmaiz{ZmwB6vsc$vd_KC?uC_JhL=DSM*^0YP?Bk)X<{;_0;VZ{X@IGfc66I{*H%Ex zY(Xqvs#Uv+g4(M|R@Q>LfF`Ct*i1FOIsFO!oWJH&+qrlBf!~_B&vTx8&bjA&zu)KF zE0s4Z{ont4X%T4U(u{vt2mB=1YVxW@(*8>?U!7pkVu)dd5sSn(4Q^Hx)Yd)ar-Ts; z%?`!Awf1PiJ@&Ikq4B!KF5a+TG^%|GMlHtJZSOaljXf4`@|L}5)CBgi-{gSB+q`4{ zX*^n(=Ag+Ti^KLq<|uF%)_rme>|CnNr;SN_aTj91F)cHE-CQ zOl%Ktj70);aMI$`ozzG;Y4U-+Xtt=2>|17=_Y*~vea$SlpYWBcPwhTmG#KoP#v?sL zTQ)|L8>Gbf>prF0O)e^ey-;T+@ADxaa~iL`)nC5iE{X}FI3wzMn8qho(=DFQBB~&$3Kkq*7;DrRQWY=4sOlJghI%p}(P1Um>g)iR!Dw^fgBGb#{txOn>L9{5WQ@ z2wdw5T#^&reEDg~I}DQx^4RI3(v)bPaH-3m=aPj9v0m}D31t5QQ9{)IaIGxhpc8uf4M uS*LHhm{&TJ5j06VaP#w!mg#WjVe&TImRcUN0?Wf3^J4u1GeEn5tN0He&itAH diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class index ba1fb3807a5075b5459c8395ed7f122b2e203714..9ea727989dd467b090a1d2424996df45d4f8b39e 100644 GIT binary patch delta 197 zcmWlSI|{;35Jm6ALmO-V@wWvRVDX+k%d=AK8Iu$8(ELgWDsDq?aCzR(gny*nrqkq<~^ S1}I3-z)nmH_rgOSd&NI5V;j8y delta 395 zcmYL^O-lk{6ot<_*Jf-G)G{QYji3ubiEvRM1VJTHZF18l7o&ZF#R#SUqr*qmU=(FW~ zQa2M?GH3P65s|Utav-4270YHypD=Q|fOl%lD5S^NOQx1870nsVC`2+^MYFQR zSX)xqdXxRJFl-Qfz#VEr3ALflizXZmIBUEV(D_v%0kj7j?enyVC7#n9e$@TSkl2*| zN(=CEsR{=jz(a@d(Gj9_j2N9@luj{8XVB>!6}rF%U1FQAutV3_zt85>180ff$?3=9k=3|bqxq8S;DC-XAtZ(h!*!YIt+nU|MZff$?3=9k=47wY+q8UZ`eKPY>{YrC_Qj0ghlQ5&sWFF?MdL0I421cO00$SS{gtT@uh(>N_5Z}tcz7Z(K$RG_Q zxfqxj7?}4murTiv0IFk<0g6ij#btpsP=10GXAqn$!&s;)#J~iE3=C{q c+S?fzHv(A<42%r?K#~hcvoi=V2r)EGxPMa63Y@98F)PN@=}YOa}tY-Q;XRdL>L)#CWh-w7GiX= zRbpTQLIwslE$!_Lj2nS0Mh1Q$$psQ+Rb^mhRRgjFfFeu`f($}n8Ab+n24MzK1}OlM CB_t#O diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index c4437944ec8a11bba0dedd9cf9b56dae413b40dd..fd08e27a609973f4470a8ed66b376cd8241e3770 100644 GIT binary patch literal 27807 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 P`DiS|bvdppX!idCva8`s literal 28842 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 TL~Bt+6kh$Q@G6pj2p^f3iC diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 60c2826..d7cc3d0 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -2,7 +2,8 @@ package com.sucukdeluxe.extractor; import net.lingala.zip4j.ZipFile; import net.lingala.zip4j.exception.ZipException; -import net.lingala.zip4j.model.FileHeader; +import net.lingala.zip4j.model.FileHeader; +import net.lingala.zip4j.model.enums.EncryptionMethod; import net.sf.sevenzipjbinding.ExtractAskMode; import net.sf.sevenzipjbinding.ExtractOperationResult; import net.sf.sevenzipjbinding.IArchiveExtractCallback; @@ -106,115 +107,234 @@ public final class JBindExtractorMain { } } - private static ExtractionRequest parseDaemonRequest(String jsonLine) { - - ExtractionRequest request = new ExtractionRequest(); - request.archiveFile = new File(extractJsonString(jsonLine, "archive")); - request.targetDir = new File(extractJsonString(jsonLine, "target")); - String conflict = extractJsonString(jsonLine, "conflict"); - if (conflict.length() > 0) { - request.conflictMode = ConflictMode.fromValue(conflict); - } - String backend = extractJsonString(jsonLine, "backend"); - if (backend.length() > 0) { - request.backend = Backend.fromValue(backend); - } - - int pwStart = jsonLine.indexOf("\"passwords\""); - if (pwStart >= 0) { - int arrStart = jsonLine.indexOf('[', pwStart); - int arrEnd = jsonLine.indexOf(']', arrStart); - if (arrStart >= 0 && arrEnd > arrStart) { - String arrContent = jsonLine.substring(arrStart + 1, arrEnd); - int idx = 0; - while (idx < arrContent.length()) { - int qStart = arrContent.indexOf('"', idx); - if (qStart < 0) break; - int qEnd = findClosingQuote(arrContent, qStart + 1); - if (qEnd < 0) break; - request.passwords.add(unescapeJsonString(arrContent.substring(qStart + 1, qEnd))); - idx = qEnd + 1; - } - } - } + private static ExtractionRequest parseDaemonRequest(String jsonLine) { + Map values = new DaemonJsonParser(jsonLine).parseObject(); + ExtractionRequest request = new ExtractionRequest(); + request.archiveFile = new File(requireJsonString(values, "archive")); + request.targetDir = new File(requireJsonString(values, "target")); + String conflict = optionalJsonString(values, "conflict"); + if (conflict.length() > 0) { + request.conflictMode = ConflictMode.fromValue(conflict); + } + String backend = optionalJsonString(values, "backend"); + if (backend.length() > 0) { + request.backend = Backend.fromValue(backend); + } + Object rawPasswords = values.get("passwords"); + if (rawPasswords != null) { + if (!(rawPasswords instanceof List)) { + throw new IllegalArgumentException("Daemon-Feld passwords muss ein String-Array sein"); + } + for (Object value : (List) rawPasswords) { + if (!(value instanceof String)) { + throw new IllegalArgumentException("Daemon-Feld passwords muss nur Strings enthalten"); + } + request.passwords.add((String) value); + } + } if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) { throw new IllegalArgumentException("Archiv nicht gefunden: " + (request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath())); } if (request.targetDir == null) { throw new IllegalArgumentException("--target fehlt"); - } - return request; - } - - private static String extractJsonString(String json, String key) { - String search = "\"" + key + "\""; - int keyIdx = json.indexOf(search); - if (keyIdx < 0) return ""; - int colonIdx = json.indexOf(':', keyIdx + search.length()); - if (colonIdx < 0) return ""; - int qStart = json.indexOf('"', colonIdx + 1); - if (qStart < 0) return ""; - int qEnd = findClosingQuote(json, qStart + 1); - if (qEnd < 0) return ""; - return unescapeJsonString(json.substring(qStart + 1, qEnd)); - } - - private static int findClosingQuote(String s, int from) { - for (int i = from; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\') { - i++; - continue; - } - if (c == '"') return i; - } - return -1; - } - - private static String unescapeJsonString(String s) { - if (s.indexOf('\\') < 0) return s; - StringBuilder sb = new StringBuilder(s.length()); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\' && i + 1 < s.length()) { - char next = s.charAt(i + 1); - switch (next) { - case '"': sb.append('"'); i++; break; - case '\\': sb.append('\\'); i++; break; - case '/': sb.append('/'); i++; break; - case 'n': sb.append('\n'); i++; break; - case 'r': sb.append('\r'); i++; break; - case 't': sb.append('\t'); i++; break; - default: sb.append(c); break; - } - } else { - sb.append(c); - } - } - return sb.toString(); - } + } + return request; + } + + private static String requireJsonString(Map values, String key) { + String value = optionalJsonString(values, key); + if (value.length() == 0) { + throw new IllegalArgumentException("Daemon-Feld fehlt: " + key); + } + return value; + } + + private static String optionalJsonString(Map values, String key) { + Object value = values.get(key); + if (value == null) { + return ""; + } + if (!(value instanceof String)) { + throw new IllegalArgumentException("Daemon-Feld muss ein String sein: " + key); + } + return (String) value; + } + + private static final class DaemonJsonParser { + private final String input; + private int index; + + DaemonJsonParser(String input) { + this.input = input == null ? "" : input; + } + + Map parseObject() { + skipWhitespace(); + expect('{'); + Map values = new HashMap(); + skipWhitespace(); + if (consume('}')) { + requireEnd(); + return values; + } + while (true) { + String key = parseString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + Object value = peek('"') ? parseString() : parseStringArray(); + if (values.containsKey(key)) { + throw malformed(); + } + values.put(key, value); + skipWhitespace(); + if (consume('}')) { + requireEnd(); + return values; + } + expect(','); + skipWhitespace(); + } + } + + private List parseStringArray() { + expect('['); + List values = new ArrayList(); + skipWhitespace(); + if (consume(']')) { + return values; + } + while (true) { + values.add(parseString()); + skipWhitespace(); + if (consume(']')) { + return values; + } + expect(','); + skipWhitespace(); + } + } + + private String parseString() { + expect('"'); + StringBuilder value = new StringBuilder(); + while (index < input.length()) { + char current = input.charAt(index++); + if (current == '"') { + return value.toString(); + } + if (current < 0x20) { + throw malformed(); + } + if (current != '\\') { + value.append(current); + continue; + } + if (index >= input.length()) { + throw malformed(); + } + char escaped = input.charAt(index++); + switch (escaped) { + case '"': value.append('"'); break; + case '\\': value.append('\\'); break; + case '/': value.append('/'); break; + case 'b': value.append('\b'); break; + case 'f': value.append('\f'); break; + case 'n': value.append('\n'); break; + case 'r': value.append('\r'); break; + case 't': value.append('\t'); break; + case 'u': value.append(parseUnicodeEscape()); break; + default: throw malformed(); + } + } + throw malformed(); + } + + private char parseUnicodeEscape() { + if (index + 4 > input.length()) { + throw malformed(); + } + int value = 0; + for (int offset = 0; offset < 4; offset += 1) { + int digit = Character.digit(input.charAt(index++), 16); + if (digit < 0) { + throw malformed(); + } + value = (value << 4) | digit; + } + return (char) value; + } + + private void requireEnd() { + skipWhitespace(); + if (index != input.length()) { + throw malformed(); + } + } + + private void skipWhitespace() { + while (index < input.length()) { + char current = input.charAt(index); + if (current != ' ' && current != '\t' && current != '\r' && current != '\n') { + return; + } + index += 1; + } + } + + private boolean peek(char expected) { + return index < input.length() && input.charAt(index) == expected; + } + + private boolean consume(char expected) { + if (!peek(expected)) { + return false; + } + index += 1; + return true; + } + + private void expect(char expected) { + if (!consume(expected)) { + throw malformed(); + } + } + + private IllegalArgumentException malformed() { + return new IllegalArgumentException("Ungueltige Daemon-JSON-Anfrage"); + } + } private static int runExtraction(ExtractionRequest request) throws Exception { List passwords = normalizePasswords(request.passwords); - Exception lastError = null; - boolean hadWrongPassword = false; - for (String password : passwords) { - try { - extractSingle(request, password); - emitPassword(password); - emitDone(); + Exception lastError = null; + Exception integrityError = null; + boolean hadWrongPassword = false; + for (int passwordIndex = 0; passwordIndex < passwords.size(); passwordIndex++) { + String password = passwords.get(passwordIndex); + emitPasswordAttempt(passwordIndex + 1, passwords.size()); + try { + extractSingle(request, password); + emitDone(); return 0; - } catch (WrongPasswordException wrongPassword) { - hadWrongPassword = true; - lastError = wrongPassword; - } catch (Exception error) { - lastError = error; + } catch (AmbiguousPasswordOrIntegrityException ambiguous) { + integrityError = ambiguous; + lastError = ambiguous; + } catch (WrongPasswordException wrongPassword) { + hadWrongPassword = true; + lastError = wrongPassword; + } catch (Exception error) { + integrityError = null; + lastError = error; break; } } - if (hadWrongPassword && (lastError instanceof WrongPasswordException)) { + if (integrityError != null) { + throw integrityError; + } + if (hadWrongPassword && (lastError instanceof WrongPasswordException)) { emitError("Falsches Archiv-Passwort"); return 1; } @@ -272,8 +392,31 @@ public final class JBindExtractorMain { continue; } encrypted = encrypted || header.isEncrypted(); - totalUnits += safeSize(header.getUncompressedSize()); - } + totalUnits += safeSize(header.getUncompressedSize()); + } + if (encrypted) { + for (FileHeader header : fileHeaders) { + if (header == null || header.isDirectory() || !header.isEncrypted()) { + continue; + } + InputStream passwordProbe = null; + try { + passwordProbe = zipFile.getInputStream(header); + } catch (ZipException error) { + if (isWrongPassword(error, true, false)) { + throw new WrongPasswordException(error); + } + throw error; + } finally { + if (passwordProbe != null) { + try { + passwordProbe.close(); + } catch (Throwable ignored) { + } + } + } + } + } Set reserved = new HashSet(); TargetPlanInvariant targetPlan = new TargetPlanInvariant(); Map plannedEntryNames = new IdentityHashMap(); @@ -326,8 +469,9 @@ public final class JBindExtractorMain { emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition); ensureDirectory(output.getParentFile()); rejectLinkedPath(request.targetDir, output); - long[] remaining = new long[] { itemUnits }; - boolean extractionSuccess = false; + long[] remaining = new long[] { itemUnits }; + boolean extractionSuccess = false; + boolean outputProduced = false; try { InputStream in = zipFile.getInputStream(header); try { @@ -341,8 +485,9 @@ public final class JBindExtractorMain { } if (read == 0) { continue; - } - out.write(buffer, 0, read); + } + out.write(buffer, 0, read); + outputProduced = true; long accounted = Math.min(remaining[0], (long) read); remaining[0] -= accounted; progress.advance(accounted); @@ -369,10 +514,18 @@ public final class JBindExtractorMain { extractionSuccess = true; emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition); } catch (ZipException error) { - if (isWrongPassword(error, encrypted)) { - throw new WrongPasswordException(error); - } - throw error; + if (isWrongPassword(error, encrypted, outputProduced)) { + throw new WrongPasswordException(error); + } + if (isZipIntegrityFailure(error, encrypted, outputProduced)) { + throw zipIntegrityFailure(header, error); + } + throw error; + } catch (IOException error) { + if (isZipIntegrityFailure(error, encrypted, outputProduced)) { + throw zipIntegrityFailure(header, error); + } + throw error; } finally { if (!extractionSuccess && output.exists()) { if (output.delete()) { @@ -405,8 +558,13 @@ public final class JBindExtractorMain { ensureSevenZipInitialized(); SevenZipArchiveContext context = null; try { - context = openSevenZipArchive(request.archiveFile, password); - IInArchive archive = context.archive; + context = openSevenZipArchive(request.archiveFile, password); + IInArchive archive = context.archive; + Object rawArchiveError = archive.getArchiveProperty(PropID.ERROR); + String archiveError = rawArchiveError == null ? "" : String.valueOf(rawArchiveError).trim(); + if (archiveError.length() > 0) { + throw new IOException(archiveError); + } int itemCount = archive.getNumberOfItems(); if (itemCount <= 0) { throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); @@ -512,19 +670,21 @@ public final class JBindExtractorMain { final File[] currentOutput = new File[1]; final FileOutputStream[] currentStream = new FileOutputStream[1]; final boolean[] currentSuccess = new boolean[1]; - final long[] currentRemaining = new long[1]; - final Throwable[] firstError = new Throwable[1]; - final int[] currentPos = new int[] { -1 }; - + final long[] currentRemaining = new long[1]; + final Throwable[] firstError = new Throwable[1]; + final int[] currentPos = new int[] { -1 }; + final boolean[] passwordRequested = new boolean[1]; + final boolean[] outputProduced = new boolean[1]; + BulkExtractCallback extractCallback = new BulkExtractCallback( archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, progress, encryptedFinal, effectivePassword, currentOutput, - currentStream, currentSuccess, currentRemaining, currentPos, firstError + currentStream, currentSuccess, currentRemaining, currentPos, firstError, passwordRequested, outputProduced ); try { archive.extract(indices, false, extractCallback); } catch (SevenZipException error) { - if (looksLikeWrongPassword(error, encryptedFinal)) { + if (!outputProduced[0] && looksLikeWrongPassword(error, encryptedFinal || passwordRequested[0])) { throw new WrongPasswordException(error); } throw error; @@ -552,15 +712,22 @@ public final class JBindExtractorMain { String effectivePassword = password == null ? "" : password; SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword); - if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { - VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); - try { - IInArchive archive = SevenZip.openInArchive(null, volumed, callback); - return new SevenZipArchiveContext(archive, null, volumed, callback); - } catch (Exception error) { - callback.close(); - throw error; - } + if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { + VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); + try { + IInArchive archive = SevenZip.openInArchive(null, volumed, callback); + return new SevenZipArchiveContext(archive, null, volumed, callback); + } catch (Exception error) { + SevenZipException volumeAccessError = callback.getVolumeAccessError(); + callback.close(); + if (volumeAccessError != null) { + throw volumeAccessError; + } + if (callback.wasPasswordRequested()) { + throw new WrongPasswordException(error); + } + throw error; + } } RandomAccessFile raf = new RandomAccessFile(archiveFile, "r"); @@ -568,23 +735,34 @@ public final class JBindExtractorMain { try { IInArchive archive = SevenZip.openInArchive(null, stream, callback); return new SevenZipArchiveContext(archive, stream, null, callback); - } catch (Exception error) { + } catch (Exception error) { + SevenZipException volumeAccessError = callback.getVolumeAccessError(); try { stream.close(); } catch (Throwable ignored) { } - try { - raf.close(); - } catch (Throwable ignored) { - } - throw error; + try { + raf.close(); + } catch (Throwable ignored) { + } + callback.close(); + if (volumeAccessError != null) { + throw volumeAccessError; + } + if (callback.wasPasswordRequested()) { + throw new WrongPasswordException(error); + } + throw error; } } - private static boolean isWrongPassword(ZipException error, boolean encrypted) { - if (error == null) { - return false; - } + private static boolean isWrongPassword(ZipException error, boolean encrypted, boolean outputProduced) { + if (error == null) { + return false; + } + if (outputProduced) { + return false; + } if (error.getType() == ZipException.Type.WRONG_PASSWORD) { return true; } @@ -592,13 +770,31 @@ public final class JBindExtractorMain { if (text.contains("wrong password") || text.contains("falsches passwort")) { return true; } - return encrypted && (text.contains("checksum") || text.contains("crc") || text.contains("password")); - } + return encrypted && text.contains("password"); + } + + private static boolean isZipIntegrityFailure(Throwable error, boolean encrypted, boolean outputProduced) { + if (!encrypted || error == null) { + return false; + } + String text = safeMessage(error).toLowerCase(Locale.ROOT); + return outputProduced || text.contains("aes verification failed") || text.contains("checksum") || text.contains("crc"); + } + + private static Exception zipIntegrityFailure(FileHeader header, Throwable error) { + if (header != null && header.getEncryptionMethod() == EncryptionMethod.ZIP_STANDARD) { + return new AmbiguousPasswordOrIntegrityException(error); + } + return new IOException("zip4j-Fehler: CRCERROR", error); + } - private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted) { - if (!encrypted || result == null) { - return false; - } + private static boolean isPasswordFailure(ExtractOperationResult result, boolean encrypted, boolean outputProduced) { + if (result == ExtractOperationResult.WRONG_PASSWORD) { + return true; + } + if (!encrypted || outputProduced || result == null) { + return false; + } return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR; } @@ -1031,10 +1227,9 @@ public final class JBindExtractorMain { System.out.println("RD_BACKEND " + backend.value); } - private static void emitPassword(String password) { - String encoded = Base64.getEncoder().encodeToString((password == null ? "" : password).getBytes(StandardCharsets.UTF_8)); - System.out.println("RD_PASSWORD " + encoded); - } + private static void emitPasswordAttempt(int attempt, int total) { + System.out.println("RD_PASSWORD_ATTEMPT " + attempt + " " + total); + } private static void emitDone() { System.out.println("RD_DONE"); @@ -1141,16 +1336,18 @@ public final class JBindExtractorMain { private final FileOutputStream[] currentStream; private final boolean[] currentSuccess; private final long[] currentRemaining; - private final int[] currentPos; - private final Throwable[] firstError; + private final int[] currentPos; + private final Throwable[] firstError; + private final boolean[] passwordRequested; + private final boolean[] outputProduced; BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map indexToPos, List fileIndices, List outputFiles, List fileSizes, List entryNames, List dispositions, - ProgressTracker progress, boolean encrypted, String password, - File[] currentOutput, FileOutputStream[] currentStream, - boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, - Throwable[] firstError) { + ProgressTracker progress, boolean encrypted, String password, + File[] currentOutput, FileOutputStream[] currentStream, + boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, + Throwable[] firstError, boolean[] passwordRequested, boolean[] outputProduced) { this.archive = archive; this.archiveFile = archiveFile; this.targetDir = targetDir; @@ -1168,12 +1365,15 @@ public final class JBindExtractorMain { this.currentSuccess = currentSuccess; this.currentRemaining = currentRemaining; this.currentPos = currentPos; - this.firstError = firstError; + this.firstError = firstError; + this.passwordRequested = passwordRequested; + this.outputProduced = outputProduced; } @Override - public String cryptoGetTextPassword() { - return password; + public String cryptoGetTextPassword() { + passwordRequested[0] = true; + return password; } @Override @@ -1225,9 +1425,10 @@ public final class JBindExtractorMain { if (data == null || data.length == 0) { return 0; } - try { - currentStream[0].write(data); - } catch (IOException error) { + try { + currentStream[0].write(data); + outputProduced[0] = true; + } catch (IOException error) { throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error); } long accounted = Math.min(currentRemaining[0], (long) data.length); @@ -1267,8 +1468,8 @@ public final class JBindExtractorMain { } } else { discardCurrentOutput(); - if (firstError[0] == null) { - if (isPasswordFailure(result, encrypted)) { + if (firstError[0] == null) { + if (isPasswordFailure(result, encrypted || passwordRequested[0], outputProduced[0])) { firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort")); } else { firstError[0] = new IOException("7z-Fehler: " + result.name()); @@ -1310,13 +1511,21 @@ public final class JBindExtractorMain { } } - private static final class WrongPasswordException extends Exception { + private static final class WrongPasswordException extends Exception { private static final long serialVersionUID = 1L; WrongPasswordException(Throwable cause) { super(cause); } - } + } + + private static final class AmbiguousPasswordOrIntegrityException extends Exception { + private static final long serialVersionUID = 1L; + + AmbiguousPasswordOrIntegrityException(Throwable cause) { + super("zip4j-Fehler: CRCERROR", cause); + } + } private static final class ProgressTracker { private final long total; @@ -1401,7 +1610,9 @@ public final class JBindExtractorMain { private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable { private final File archiveDir; private final String firstFileName; - private final String password; + private final String password; + private boolean passwordRequested; + private SevenZipException volumeAccessError; private final Map openRafs = new HashMap(); SevenZipVolumeCallback(File archiveFile, String password) { @@ -1433,8 +1644,9 @@ public final class JBindExtractorMain { } raf.seek(0L); return new RandomAccessFileInStream(raf); - } catch (IOException error) { - throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); + } catch (IOException error) { + volumeAccessError = new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); + throw volumeAccessError; } } @@ -1449,9 +1661,18 @@ public final class JBindExtractorMain { } @Override - public String cryptoGetTextPassword() { - return password; - } + public String cryptoGetTextPassword() { + passwordRequested = true; + return password; + } + + boolean wasPasswordRequested() { + return passwordRequested; + } + + SevenZipException getVolumeAccessError() { + return volumeAccessError; + } private File resolveVolumeFile(String filename) { if (filename == null || filename.trim().length() == 0) { diff --git a/scripts/build-extractor-jvm.mjs b/scripts/build-extractor-jvm.mjs new file mode 100644 index 0000000..e8c488f --- /dev/null +++ b/scripts/build-extractor-jvm.mjs @@ -0,0 +1,213 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRuntimeRoot = path.resolve(scriptDir, "..", "resources", "extractor-jvm"); +const requiredLibNames = [ + "sevenzipjbinding.jar", + "sevenzipjbinding-all-platforms.jar", + "zip4j.jar" +]; +const stampName = ".source.sha256"; + +function parseArguments(args) { + let runtimeRoot = defaultRuntimeRoot; + let checkOnly = false; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === "--check") { + checkOnly = true; + continue; + } + if (value === "--runtime-root") { + const next = args[index + 1]; + if (!next || next.startsWith("--")) { + throw new Error("--runtime-root benötigt einen Pfad"); + } + runtimeRoot = path.resolve(next); + index += 1; + continue; + } + throw new Error(`Unbekanntes Argument: ${value}`); + } + return { runtimeRoot, checkOnly }; +} + +function requireFile(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`${label} fehlt: ${filePath}`); + } + throw error; + } + if (!stat.isFile() || stat.size === 0 || stat.isSymbolicLink()) { + throw new Error(`${label} ist keine reguläre, nicht leere Datei: ${filePath}`); + } +} + +function listJavaSources(sourceRoot) { + const sources = []; + const pending = [sourceRoot]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile() && entry.name.endsWith(".java")) { + sources.push(entryPath); + } + } + } + sources.sort((left, right) => left.localeCompare(right, "en")); + if (sources.length === 0) { + throw new Error(`Keine Java-Quellen gefunden: ${sourceRoot}`); + } + return sources; +} + +function updateDigest(hash, root, filePath) { + const relativePath = path.relative(root, filePath).replace(/\\/g, "/"); + const content = fs.readFileSync(filePath); + hash.update(relativePath, "utf8"); + hash.update("\0", "utf8"); + hash.update(String(content.length), "utf8"); + hash.update("\0", "utf8"); + hash.update(content); +} + +function sourceDigest(runtimeRoot, sources, libs) { + const hash = crypto.createHash("sha256"); + hash.update("javac:-source=8:-target=8:-encoding=UTF-8:-g:none\0", "utf8"); + for (const filePath of [...sources, ...libs]) { + updateDigest(hash, runtimeRoot, filePath); + } + return hash.digest("hex"); +} + +function verifyClassesDirectory(classesDir, expectedDigest) { + const mainClass = path.join(classesDir, "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"); + const stampPath = path.join(classesDir, stampName); + requireFile(mainClass, "JVM-Extractor-Hauptklasse"); + requireFile(stampPath, "JVM-Extractor-Quellhash"); + const mainClassBytes = fs.readFileSync(mainClass); + if (mainClassBytes.length < 8 || mainClassBytes.readUInt16BE(6) !== 52) { + throw new Error("JVM-Extractor-Hauptklasse muss Java-8-Bytecode verwenden"); + } + const actualDigest = fs.readFileSync(stampPath, "utf8").trim(); + if (actualDigest !== expectedDigest) { + throw new Error("JVM-Extractor-Klassen sind veraltet; Java-Quelle oder Bibliotheken wurden seit dem letzten Build geändert"); + } +} + +function verifyClasses(runtimeRoot, expectedDigest) { + verifyClassesDirectory(path.join(runtimeRoot, "classes"), expectedDigest); +} + +function resolveJavac() { + const javaHome = String(process.env.JAVA_HOME || "").trim(); + const javaHomeCompiler = javaHome + ? path.join(javaHome, "bin", process.platform === "win32" ? "javac.exe" : "javac") + : ""; + if (javaHomeCompiler && fs.existsSync(javaHomeCompiler)) { + return javaHomeCompiler; + } + return "javac"; +} + +function requireJavac() { + const command = resolveJavac(); + const probe = spawnSync(command, ["-version"], { encoding: "utf8", windowsHide: true }); + if (probe.error?.code === "ENOENT" || probe.status === null) { + throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH"); + } + if (probe.status !== 0) { + throw new Error(`JDK-Compiler javac ist nicht verwendbar: ${String(probe.stderr || probe.stdout || `Exit ${probe.status}`).trim()}`); + } + return command; +} + +function replaceClasses(runtimeRoot, temporaryClasses) { + const classesDir = path.join(runtimeRoot, "classes"); + const backupDir = path.join(runtimeRoot, `.classes-previous-${process.pid}-${Date.now()}`); + let previousMoved = false; + try { + if (fs.existsSync(classesDir)) { + fs.renameSync(classesDir, backupDir); + previousMoved = true; + } + fs.renameSync(temporaryClasses, classesDir); + if (previousMoved) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } catch (error) { + if (!fs.existsSync(classesDir) && previousMoved && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, classesDir); + } + throw error; + } +} + +function compileClasses(runtimeRoot, sources, libs, digest) { + const javac = requireJavac(); + const temporaryClasses = fs.mkdtempSync(path.join(runtimeRoot, ".classes-build-")); + try { + const result = spawnSync(javac, [ + "-source", "8", + "-target", "8", + "-encoding", "UTF-8", + "-g:none", + "-classpath", libs.join(path.delimiter), + "-d", temporaryClasses, + ...sources + ], { encoding: "utf8", windowsHide: true }); + if (result.error?.code === "ENOENT") { + throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH"); + } + if (result.status !== 0) { + throw new Error(`javac fehlgeschlagen: ${String(result.stderr || result.stdout || `Exit ${result.status}`).trim()}`); + } + fs.writeFileSync(path.join(temporaryClasses, stampName), `${digest}\n`, "utf8"); + verifyClassesDirectory(temporaryClasses, digest); + replaceClasses(runtimeRoot, temporaryClasses); + } finally { + if (fs.existsSync(temporaryClasses)) { + fs.rmSync(temporaryClasses, { recursive: true, force: true }); + } + } +} + +function main() { + const { runtimeRoot, checkOnly } = parseArguments(process.argv.slice(2)); + const sourceRoot = path.join(runtimeRoot, "src"); + const sources = listJavaSources(sourceRoot); + const libs = requiredLibNames.map((name) => path.join(runtimeRoot, "lib", name)); + for (const source of sources) { + requireFile(source, "Java-Quelle"); + } + for (const lib of libs) { + requireFile(lib, "JVM-Extractor-Bibliothek"); + } + const digest = sourceDigest(runtimeRoot, sources, libs); + if (checkOnly) { + verifyClasses(runtimeRoot, digest); + process.stdout.write(`JVM-Extractor-Klassen aktuell: ${digest}\n`); + return; + } + compileClasses(runtimeRoot, sources, libs, digest); + verifyClasses(runtimeRoot, digest); + process.stdout.write(`JVM-Extractor-Klassen gebaut: ${digest}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write(`JVM-Extractor-Build fehlgeschlagen: ${String(error?.message || error)}\n`); + process.exitCode = 1; +} diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index 27cba24..16d4f87 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -16,6 +16,7 @@ const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh"; +const EXPECTED_JVM_ASAR_UNPACK = "resources/extractor-jvm/**/*"; const REQUIRED_BUILD_FILES = Object.freeze([ "resources/extractor-jvm/**/*", "LICENSE", @@ -158,6 +159,97 @@ function sha256File(filePath) { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } +function isJvmRuntimeArtifact(relativePath) { + const normalized = relativePath.split(path.sep).join("/"); + return /^classes\/.+\.class$/i.test(normalized) + || /(?:^|\/)\.source\.sha256$/i.test(normalized) + || /^lib\/[^/]+\.jar$/i.test(normalized); +} + +function readJvmRuntimeInventory(runtimeRoot, label) { + let rootStat; + try { + rootStat = fs.lstatSync(runtimeRoot); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Missing ${label} JVM runtime: ${runtimeRoot}`); + } + throw error; + } + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new Error(`${label} JVM runtime must be a regular directory: ${runtimeRoot}`); + } + const inventory = new Map(); + for (const filePath of listFiles(runtimeRoot)) { + const relativePath = normalizeRelativePath(runtimeRoot, filePath); + if (!isJvmRuntimeArtifact(relativePath)) { + continue; + } + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) { + throw new Error(`${label} JVM runtime artifact must be a non-empty regular file: ${relativePath}`); + } + inventory.set(relativePath, sha256File(filePath)); + } + const paths = [...inventory.keys()]; + if (!paths.some((relativePath) => /^classes\/.+\.class$/i.test(relativePath))) { + throw new Error(`${label} JVM runtime contains no class files`); + } + if (!paths.some((relativePath) => /(?:^|\/)\.source\.sha256$/i.test(relativePath))) { + throw new Error(`${label} JVM runtime omits .source.sha256`); + } + if (!paths.some((relativePath) => /^lib\/[^/]+\.jar$/i.test(relativePath))) { + throw new Error(`${label} JVM runtime contains no library JAR files`); + } + return inventory; +} + +function verifyJvmRuntimeEquality(sourceRoot, packagedRoot, label) { + const sourceInventory = readJvmRuntimeInventory(sourceRoot, "source"); + const packagedInventory = readJvmRuntimeInventory(packagedRoot, label); + for (const [relativePath, sourceDigest] of sourceInventory) { + if (!packagedInventory.has(relativePath)) { + throw new Error(`${label} JVM runtime is missing ${relativePath}`); + } + if (packagedInventory.get(relativePath) !== sourceDigest) { + throw new Error(`${label} JVM runtime SHA256 mismatch for ${relativePath}`); + } + } + for (const relativePath of packagedInventory.keys()) { + if (!sourceInventory.has(relativePath)) { + throw new Error(`${label} JVM runtime contains unexpected artifact ${relativePath}`); + } + } +} + +function findArchiveJvmRuntimeRoots(extractionRoot) { + const roots = new Map(); + const marker = ["resources", "app.asar.unpacked", "resources", "extractor-jvm"]; + for (const filePath of listFiles(extractionRoot)) { + const parts = normalizeRelativePath(extractionRoot, filePath).split("/"); + for (let index = 0; index <= parts.length - marker.length; index += 1) { + const candidate = parts.slice(index, index + marker.length); + if (candidate.every((part, markerIndex) => part.toLowerCase() === marker[markerIndex])) { + const relativeRoot = parts.slice(0, index + marker.length).join("/"); + roots.set(relativeRoot.toLowerCase(), path.join(extractionRoot, ...relativeRoot.split("/"))); + break; + } + } + } + return [...roots.values()]; +} + +function verifyArchiveJvmRuntime(extractionRoot, archiveName, sourceRoot) { + const runtimeRoots = findArchiveJvmRuntimeRoots(extractionRoot); + if (runtimeRoots.length === 0) { + throw new Error(`Missing JVM runtime in archive ${archiveName}`); + } + const sourceRuntimeRoot = path.join(sourceRoot, "resources", "extractor-jvm"); + for (const runtimeRoot of runtimeRoots) { + verifyJvmRuntimeEquality(sourceRuntimeRoot, runtimeRoot, `${archiveName}`); + } +} + function verifyPackagedIcon(sourceRoot, packagedRoot, label) { const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico"); const packagedPath = path.join(packagedRoot, "resources", "assets", "app_icon.ico"); @@ -308,11 +400,15 @@ export function verifyPublicRelease(rootDir = process.cwd()) { throw new Error(`NSIS update include omits ${requiredText}`); } } - const buildFiles = Array.isArray(build.files) ? build.files : []; + const buildFiles = Array.isArray(build.files) ? build.files : []; const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry)); - if (missingBuildFiles.length > 0) { - throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`); - } + if (missingBuildFiles.length > 0) { + throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`); + } + const asarUnpack = Array.isArray(build.asarUnpack) ? build.asarUnpack : [build.asarUnpack]; + if (!asarUnpack.includes(EXPECTED_JVM_ASAR_UNPACK)) { + throw new Error(`package.json build.asarUnpack must include ${EXPECTED_JVM_ASAR_UNPACK}`); + } if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) { throw new Error("package.json build.extraResources must copy LICENSE to LICENSE"); } @@ -370,6 +466,11 @@ export function verifyPublicRelease(rootDir = process.cwd()) { verifyRedistributionFiles(absoluteRoot, "sourcePath", "source"); verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked"); verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "win-unpacked"); + verifyJvmRuntimeEquality( + path.join(absoluteRoot, "resources", "extractor-jvm"), + path.join(releaseDir, "win-unpacked", "resources", "app.asar.unpacked", "resources", "extractor-jvm"), + "win-unpacked" + ); return { publish: { @@ -399,8 +500,9 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) { requireNonEmptyFile(archivePath, "release archive"); const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-")); try { - extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); + extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot); + verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot); verifiedArchives.push(archiveName); } finally { fs.rmSync(extractionRoot, { recursive: true, force: true }); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 883475b..a3f9992 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -66,7 +66,7 @@ function releaseTlsSkip(): void { import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; -import { clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; +import { classifyExtractionError, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; @@ -157,11 +157,46 @@ type PackageItemDiskState = { reason: "ok" | "missing_path" | "missing_file" | "too_small" | "persisted_shortfall"; }; -type HybridFailedArchiveState = { - marker: string; - lastError: string; - updatedAt: number; -}; +type HybridFailedArchiveState = { + marker: string; + lastError: string; + updatedAt: number; +}; + +type PackageDiskRetryRequest = { + retryAt: number; + manualRequested: boolean; + postProcessVersion: number; + runOwnerId: string | null; + archiveFilter?: Set; + selectedItemIds?: Set; +}; + +type PackageDiskRetryPlan = PackageDiskRetryRequest & { + id: string; + generation: number; +}; + +type FileFingerprint = { + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; +}; + +type ArchiveCleanupTarget = { + filePath: string; + fingerprint: FileFingerprint; +}; + +type ManualExtractionPlan = { + packageId: string; + generation: number; + targetItemIds: Set; + archiveFilter?: Set; + itemFiles: Map; +}; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; @@ -1680,7 +1715,7 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download ? items.filter((item) => { const targetPath = String(item.targetPath || "").trim(); if (!targetPath) { - return true; + return false; } return pathKey(path.dirname(path.resolve(targetPath))) === pathKey(path.dirname(path.resolve(normalizedArchivePath))); }) @@ -1732,7 +1767,7 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const exactMatch = candidateItems.filter((item) => itemBaseName(item).toLowerCase() === entryLower); if (exactMatch.length > 0) return exactMatch; - const archiveStem = entryLower + const archiveStem = entryLower .replace(/\.part\d+\.rar$/i, "") .replace(/\.r\d{2,3}$/i, "") .replace(/\.rar$/i, "") @@ -1744,16 +1779,13 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const name = itemBaseName(item).toLowerCase(); return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); }); - if (stemMatch.length > 0) return stemMatch; - } - - if (candidateItems.length === 1) { - const singleName = itemBaseName(candidateItems[0]).toLowerCase(); - if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { - return candidateItems; - } + if (stemMatch.length > 0) return stemMatch; } - + + if (normalizedArchivePath) { + return []; + } + return []; } @@ -1764,13 +1796,33 @@ export function resolveSelectedArchiveSetsFromCandidates( ): { archivePaths: Set; itemIds: Set } { const archivePaths = new Set(); const itemIds = new Set(); - for (const candidatePath of candidatePaths) { - const archiveItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath); - if (!archiveItems.some((item) => selectedItemIds.has(item.id))) { + const pathlessItems = items.filter((item) => !String(item.targetPath || "").trim()); + const candidates = candidatePaths.map((candidatePath) => { + const qualifiedItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath); + const legacyItems = resolveArchiveItemsFromList(path.basename(candidatePath), pathlessItems); + return { + candidatePath, + archiveItems: [...new Map([...qualifiedItems, ...legacyItems].map((item) => [item.id, item])).values()] + }; + }); + const pathlessMatchCount = new Map(); + for (const candidate of candidates) { + for (const item of candidate.archiveItems) { + if (!String(item.targetPath || "").trim()) { + pathlessMatchCount.set(item.id, (pathlessMatchCount.get(item.id) || 0) + 1); + } + } + } + const ambiguousPathlessIds = new Set([...pathlessMatchCount] + .filter(([, count]) => count > 1) + .map(([itemId]) => itemId)); + for (const { candidatePath, archiveItems } of candidates) { + const unambiguousItems = archiveItems.filter((item) => !ambiguousPathlessIds.has(item.id)); + if (!unambiguousItems.some((item) => selectedItemIds.has(item.id))) { continue; } archivePaths.add(candidatePath); - for (const item of archiveItems) { + for (const item of unambiguousItems) { itemIds.add(item.id); } } @@ -1797,6 +1849,57 @@ export function markPlannedHybridArchiveItemsPending( } return changed; } + +export function findCrcImplicatedArchiveItems(errorText: string, archiveItems: DownloadItem[]): DownloadItem[] { + const normalized = String(errorText || "").toLocaleLowerCase("en-US"); + const markers = ["checksum error", "crc error", "crcerror", "dataerror", "prüfsummenfehler", "pr�fsummenfehler"]; + const markerIndex = markers.reduce((latest, marker) => Math.max(latest, normalized.lastIndexOf(marker)), -1); + if (markerIndex < 0) { + return []; + } + let selected: DownloadItem | null = null; + let selectedDistance = Number.POSITIVE_INFINITY; + let selectedIndex = -1; + for (const item of archiveItems) { + const fileName = path.basename(item.targetPath || item.fileName || "").toLocaleLowerCase("en-US"); + if (!fileName) { + continue; + } + const isBoundary = (index: number): boolean => index < 0 || index >= normalized.length || !/[a-z0-9]/i.test(normalized[index]); + const findBoundedBefore = (): number => { + let index = normalized.lastIndexOf(fileName, markerIndex); + while (index >= 0 && (!isBoundary(index - 1) || !isBoundary(index + fileName.length))) { + index = normalized.lastIndexOf(fileName, index - 1); + } + return index; + }; + const findBoundedAfter = (): number => { + let index = normalized.indexOf(fileName, markerIndex); + while (index >= 0 && (!isBoundary(index - 1) || !isBoundary(index + fileName.length))) { + index = normalized.indexOf(fileName, index + 1); + } + return index; + }; + const beforeIndex = findBoundedBefore(); + const afterIndex = findBoundedAfter(); + const beforeDistance = beforeIndex >= 0 && markerIndex - beforeIndex <= 4096 + ? markerIndex - (beforeIndex + fileName.length) + : Number.POSITIVE_INFINITY; + const afterDistance = afterIndex >= 0 && afterIndex - markerIndex <= 4096 + ? afterIndex - markerIndex + : Number.POSITIVE_INFINITY; + const distance = Math.min(Math.max(0, beforeDistance), afterDistance); + if (distance < selectedDistance) { + selected = item; + selectedDistance = distance; + selectedIndex = beforeDistance <= afterDistance ? beforeIndex : afterIndex; + } + } + if (!selected || selectedIndex < 0) { + return []; + } + return [selected]; +} function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); @@ -2065,7 +2168,9 @@ export class DownloadManager extends EventEmitter { private runItemIds = new Set(); - private runPackageIds = new Set(); + private runPackageIds = new Set(); + + private runScopeKind: "all" | "selected" | "postprocessing" | null = null; private runOutcomes = new Map(); @@ -2123,8 +2228,141 @@ export class DownloadManager extends EventEmitter { private packageDiskRetryAfterByPackage = new Map(); + private packageDiskRetryPlans = new Map(); + + private packageDiskRetryTimers = new Map }>(); + private diskWaitEvents: NonNullable = []; + private clearPackageDiskRetry(packageId: string, clearEvent = true): void { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState) { + clearTimeout(timerState.timer); + this.packageDiskRetryTimers.delete(packageId); + } + this.packageDiskRetryAfterByPackage.delete(packageId); + this.packageDiskRetryPlans.delete(packageId); + if (clearEvent) { + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.packageId !== packageId || event.phase !== "extract"); + } + } + + private pausePackageDiskRetry(packageId: string): void { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState) { + clearTimeout(timerState.timer); + this.packageDiskRetryTimers.delete(packageId); + } + } + + private clearAllPackageDiskRetries(): void { + for (const timerState of this.packageDiskRetryTimers.values()) { + clearTimeout(timerState.timer); + } + this.packageDiskRetryTimers.clear(); + this.packageDiskRetryAfterByPackage.clear(); + this.packageDiskRetryPlans.clear(); + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.phase !== "extract"); + } + + private schedulePackageDiskRetry(packageId: string, plan: PackageDiskRetryRequest): boolean { + const pkg = this.session.packages[packageId]; + if (!pkg + || pkg.cancelled + || !pkg.enabled + || plan.postProcessVersion !== this.getPackagePostProcessVersion(packageId) + || plan.runOwnerId !== this.getPackageResultRunOwner(packageId) + || this.lifecyclePhase === "stopping" + || this.healthManualStop + || this.healthShuttingDown) { + return false; + } + this.clearPackageDiskRetry(packageId, false); + const storedPlan: PackageDiskRetryPlan = { + id: uuidv4(), + generation: this.getPackageResultGeneration(packageId), + retryAt: plan.retryAt, + manualRequested: plan.manualRequested, + postProcessVersion: plan.postProcessVersion, + runOwnerId: plan.runOwnerId, + archiveFilter: plan.archiveFilter ? new Set(plan.archiveFilter) : undefined, + selectedItemIds: plan.selectedItemIds ? new Set(plan.selectedItemIds) : undefined + }; + this.packageDiskRetryAfterByPackage.set(packageId, storedPlan.retryAt); + this.packageDiskRetryPlans.set(packageId, storedPlan); + const timer = setTimeout(() => { + const timerState = this.packageDiskRetryTimers.get(packageId); + if (timerState?.planId === storedPlan.id) { + this.packageDiskRetryTimers.delete(packageId); + } + this.executePackageDiskRetry(packageId, storedPlan.id); + }, Math.max(0, storedPlan.retryAt - nowMs())); + timer.unref?.(); + this.packageDiskRetryTimers.set(packageId, { planId: storedPlan.id, timer }); + return true; + } + + private executePackageDiskRetry(packageId: string, planId: string): void { + const currentPlan = this.packageDiskRetryPlans.get(packageId); + if (!currentPlan || currentPlan.id !== planId) { + this.completeStopIfDrained(); + return; + } + const pkg = this.session.packages[packageId]; + if (!pkg + || pkg.cancelled + || currentPlan.generation !== this.getPackageResultGeneration(packageId) + || currentPlan.postProcessVersion !== this.getPackagePostProcessVersion(packageId) + || currentPlan.runOwnerId !== this.getPackageResultRunOwner(packageId)) { + this.clearPackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + if (!pkg.enabled) { + this.pausePackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + if (this.lifecyclePhase === "stopping" + || (this.healthManualStop && currentPlan.runOwnerId !== null) + || this.healthShuttingDown) { + this.clearPackageDiskRetry(packageId); + this.completeStopIfDrained(); + return; + } + this.packageDiskRetryAfterByPackage.delete(packageId); + this.packageDiskRetryPlans.delete(packageId); + this.diskWaitEvents = this.diskWaitEvents.filter((event) => event.packageId !== packageId || event.phase !== "extract"); + const selectedIds = currentPlan.selectedItemIds; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed" || (selectedIds && !selectedIds.has(itemId))) { + continue; + } + if (item.fullStatus === "Warte auf Festplatte") { + item.fullStatus = "Entpacken - Ausstehend"; + } + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); + } + if (currentPlan.manualRequested) { + this.manualExtractPackages.add(packageId); + if (currentPlan.archiveFilter) { + this.manualExtractArchiveFilters.set(packageId, new Set(currentPlan.archiveFilter)); + } + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + void this.runPackagePostProcessing(packageId).catch((error) => logger.warn(`runPackagePostProcessing Fehler (diskRetry): ${compactErrorText(error)}`)); + } else if (this.settings.autoExtract && (this.session.running || this.settings.autoExtractWhenStopped)) { + this.triggerPendingExtractions(new Set([packageId])); + } + this.persistSoon(); + this.emitState(true); + this.completeStopIfDrained(); + } + private diskReservations = new DiskReservationCoordinator(); private diskLeasesByOwner = new Map(); @@ -2745,6 +2983,7 @@ export class DownloadManager extends EventEmitter { } public abortAllPostProcessing(): void { + this.clearAllPackageDiskRetries(); this.abortPostProcessing("external"); } @@ -2753,7 +2992,7 @@ export class DownloadManager extends EventEmitter { return; } this.recoverPostProcessingOnStartup(); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(); this.persistSoon(); this.emitState(); } @@ -2858,7 +3097,7 @@ export class DownloadManager extends EventEmitter { let totalItems = 0; let doneItems = 0; - if (this.session.running && this.runItemIds.size > 0) { + if (this.session.running) { totalItems = this.runItemIds.size; for (const itemId of this.runItemIds) { if (this.runOutcomes.has(itemId)) { @@ -2913,7 +3152,7 @@ export class DownloadManager extends EventEmitter { canStart: hasUsableAccount && (paused || (!this.session.running && lifecycle.phase !== "waiting_provider" && (lifecycle.phase !== "stopping" || !lifecycle.pendingStart))), - canStop: this.session.running, + canStop: this.session.running || lifecycle.activePostProcessing > 0, canPause: this.session.running, clipboardActive: this.settings.clipboardWatch, reconnectSeconds: Math.ceil(reconnectMs / 1000), @@ -3252,7 +3491,8 @@ export class DownloadManager extends EventEmitter { const nextEnabled = !pkg.enabled; pkg.enabled = nextEnabled; - if (!nextEnabled) { + if (!nextEnabled) { + this.pausePackageDiskRetry(packageId); if (pkg.status === "downloading" || pkg.status === "extracting") { pkg.status = "paused"; } @@ -3281,8 +3521,26 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.delete(packageId); this.untrackActiveRunPackage(packageId); this.runCompletedPackages.delete(packageId); - } else { - if (pkg.status === "paused") { + } else { + const diskRetryPlan = this.packageDiskRetryPlans.get(packageId); + if (diskRetryPlan) { + if (diskRetryPlan.generation === this.getPackageResultGeneration(packageId)) { + const resumed = this.schedulePackageDiskRetry(packageId, { + retryAt: Math.max(nowMs(), diskRetryPlan.retryAt), + manualRequested: diskRetryPlan.manualRequested, + postProcessVersion: this.getPackagePostProcessVersion(packageId), + runOwnerId: diskRetryPlan.runOwnerId, + archiveFilter: diskRetryPlan.archiveFilter, + selectedItemIds: diskRetryPlan.selectedItemIds + }); + if (!resumed) { + this.clearPackageDiskRetry(packageId); + } + } else { + this.clearPackageDiskRetry(packageId); + } + } + if (pkg.status === "paused") { pkg.status = "queued"; } let hasReactivatedRunItems = false; @@ -3378,7 +3636,7 @@ export class DownloadManager extends EventEmitter { return this.addPackages(inputs); } - public clearAll(): void { + public clearAll(): void { this.clearPersistTimer(); this.stop(); this.abortPostProcessing("clear_all"); @@ -3396,8 +3654,9 @@ export class DownloadManager extends EventEmitter { this.session.items = {}; this.itemCount = 0; this.session.summaryText = ""; - this.runItemIds.clear(); - this.runPackageIds.clear(); + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.historyRecordedPackages.clear(); @@ -3431,9 +3690,11 @@ export class DownloadManager extends EventEmitter { this.manualExtractArchiveFilters.clear(); this.manualExtractPackages.clear(); this.hybridExtractRequeue.clear(); - this.hybridExtractedPaths.clear(); - this.hybridFailedArchives.clear(); + this.hybridExtractedPaths.clear(); + this.hybridFailedArchives.clear(); + this.autoRecoveredForRedownload.clear(); this.providerFailures.clear(); + this.clearAllPackageDiskRetries(); this.summary = null; this.nonResumableActive = 0; this.resetSessionTotalsIfQueueEmpty(true); @@ -3516,7 +3777,7 @@ export class DownloadManager extends EventEmitter { packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; this.itemCount += 1; - if (this.session.running) { + if (this.session.running && this.runScopeKind === "all") { this.runItemIds.add(itemId); this.runPackageIds.add(packageId); this.beginPackageResultGeneration(packageId); @@ -4051,7 +4312,10 @@ export class DownloadManager extends EventEmitter { tasks.add(task); } } - return tasks.size; + const pendingDiskRetries = this.lifecyclePhase === "stopping" + ? 0 + : [...this.packageDiskRetryPlans.keys()].filter((packageId) => this.session.packages[packageId]?.enabled !== false).length; + return tasks.size + pendingDiskRetries; } private getEarliestProviderRetryAt(now: number): number | null { @@ -4061,10 +4325,13 @@ export class DownloadManager extends EventEmitter { if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + if (this.session.running && !this.runPackageIds.has(packageId)) { continue; } for (const itemId of pkg.itemIds) { + if (this.session.running && !this.runItemIds.has(itemId)) { + continue; + } const item = this.session.items[itemId]; if (item && (item.status === "queued" || item.status === "reconnect_wait")) { queuedItems.push(item); @@ -4325,8 +4592,7 @@ export class DownloadManager extends EventEmitter { extractDirUsage.set(key, (extractDirUsage.get(key) || 0) + 1); } - const cleanupTargetsByPackage = new Map>(); - const dirFilesCache = new Map(); + const cleanupPlans: Array<{ packageId: string; packageUpdatedAt: number; itemState: string; targets: Map }> = []; for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || pkg.status !== "completed") { @@ -4357,64 +4623,52 @@ export class DownloadManager extends EventEmitter { continue; } - const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set(); - for (const item of items) { - const rawTargetPath = String(item.targetPath || "").trim(); - const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : ""; - const targetPath = rawTargetPath || fallbackTargetPath; - if (!targetPath || !isArchiveLikePath(targetPath)) { - continue; - } - const dir = path.dirname(targetPath); - let filesInDir = dirFilesCache.get(dir); - if (!filesInDir) { - try { - filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); - } catch { - filesInDir = []; - } - dirFilesCache.set(dir, filesInDir); - } - - for (const cleanupTarget of collectArchiveCleanupTargets(targetPath, filesInDir)) { - packageTargets.add(cleanupTarget); - } - } - if (packageTargets.size > 0) { - cleanupTargetsByPackage.set(packageId, packageTargets); - } - } - - if (cleanupTargetsByPackage.size === 0) { - return; - } - - this.cleanupQueue = this.cleanupQueue - .then(async () => { - for (const [packageId, targets] of cleanupTargetsByPackage.entries()) { - const pkg = this.session.packages[packageId]; - if (!pkg) { - continue; - } - - logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => isExtractedLabel(this.session.items[id]?.fullStatus || ""))}`); - - let removed = 0; - for (const targetPath of targets) { - if (!await this.existsAsync(targetPath)) { - continue; - } - try { - await fs.promises.rm(targetPath, { force: true }); - removed += 1; - } catch { - } - } - + const targets = await this.collectPackageArchiveCleanupTargets(pkg); + if (targets.size === 0) { + continue; + } + cleanupPlans.push({ + packageId, + packageUpdatedAt: pkg.updatedAt, + itemState: items.map((item) => `${item.id}:${item.updatedAt}:${item.status}:${item.targetPath}`).join("|"), + targets + }); + } + + if (cleanupPlans.length === 0) { + return; + } + + this.cleanupQueue = this.cleanupQueue + .then(async () => { + for (const plan of cleanupPlans) { + const packageId = plan.packageId; + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || pkg.status !== "completed" || pkg.updatedAt !== plan.packageUpdatedAt || this.packagePostProcessTasks.has(packageId)) { + continue; + } + const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const currentItemState = items.map((item) => `${item.id}:${item.updatedAt}:${item.status}:${item.targetPath}`).join("|"); + if (items.length === 0 + || currentItemState !== plan.itemState + || !items.every((item) => item.status === "completed") + || items.some((item) => isExtractErrorLabel(item.fullStatus || ""))) { + continue; + } + const sharedExtractDir = this.session.packageOrder.some((otherId) => { + if (otherId === packageId) return false; + const other = this.session.packages[otherId]; + return Boolean(other && !other.cancelled && pathKey(other.extractDir) === pathKey(pkg.extractDir)); + }); + const hasExtractMarker = items.some((item) => isExtractedLabel(item.fullStatus)); + const hasExtractedOutput = !sharedExtractDir + && this.getPackageOutputScope(pkg).completeFiles().some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); + if (!hasExtractMarker && !hasExtractedOutput) { + continue; + } + const removed = await this.cleanupRemainingArchiveArtifacts(pkg, undefined, plan.targets); if (removed > 0) { - logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) bereinigt`); const removedDirs = await this.removeEmptyDirectoryTree(pkg.outputDir); if (removedDirs > 0) { logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); @@ -4424,10 +4678,11 @@ export class DownloadManager extends EventEmitter { } } }) - .catch((error) => { - logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); - }); - } + .catch((error) => { + logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); + }); + await this.cleanupQueue; + } private async directoryHasAnyFiles(rootDir: string): Promise { if (!rootDir) { @@ -5963,58 +6218,128 @@ export class DownloadManager extends EventEmitter { return removed; } - private async cleanupRemainingArchiveArtifacts(pkg: PackageEntry, shouldAbort?: () => boolean): Promise { + private async readFileFingerprint(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs + }; + } catch { + return null; + } + } + + private readFileFingerprintSync(filePath: string): FileFingerprint | null { + try { + const stat = fs.statSync(filePath); + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs + }; + } catch { + return null; + } + } + + private fingerprintsEqual(left: FileFingerprint, right: FileFingerprint): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; + } + + private async collectPackageArchiveCleanupTargets(pkg: PackageEntry): Promise> { + const claims = new Map>(); + for (const otherPkg of Object.values(this.session.packages)) { + if (!otherPkg || otherPkg.cancelled) { + continue; + } + for (const itemId of otherPkg.itemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(otherPkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath) { + continue; + } + const key = pathKey(path.resolve(itemPath)); + const owners = claims.get(key) || new Set(); + owners.add(otherPkg.id); + claims.set(key, owners); + } + } + const ownedPaths = new Set(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath || !isArchiveLikePath(itemPath) || !isPathInsideDir(itemPath, pkg.outputDir)) { + continue; + } + ownedPaths.add(path.resolve(itemPath)); + } + const dirFiles = new Map(); + const targets = new Map(); + for (const sourcePath of ownedPaths) { + const directory = path.dirname(sourcePath); + const directoryKey = pathKey(directory); + if (!dirFiles.has(directoryKey)) { + try { + dirFiles.set(directoryKey, (await fs.promises.readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name)); + } catch { + dirFiles.set(directoryKey, []); + } + } + for (const candidate of collectArchiveCleanupTargets(sourcePath, dirFiles.get(directoryKey) || [])) { + const resolved = path.resolve(candidate); + if (!isPathInsideDir(resolved, pkg.outputDir)) { + continue; + } + const owners = claims.get(pathKey(resolved)); + if (owners && [...owners].some((ownerId) => ownerId !== pkg.id)) { + continue; + } + const fingerprint = await this.readFileFingerprint(resolved); + if (fingerprint) { + targets.set(pathKey(resolved), { filePath: resolved, fingerprint }); + } + } + } + return targets; + } + + private async cleanupRemainingArchiveArtifacts( + pkg: PackageEntry, + shouldAbort?: () => boolean, + expectedTargets?: ReadonlyMap + ): Promise { if (this.settings.cleanupMode === "none") { return 0; } if (shouldAbort?.()) { return 0; } - const ownedPaths = new Map(); - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - const rawPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); - if (!rawPath || !isArchiveLikePath(rawPath) || !isPathInsideDir(rawPath, pkg.outputDir)) { - continue; - } - const resolved = path.resolve(rawPath); - ownedPaths.set(pathKey(resolved), resolved); - } - if (ownedPaths.size === 0) { - return 0; - } - let removed = 0; - const dirFiles = new Map(); - for (const ownedPath of ownedPaths.values()) { - const directory = path.dirname(ownedPath); - const directoryKey = pathKey(directory); - const files = dirFiles.get(directoryKey) || []; - files.push(path.basename(ownedPath)); - dirFiles.set(directoryKey, files); - } - const targets = new Set(); - for (const sourceFile of ownedPaths.values()) { + const targets = expectedTargets + ? new Map(expectedTargets) + : await this.collectPackageArchiveCleanupTargets(pkg); + for (const target of targets.values()) { if (shouldAbort?.()) { return removed; } - const dir = path.dirname(sourceFile); - for (const target of collectArchiveCleanupTargets(sourceFile, dirFiles.get(pathKey(dir)) || [])) { - const resolved = path.resolve(target); - if (ownedPaths.has(pathKey(resolved))) { - targets.add(resolved); + const targetPath = target.filePath; + try { + const currentFingerprint = await this.readFileFingerprint(targetPath); + if (!currentFingerprint || !this.fingerprintsEqual(currentFingerprint, target.fingerprint)) { + continue; } - } - } - - for (const targetPath of targets) { - if (shouldAbort?.()) { - return removed; - } - try { - if (!await this.existsAsync(targetPath)) { - continue; - } if (this.settings.cleanupMode === "trash") { const parsed = path.parse(targetPath); const trashDir = path.join(parsed.dir, ".rd-trash"); @@ -6023,20 +6348,30 @@ export class DownloadManager extends EventEmitter { for (let index = 0; index <= 1000; index += 1) { const suffix = index === 0 ? "" : `-${index}`; const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`); - if (await this.existsAsync(candidate)) { - continue; - } - await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); - moved = true; - break; + if (await this.existsAsync(candidate)) { + continue; + } + const moveFingerprint = await this.readFileFingerprint(targetPath); + if (!moveFingerprint || !this.fingerprintsEqual(moveFingerprint, target.fingerprint)) { + break; + } + await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); + moved = !await this.existsAsync(targetPath) && await this.existsAsync(candidate); + break; } if (moved) { removed += 1; } continue; } + const deleteFingerprint = await this.readFileFingerprint(targetPath); + if (!deleteFingerprint || !this.fingerprintsEqual(deleteFingerprint, target.fingerprint)) { + continue; + } await fs.promises.rm(toWindowsLongPathIfNeeded(targetPath), { force: true }); - removed += 1; + if (!await this.existsAsync(targetPath)) { + removed += 1; + } } catch { } } @@ -6522,8 +6857,11 @@ export class DownloadManager extends EventEmitter { } public async resetPackage(packageId: string): Promise { - const pkg = this.session.packages[packageId]; - if (!pkg) return; + const pkg = this.session.packages[packageId]; + if (!pkg) return; + + this.clearPackageDiskRetry(packageId); + this.clearArchiveRedownloadRecovery(packageId); const itemIds = [...pkg.itemIds]; @@ -6893,7 +7231,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - this.triggerPendingExtractions(); + this.triggerPendingExtractions(targetSet); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -6902,6 +7240,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "selected"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6917,7 +7256,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(targetSet); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -7009,7 +7348,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - this.triggerPendingExtractions(); + this.triggerPendingExtractions(affectedPackageIds); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -7018,6 +7357,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "selected"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7033,7 +7373,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); - this.triggerPendingExtractions(); + this.triggerPendingExtractions(affectedPackageIds); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -7065,6 +7405,7 @@ export class DownloadManager extends EventEmitter { private async startAllNow(excludePackageIds: ReadonlySet | undefined, generation: number): Promise { this.session.running = true; this.session.paused = false; + this.runScopeKind = "all"; const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => { const pkg = this.session.packages[packageId]; return Boolean(pkg && !pkg.cancelled && pkg.enabled && !excludePackageIds?.has(packageId)); @@ -7105,7 +7446,7 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } - this.triggerPendingExtractions(); + this.triggerPendingExtractions(recoveryRunPackageIds); const runItems = Object.values(this.session.items) .filter((item) => { @@ -7119,9 +7460,10 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { - if (this.packagePostProcessTasks.size > 0) { + if (this.getActivePostProcessingCount() > 0) { this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = "postprocessing"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.schedulerGeneration += 1; @@ -7143,6 +7485,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7175,6 +7518,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runScopeKind = "all"; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -7241,8 +7585,15 @@ export class DownloadManager extends EventEmitter { const wasRunning = this.session.running; const stoppedItemIds = new Set(this.runItemIds); const stoppedPackageIds = new Set(this.runPackageIds); - const hasScopedRun = wasRunning && stoppedItemIds.size > 0; - const stopsStandalonePostProcessing = wasRunning && !hasScopedRun && previousLifecyclePhase === "postprocessing"; + const hasScopedRun = wasRunning && this.runScopeKind === "selected"; + const stopsStandalonePostProcessing = wasRunning && this.runScopeKind === "postprocessing"; + if (hasScopedRun) { + for (const packageId of stoppedPackageIds) { + this.clearPackageDiskRetry(packageId); + } + } else { + this.clearAllPackageDiskRetries(); + } const stoppedRunContext = wasRunning ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) : null; @@ -7330,6 +7681,7 @@ export class DownloadManager extends EventEmitter { } this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.persistSoon(); @@ -7343,6 +7695,7 @@ export class DownloadManager extends EventEmitter { this.updateStatisticsActivity(nowMs()); this.rotationListenerActive = false; this.clearPersistTimer(); + this.clearAllPackageDiskRetries(); if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; @@ -7405,9 +7758,10 @@ export class DownloadManager extends EventEmitter { this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; - this.runItemIds.clear(); - this.runPackageIds.clear(); - this.runOutcomes.clear(); + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runScopeKind = null; + this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); @@ -7455,13 +7809,8 @@ export class DownloadManager extends EventEmitter { this.speedEventsHead = 0; } - if (wasPaused && !this.session.paused) { - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.providerFailures.clear(); - - const now = nowMs(); + if (wasPaused && !this.session.paused) { + const now = nowMs(); for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; @@ -7487,9 +7836,10 @@ export class DownloadManager extends EventEmitter { return this.session.paused; } - private normalizeSessionStatuses(): void { - this.session.running = false; - this.session.paused = false; + private normalizeSessionStatuses(): void { + this.session.running = false; + this.session.paused = false; + this.runScopeKind = null; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -7515,9 +7865,14 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); continue; } - if (item.status === "extracting" || item.status === "integrity_check") { - item.status = "completed"; - item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; + if (item.status === "integrity_check") { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } else if (item.status === "extracting") { + item.status = "completed"; + item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.speedBps = 0; item.updatedAt = nowMs(); } else if (item.status === "downloading" @@ -7557,8 +7912,9 @@ export class DownloadManager extends EventEmitter { } } } - for (const pkg of Object.values(this.session.packages)) { - if (pkg.enabled === undefined) { + for (const pkg of Object.values(this.session.packages)) { + pkg.postProcessLabel = undefined; + if (pkg.enabled === undefined) { pkg.enabled = true; } if (!pkg.priority) { @@ -8013,12 +8369,16 @@ export class DownloadManager extends EventEmitter { } const canonicalBaseName = stripDuplicateSuffixBeforeExtension(duplicateBaseName); - const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); - const canonicalKey = pathKey(canonicalPath); - let primaryItem = Object.values(this.session.items).find((candidate) => - candidate.packageId === packageId - && candidate.id !== duplicateItem.id - && ( + const canonicalPath = path.join(path.dirname(duplicateTargetPath), canonicalBaseName); + const canonicalKey = pathKey(canonicalPath); + const duplicateUrl = String(duplicateItem.url || "").trim(); + let primaryItem = Object.values(this.session.items).find((candidate) => + candidate.packageId === packageId + && candidate.id !== duplicateItem.id + && duplicateUrl.length > 0 + && String(candidate.url || "").trim() === duplicateUrl + && candidate.provider === duplicateItem.provider + && ( pathKey(String(candidate.targetPath || "")) === canonicalKey || ( !candidate.targetPath @@ -8030,9 +8390,12 @@ export class DownloadManager extends EventEmitter { continue; } - const duplicateExists = fs.existsSync(duplicateTargetPath); - let canonicalExists = fs.existsSync(canonicalPath); - const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); + const duplicateExists = fs.existsSync(duplicateTargetPath); + let canonicalExists = fs.existsSync(canonicalPath); + if (duplicateExists && canonicalExists) { + continue; + } + const primaryWins = startupDuplicateStateRank(primaryItem, canonicalExists) >= startupDuplicateStateRank(duplicateItem, duplicateExists); if (duplicateExists && !canonicalExists) { try { @@ -8349,14 +8712,11 @@ export class DownloadManager extends EventEmitter { return relevant; } - private clearHybridArchiveState(packageId: string, archiveKey?: string): void { - if (!archiveKey) { - this.hybridExtractedPaths.delete(packageId); - this.hybridFailedArchives.delete(packageId); - for (const key of this.autoRecoveredForRedownload) { - if (key.startsWith(`${packageId}::`)) this.autoRecoveredForRedownload.delete(key); - } - return; + private clearHybridArchiveState(packageId: string, archiveKey?: string): void { + if (!archiveKey) { + this.hybridExtractedPaths.delete(packageId); + this.hybridFailedArchives.delete(packageId); + return; } const normalizedKey = pathKey(archiveKey); @@ -8374,10 +8734,19 @@ export class DownloadManager extends EventEmitter { if (failed.size === 0) { this.hybridFailedArchives.delete(packageId); } - } - } - - private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { + } + } + + private clearArchiveRedownloadRecovery(packageId: string): void { + const prefix = packageId + "::"; + for (const key of this.autoRecoveredForRedownload) { + if (key.startsWith(prefix)) { + this.autoRecoveredForRedownload.delete(key); + } + } + } + + private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { const archiveName = path.basename(archiveKey); const archiveItems = resolveArchiveItemsFromList(archiveName, items, archiveKey) .slice() @@ -8406,27 +8775,42 @@ export class DownloadManager extends EventEmitter { }); } - private autoRecoverArchiveCrcFailure( + private autoRecoverArchiveCrcFailure( pkg: PackageEntry, items: DownloadItem[], failure: ExtractArchiveFailureInfo, scope: "hybrid" | "full" ): number { - if (!failure.suggestRedownload || (failure.category !== "crc_error" && failure.category !== "wrong_password")) { + if (!failure.suggestRedownload || failure.category !== "crc_error") { return 0; } const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items, failure.archivePath) .filter((item) => item.status === "completed"); - if (archiveItems.length === 0) { + if (archiveItems.length === 0) { logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); - return 0; - } - - const inspectedArchiveItems = archiveItems - .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); - const corruptArchiveItems = inspectedArchiveItems - .filter(({ state }) => state.reason !== "ok"); + return 0; + } + + const inspectedArchiveItems = archiveItems + .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); + const corruptArchiveItems = inspectedArchiveItems + .filter(({ state }) => state.reason !== "ok"); + const failureText = `${failure.errorText || ""} ${failure.jvmFailureReason || ""}`; + const standardUnrarCrcText = /corrupt file or wrong password/i.test(failureText); + const passwordAmbiguousFailure = /encrypted file|falsches? archiv-passwort/i.test(failureText) + || (!standardUnrarCrcText && /wrong password/i.test(failureText)); + const corroboratedCrc = failure.category === "crc_error" + && !passwordAmbiguousFailure + && classifyExtractionError(failure.jvmFailureReason || "") === "crc_error"; + if (corroboratedCrc) { + const implicatedIds = new Set(findCrcImplicatedArchiveItems(failure.errorText, archiveItems).map((item) => item.id)); + for (const inspected of inspectedArchiveItems) { + if (implicatedIds.has(inspected.item.id) && !corruptArchiveItems.some(({ item }) => item.id === inspected.item.id)) { + corruptArchiveItems.push(inspected); + } + } + } if (corruptArchiveItems.length === 0) { const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); @@ -8460,21 +8844,38 @@ export class DownloadManager extends EventEmitter { `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` + `erzwinge Re-Download aller ${archiveItems.length} Parts` ); - corruptArchiveItems.push(...inspectedArchiveItems); - } - - const queuedAt = nowMs(); - const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; - let changed = 0; - for (const { item } of corruptArchiveItems) { - const claimedTargetPath = String(item.targetPath || "").trim(); - if (claimedTargetPath) { - try { - fs.rmSync(claimedTargetPath, { force: true }); - } catch { - } - } - this.releaseTargetPath(item.id); + corruptArchiveItems.push(...inspectedArchiveItems); + } + + const generation = this.getPackageResultGeneration(pkg.id); + const recoveryKeyForItem = (item: DownloadItem): string => + `${pkg.id}::${generation}::${pathKey(item.targetPath || item.fileName || item.id)}`; + const pendingRecoveryItems = corruptArchiveItems + .filter(({ item }) => !this.autoRecoveredForRedownload.has(recoveryKeyForItem(item))); + if (pendingRecoveryItems.length === 0) { + return 0; + } + + const queuedAt = nowMs(); + const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; + const requeuedItems: typeof pendingRecoveryItems = []; + for (const inspected of pendingRecoveryItems) { + const { item } = inspected; + const itemRecoveryKey = recoveryKeyForItem(item); + const claimedTargetPath = String(item.targetPath || "").trim(); + let removalConfirmed = claimedTargetPath.length === 0; + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + removalConfirmed = !fs.existsSync(claimedTargetPath); + } catch (error) { + logger.warn(`Auto-Recovery (${scope}): Defektes Archiv konnte nicht entfernt werden (${item.fileName}): ${compactErrorText(error)}`); + } + } + if (!removalConfirmed) { + continue; + } + this.releaseTargetPath(item.id); this.dropItemContribution(item.id); item.targetPath = ""; item.status = "queued"; @@ -8483,14 +8884,16 @@ export class DownloadManager extends EventEmitter { item.progressPercent = 0; item.speedBps = 0; item.lastError = failure.errorText; - item.fullStatus = reason; - item.updatedAt = queuedAt; - changed += 1; - } - + item.fullStatus = reason; + item.updatedAt = queuedAt; + this.autoRecoveredForRedownload.add(itemRecoveryKey); + requeuedItems.push(inspected); + } + + const changed = requeuedItems.length; if (changed > 0) { if (this.session.running) { - for (const { item } of corruptArchiveItems) { + for (const { item } of requeuedItems) { this.runItemIds.add(item.id); this.runOutcomes.delete(item.id); } @@ -8498,13 +8901,13 @@ export class DownloadManager extends EventEmitter { this.trackActiveRunPackage(pkg.id); } this.clearHybridArchiveState(pkg.id); - pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; - pkg.updatedAt = queuedAt; - const evidence = corruptArchiveItems - .slice(0, 3) + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = queuedAt; + const evidence = requeuedItems + .slice(0, 3) .map(({ item, state }) => `${item.fileName}:${state.reason}`) .join(", "); - const suffix = corruptArchiveItems.length > 3 ? ` (+${corruptArchiveItems.length - 3} weitere)` : ""; + const suffix = requeuedItems.length > 3 ? ` (+${requeuedItems.length - 3} weitere)` : ""; logger.warn( `Auto-Recovery (${scope}): ${failure.archiveName} auf queued gesetzt (${changed} Items), ` + `evidence=${evidence}${suffix}, cause=${compactErrorText(failure.jvmFailureReason || failure.errorText)}` @@ -8522,8 +8925,9 @@ export class DownloadManager extends EventEmitter { fallbackReason: string, previousStatuses: Map, appliedAt = nowMs() - ): void { - const affectedItemIds = new Set(); + ): void { + const affectedItemIds = new Set(); + const soleFailure = failedArchiveErrors.size === 1 ? [...failedArchiveErrors.values()][0] : undefined; for (const failure of failedArchiveErrors.values()) { const reason = compactErrorText(failure.errorText || fallbackReason || "Entpacken fehlgeschlagen"); @@ -8546,9 +8950,9 @@ export class DownloadManager extends EventEmitter { continue; } - const currentStatus = String(entry.fullStatus || "").trim(); - if (currentStatus === "Entpacken - Error") { - entry.fullStatus = formatExtractFailureLabel(fallbackReason); + const currentStatus = String(entry.fullStatus || "").trim(); + if (currentStatus === "Entpacken - Error") { + entry.fullStatus = formatExtractFailureLabel(fallbackReason, soleFailure?.archiveName); entry.updatedAt = appliedAt; appliedSpecificFailure = true; continue; @@ -8944,9 +9348,42 @@ export class DownloadManager extends EventEmitter { controller.abort(reason); } } - } - } - + } + } + + private isExpectedPostProcessAbort(error: unknown, signal: AbortSignal): boolean { + void error; + return signal.aborted; + } + + private markUnexpectedPostProcessFailure(packageId: string, error: unknown): void { + const pkg = this.session.packages[packageId]; + if (!pkg) { + return; + } + const reason = compactErrorText(error).replace(/^Error:\s*/i, "") || "Unbekannter Nachbearbeitungsfehler"; + const failedAt = nowMs(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed" || isExtractedLabel(item.fullStatus || "")) { + continue; + } + item.fullStatus = formatExtractFailureLabel(reason); + item.lastError = reason; + item.updatedAt = failedAt; + } + pkg.status = "failed"; + pkg.postProcessLabel = undefined; + pkg.updatedAt = failedAt; + if (this.runPackageIds.has(packageId)) { + this.runCompletedPackages.add(packageId); + } + this.hybridExtractRequeue.delete(packageId); + this.logPackageForPackage(pkg, "ERROR", "Post-Processing unerwartet fehlgeschlagen", { reason }); + this.persistSoon(); + this.emitState(true); + } + private runPackagePostProcessing(packageId: string): Promise { this.trackPackagePostProcessResult(packageId); const existing = this.packagePostProcessTasks.get(packageId); @@ -8980,11 +9417,16 @@ export class DownloadManager extends EventEmitter { const hadRequeue = this.hybridExtractRequeue.has(packageId); this.hybridExtractRequeue.delete(packageId); const roundStart = nowMs(); - try { - await this.handlePackagePostProcessing(packageId, abortController.signal); - } catch (error) { - logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); - } + try { + await this.handlePackagePostProcessing(packageId, abortController.signal); + } catch (error) { + if (this.isExpectedPostProcessAbort(error, abortController.signal)) { + logger.info(`Post-Processing für Paket abgebrochen: ${compactErrorText(error)}`); + } else { + logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); + this.markUnexpectedPostProcessFailure(packageId, error); + } + } const roundMs = nowMs() - roundStart; logger.info(`Post-Process Runde ${round} fertig in ${(roundMs / 1000).toFixed(1)}s (requeue=${hadRequeue}, nextRequeue=${this.hybridExtractRequeue.has(packageId)}): pkg=${packageId.slice(0, 8)}`); const pkg = this.session.packages[packageId]; @@ -9163,12 +9605,18 @@ export class DownloadManager extends EventEmitter { } } - private triggerPendingExtractions(): void { + private triggerPendingExtractions(packageFilter?: ReadonlySet): void { if (!this.settings.autoExtract) { return; } - for (const packageId of this.session.packageOrder) { - const pkg = this.session.packages[packageId]; + for (const packageId of this.session.packageOrder) { + if (packageFilter && !packageFilter.has(packageId)) { + continue; + } + if (!packageFilter && this.session.running && !this.runPackageIds.has(packageId)) { + continue; + } + const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } @@ -9199,9 +9647,12 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); for (const item of items) { - if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); } } logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); @@ -9219,9 +9670,12 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); for (const item of items) { - if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); + if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); } } logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); @@ -9233,131 +9687,201 @@ export class DownloadManager extends EventEmitter { } public async retryExtraction(packageId: string): Promise { - if (!(await this.armExtractNowPackage(packageId))) { - throw new Error("Kein entpackbarer Archivsatz ausgewählt"); - } + await this.extractNow(packageId); } - private async armExtractNowPackage( + private async resolveManualExtractionPlan( packageId: string, - selectedItemIds?: ReadonlySet, - archiveFilter?: ReadonlySet - ): Promise { - let pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) return false; - let items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - let completedItems = items.filter((item) => item.status === "completed"); - let targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); + selectedItemIds?: ReadonlySet + ): Promise { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return null; + const completedItems = pkg.itemIds + .map((id) => this.session.items[id]) + .filter((item): item is DownloadItem => Boolean(item && item.status === "completed")); + const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) + && (!selectedItemIds || selectedItemIds.has(item.id))); if (targetItems.length === 0) { - this.manualExtractArchiveFilters.delete(packageId); - this.manualExtractPackages.delete(packageId); - return false; + return null; } - const initialTargetIds = new Set(targetItems.map((item) => item.id)); - if (this.packagePostProcessTasks.has(packageId) || this.hasDeferredPostProcessPending(packageId)) { - pkg.postProcessLabel = "Entpacken wird neu gestartet..."; - pkg.updatedAt = nowMs(); - this.emitState(true); - await Promise.allSettled(this.abortPackagePostProcessing(packageId, "manual_extract_restart")); - pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) return false; - items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - completedItems = items.filter((item) => item.status === "completed"); - targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); - if (targetItems.length === 0) { - pkg.postProcessLabel = undefined; - pkg.updatedAt = nowMs(); - this.emitState(true); - return [...initialTargetIds].every((itemId) => { - const item = this.session.items[itemId]; - return Boolean(item && item.status === "completed" && isExtractedLabel(item.fullStatus)); - }); + const candidates = [...await this.findReadyArchiveSets(pkg)]; + const requestedItemIds = new Set(targetItems.map((item) => item.id)); + const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, requestedItemIds); + const plannedItemIds = new Set(selection.itemIds); + const opaqueItemIds = new Set(); + for (const item of targetItems) { + if (plannedItemIds.has(item.id) || !item.targetPath || isArchiveLikePath(item.targetPath || item.fileName || "")) { + continue; + } + if (inspectPackageItemDiskState(pkg, item).reason !== "ok") { + continue; + } + if (await detectArchiveSignature(item.targetPath)) { + opaqueItemIds.add(item.id); + plannedItemIds.add(item.id); } } - this.clearHybridArchiveState(packageId); + if (plannedItemIds.size === 0) { + return null; + } + if (selectedItemIds && opaqueItemIds.size > 0 && ( + candidates.length > 0 + || plannedItemIds.size !== opaqueItemIds.size + || completedItems.some((item) => !selectedItemIds.has(item.id) && !isExtractedLabel(item.fullStatus)) + )) { + return null; + } + const itemFiles = new Map(); + for (const itemId of plannedItemIds) { + const item = this.session.items[itemId]; + const itemPath = String(item?.targetPath || (item?.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + const fingerprint = itemPath ? await this.readFileFingerprint(itemPath) : null; + if (!fingerprint) { + return null; + } + const existingOwner = this.reservedTargetPaths.get(pathKey(itemPath)); + if (existingOwner && existingOwner !== itemId) { + return null; + } + itemFiles.set(itemId, { filePath: path.resolve(itemPath), fingerprint }); + } + return { + packageId, + generation: this.getPackageResultGeneration(packageId), + targetItemIds: plannedItemIds, + archiveFilter: selectedItemIds && opaqueItemIds.size === 0 + ? new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) + : undefined, + itemFiles + }; + } + + private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean { + const pkg = this.session.packages[plan.packageId]; + if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) { + return false; + } + return [...plan.targetItemIds].every((itemId) => { + const item = this.session.items[itemId]; + const expectedFile = plan.itemFiles.get(itemId); + const currentFingerprint = expectedFile ? this.readFileFingerprintSync(expectedFile.filePath) : null; + return Boolean(item + && item.packageId === plan.packageId + && item.status === "completed" + && !isExtractedLabel(item.fullStatus) + && expectedFile + && currentFingerprint + && this.fingerprintsEqual(currentFingerprint, expectedFile.fingerprint)); + }); + } + + private async prepareManualExtractionPlan(plan: ManualExtractionPlan): Promise { + if (!this.isManualExtractionPlanCurrent(plan)) { + return false; + } + if (this.packagePostProcessTasks.has(plan.packageId) || this.hasDeferredPostProcessPending(plan.packageId)) { + await Promise.allSettled(this.abortPackagePostProcessing(plan.packageId, "manual_extract_restart")); + } + return this.isManualExtractionPlanCurrent(plan); + } + + private commitManualExtractionPlan(plan: ManualExtractionPlan): void { + const pkg = this.session.packages[plan.packageId] as PackageEntry; + this.beginHealthRun(); + this.clearPackageDiskRetry(plan.packageId); + this.clearHybridArchiveState(plan.packageId); if (!pkg.enabled) { pkg.enabled = true; } - if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter)); - else this.manualExtractArchiveFilters.delete(packageId); - this.manualExtractPackages.add(packageId); - pkg.status = "queued"; - pkg.updatedAt = nowMs(); - for (const item of targetItems) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); - } - logger.info(`Jetzt entpacken: pkg=${pkg.name}, completed=${completedItems.length}, targeted=${targetItems.length}`); - this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { - completedItems: completedItems.length, - targetedItems: targetItems.length - }); - this.beginPackageResultGeneration(packageId, false, true); - this.reactivateStandalonePackageResult(packageId); - this.persistSoon(); - this.emitState(true); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); - return true; + if (plan.archiveFilter) this.manualExtractArchiveFilters.set(plan.packageId, new Set(plan.archiveFilter)); + else this.manualExtractArchiveFilters.delete(plan.packageId); + this.manualExtractPackages.add(plan.packageId); + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + for (const itemId of plan.targetItemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const plannedFile = plan.itemFiles.get(itemId); + if (!item.targetPath && plannedFile) { + item.fileName = path.basename(plannedFile.filePath); + item.targetPath = plannedFile.filePath; + this.reservedTargetPaths.set(pathKey(plannedFile.filePath), item.id); + this.claimedTargetPathByItem.set(item.id, plannedFile.filePath); + } + item.fullStatus = "Entpacken - Ausstehend"; + if (item.lastError === "Zu wenig Speicherplatz") { + item.lastError = ""; + } + item.updatedAt = nowMs(); + } + this.beginPackageResultGeneration(plan.packageId, false, true); + this.reactivateStandalonePackageResult(plan.packageId); } - private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet): Promise { - const selectedByPackage = new Map>(); - let armed = 0; - for (const itemId of itemIds) { - const item = this.session.items[itemId]; - if (!item || excludedPackageIds.has(item.packageId)) { - continue; - } - const selected = selectedByPackage.get(item.packageId) || new Set(); - selected.add(itemId); - selectedByPackage.set(item.packageId, selected); - } - for (const [packageId, selectedItemIds] of selectedByPackage) { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) { - continue; - } - const completedItems = pkg.itemIds - .map((itemId) => this.session.items[itemId]) - .filter((item): item is DownloadItem => Boolean(item && item.status === "completed")); - const candidates = await findArchiveCandidates(pkg.outputDir); - const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, selectedItemIds); - if (selection.archivePaths.size === 0 || selection.itemIds.size === 0) { - logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`); - continue; - } - if (await this.armExtractNowPackage( - packageId, - selection.itemIds, - new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) - )) { - armed += 1; + private async executeManualExtractionPlans(plans: ManualExtractionPlan[]): Promise { + for (const plan of plans) { + if (!await this.prepareManualExtractionPlan(plan)) { + throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); } } - return armed; + if (!plans.every((plan) => this.isManualExtractionPlanCurrent(plan))) { + throw new Error("Entpackauswahl hat sich während der Vorbereitung geändert"); + } + for (const plan of plans) { + this.commitManualExtractionPlan(plan); + } + this.persistSoon(); + this.emitState(true); + for (const plan of plans) { + const pkg = this.session.packages[plan.packageId]; + logger.info(`Jetzt entpacken: pkg=${pkg?.name || plan.packageId}, targeted=${plan.targetItemIds.size}`); + if (pkg) { + this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { targetedItems: plan.targetItemIds.size }); + } + void this.runPackagePostProcessing(plan.packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); + } } public async extractNow(target: string | ExtractNowRequest): Promise { - if (typeof target === "string") { - if (!(await this.armExtractNowPackage(target))) { - throw new Error("Kein entpackbarer Archivsatz ausgewählt"); - } - return; - } - const packageIds = [...new Set(target.packageIds)]; + const packageIds = typeof target === "string" ? [target] : [...new Set(target.packageIds)]; const packageSet = new Set(packageIds); - let armed = 0; - for (const packageId of packageIds) { - if (await this.armExtractNowPackage(packageId)) { - armed += 1; + const itemIdsByPackage = new Map>(); + let rejected = 0; + if (typeof target !== "string") { + for (const itemId of target.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + rejected += 1; + continue; + } + if (packageSet.has(item.packageId)) { + continue; + } + const selected = itemIdsByPackage.get(item.packageId) || new Set(); + selected.add(itemId); + itemIdsByPackage.set(item.packageId, selected); } } - armed += await this.extractNowItems(target.itemIds, packageSet); - if (armed === 0) { - throw new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"); + const plans: ManualExtractionPlan[] = []; + for (const packageId of packageIds) { + const plan = await this.resolveManualExtractionPlan(packageId); + if (plan) plans.push(plan); + else rejected += 1; } + for (const [packageId, itemIds] of itemIdsByPackage) { + const plan = await this.resolveManualExtractionPlan(packageId, itemIds); + if (plan) plans.push(plan); + else rejected += 1; + } + if (plans.length === 0) { + throw new Error("Kein entpackbarer Archivsatz ausgewählt"); + } + if (rejected > 0) { + throw new Error(`${plans.length} Entpackvorgang bereit, ${rejected} nicht gestartet`); + } + await this.executeManualExtractionPlans(plans); } - + private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { if ((pkg.downloadStartedAt || 0) <= 0) { pkg.downloadStartedAt = startedAt; @@ -9419,7 +9943,7 @@ export class DownloadManager extends EventEmitter { this.onHistoryEntryCallback(entry); } - private removePackageFromSession(packageId: string, itemIds: string[], reason: "completed" | "deleted" = "deleted"): void { + private removePackageFromSession(packageId: string, itemIds: string[], reason: "completed" | "deleted" = "deleted"): void { const pkg = this.session.packages[packageId]; if (pkg) { this.logPackageForPackage(pkg, "INFO", "Paket aus Session entfernt", { @@ -9458,6 +9982,8 @@ export class DownloadManager extends EventEmitter { } } this.historyRecordedPackages.delete(packageId); + this.clearPackageDiskRetry(packageId); + this.clearArchiveRedownloadRecovery(packageId); this.abortPackagePostProcessing(packageId, "package_removed"); this.packagePostProcessVersions.delete(packageId); this.packageFileOpChain.delete(packageId); @@ -10154,7 +10680,7 @@ export class DownloadManager extends EventEmitter { const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; - const postProcessComplete = this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending(); + const postProcessComplete = this.getActivePostProcessingCount() === 0; if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) { this.finishRun(); break; @@ -10314,7 +10840,7 @@ export class DownloadManager extends EventEmitter { changed = true; } }; - if (this.runItemIds.size > 0) { + if (this.session.running) { for (const itemId of this.runItemIds) updateItem(itemId); } else { for (const itemId in this.session.items) updateItem(itemId); @@ -10333,13 +10859,13 @@ export class DownloadManager extends EventEmitter { for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + if (this.session.running && !this.runPackageIds.has(packageId)) continue; const pkgPrio = pkg.priority || "normal"; if (normalCandidate && pkgPrio === "low") continue; if (normalCandidate && pkgPrio === "normal") continue; for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) continue; + if (this.session.running && !this.runItemIds.has(itemId)) continue; const item = this.session.items[itemId]; if (!item) continue; const retryAfter = this.retryAfterByItem.get(itemId) || 0; @@ -10377,9 +10903,9 @@ export class DownloadManager extends EventEmitter { for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + if (this.session.running && !this.runPackageIds.has(packageId)) continue; for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + if (this.session.running && !this.runItemIds.has(itemId)) { continue; } const item = this.session.items[itemId]; @@ -10412,11 +10938,11 @@ export class DownloadManager extends EventEmitter { if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + if (this.session.running && !this.runPackageIds.has(packageId)) { continue; } for (const itemId of pkg.itemIds) { - if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + if (this.session.running && !this.runItemIds.has(itemId)) { continue; } const item = this.session.items[itemId]; @@ -12777,6 +13303,9 @@ export class DownloadManager extends EventEmitter { if (pkg) { pkg.resultGeneration = next; } + if (next !== current) { + this.clearArchiveRedownloadRecovery(packageId); + } if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { if (resetDownloadTelemetry) { pkg.downloadStartedAt = 0; @@ -13129,6 +13658,7 @@ export class DownloadManager extends EventEmitter { return true; } return this.packagePostProcessTasks.has(packageId) + || this.packageDiskRetryPlans.has(packageId) || this.hasDeferredPostProcessPending(packageId) || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 || this.packageFileOpChain.has(packageId); @@ -13494,24 +14024,31 @@ export class DownloadManager extends EventEmitter { return ready; } - const completedPaths = new Set(); - const pendingPaths = new Set(); - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (!item) { - continue; - } - if (item.status === "completed" && item.targetPath) { - completedPaths.add(pathKey(item.targetPath)); - } else { - if (item.targetPath) { - pendingPaths.add(pathKey(item.targetPath)); - } - if (item.fileName && pkg.outputDir) { - pendingPaths.add(pathKey(path.join(pkg.outputDir, item.fileName))); - } - } - } + const completedPaths = new Set(); + const effectivePaths = new Map(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + const itemPath = String(item.targetPath || (item.fileName ? path.join(pkg.outputDir, item.fileName) : "")).trim(); + if (!itemPath) { + continue; + } + const key = pathKey(itemPath); + const claim = effectivePaths.get(key) || { filePath: itemPath, itemIds: [] }; + claim.itemIds.push(item.id); + effectivePaths.set(key, claim); + } + for (const claim of effectivePaths.values()) { + if (claim.itemIds.length !== 1) { + continue; + } + const item = this.session.items[claim.itemIds[0]]; + if (item?.status === "completed" && await this.existsAsync(claim.filePath)) { + completedPaths.add(pathKey(claim.filePath)); + } + } if (completedPaths.size === 0) { return ready; } @@ -13530,23 +14067,25 @@ export class DownloadManager extends EventEmitter { return ready; } - const packageItems = pkg.itemIds + const packageItems = pkg.itemIds .map((itemId) => this.session.items[itemId]) .filter(Boolean) as DownloadItem[]; - - for (const candidate of candidates) { - const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); - const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); - if (allPartsCompleted) { - const hasUnstartedParts = [...pendingPaths].some((pendingPath) => { - const pendingName = path.basename(pendingPath).toLowerCase(); - const candidateStem = path.basename(candidate).toLowerCase(); - return this.looksLikeArchivePart(pendingName, candidateStem); - }); - if (hasUnstartedParts) { - continue; - } - ready.add(pathKey(candidate)); + + for (const candidate of candidates) { + if (/\.rev$/i.test(candidate)) { + continue; + } + const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); + const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); + const candidateStem = path.basename(candidate).toLowerCase(); + const hasUnreadyPendingPart = packageItems.some((item) => item.status !== "completed" + && this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem) + && inspectPackageItemDiskState(pkg, item).reason !== "ok"); + if (hasUnreadyPendingPart) { + continue; + } + if (allPartsCompleted) { + ready.add(pathKey(candidate)); continue; } @@ -14054,8 +14593,9 @@ export class DownloadManager extends EventEmitter { return 0; } - private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { - const handleStart = nowMs(); + private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { + const handleStart = nowMs(); + const postProcessVersion = this.getPackagePostProcessVersion(packageId); const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled) { return; @@ -14183,6 +14723,7 @@ export class DownloadManager extends EventEmitter { const allDone = this.areAllPackageItemRefsFinished(pkg); const manualExtraction = this.manualExtractPackages.has(packageId); + const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); const shouldExtract = this.settings.autoExtract || manualExtraction; if (!allDone && success + failed + cancelled >= items.length) { logger.warn( @@ -14302,7 +14843,6 @@ export class DownloadManager extends EventEmitter { } const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); - const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); if (manualArchiveFilter) { for (const archivePath of [...fullArchiveSet]) { if (!manualArchiveFilter.has(pathKey(archivePath))) { @@ -14450,9 +14990,26 @@ export class DownloadManager extends EventEmitter { })); } catch (error) { if (error instanceof DiskCapacityError) { - this.diskWaitEvents = [{ ...error.event, packageId }]; + if (signal?.aborted || this.lifecyclePhase === "stopping" || this.healthManualStop || this.healthShuttingDown || !pkg.enabled) { + this.clearPackageDiskRetry(packageId); + pkg.postProcessLabel = undefined; + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } const retryAt = error.event.retryAt; - this.packageDiskRetryAfterByPackage.set(packageId, retryAt); + const scheduled = this.schedulePackageDiskRetry(packageId, { + retryAt, + manualRequested: manualExtraction, + postProcessVersion, + runOwnerId: this.getPackageResultRunOwner(packageId), + archiveFilter: manualArchiveFilter ? new Set(manualArchiveFilter) : undefined, + selectedItemIds: new Set(fullExtractionItems.map((entry) => entry.id)) + }); + if (!scheduled) { + return; + } + this.diskWaitEvents = [{ ...error.event, packageId }]; for (const entry of fullExtractionItems) { entry.fullStatus = "Warte auf Festplatte"; entry.lastError = "Zu wenig Speicherplatz"; @@ -14627,7 +15184,8 @@ export class DownloadManager extends EventEmitter { failed, alreadyMarkedExtracted, extractedCount, - manualExtraction + manualExtraction, + Boolean(manualArchiveFilter) || !allDone ); } @@ -14638,10 +15196,11 @@ export class DownloadManager extends EventEmitter { failed: number, alreadyMarkedExtracted: boolean, extractedCount: number, - manualSelection = false + manualRequested = false, + preservePartialArchiveState = manualRequested ): Promise { this.trackPackagePostProcessResult(packageId); - const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualSelection) + const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualRequested, preservePartialArchiveState) .finally(() => { const tasks = this.packageDeferredPostProcessTasks.get(packageId); tasks?.delete(task); @@ -14662,10 +15221,11 @@ export class DownloadManager extends EventEmitter { packageId: string, pkg: PackageEntry, success: number, - failed: number, - alreadyMarkedExtracted: boolean, + failed: number, + alreadyMarkedExtracted: boolean, extractedCount: number, - manualSelection: boolean + manualRequested: boolean, + preservePartialArchiveState: boolean ): Promise { const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId); if (replacedController && !replacedController.signal.aborted) { @@ -14686,7 +15246,7 @@ export class DownloadManager extends EventEmitter { try { throwIfAborted(); - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualSelection)) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualRequested)) { const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; const nestedCandidates = outputScope.archiveFiles() .filter((candidate) => isPathInsideDir(candidate, pkg.extractDir) && !nestedBlacklist.test(candidate)); @@ -14753,7 +15313,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !manualSelection) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !preservePartialArchiveState) { pkg.postProcessLabel = "Aufräumen..."; this.emitState(); throwIfAborted(); @@ -14788,7 +15348,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !manualSelection) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !preservePartialArchiveState) { throwIfAborted(); await clearExtractResumeState(pkg.outputDir, packageId); await clearExtractResumeState(pkg.outputDir); @@ -15004,6 +15564,7 @@ export class DownloadManager extends EventEmitter { const runContext = total > 0 ? this.finishActiveRunContext(this.runPackageIds, runStartedAt) : null; this.runItemIds.clear(); this.runPackageIds.clear(); + this.runScopeKind = null; this.runOutcomes.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); diff --git a/src/main/extraction-ipc.ts b/src/main/extraction-ipc.ts new file mode 100644 index 0000000..fb77de6 --- /dev/null +++ b/src/main/extraction-ipc.ts @@ -0,0 +1,34 @@ +import type { IpcMainInvokeEvent } from "electron"; +import type { ExtractNowRequest } from "../shared/extract-now"; +import { normalizeExtractNowRequest } from "../shared/extract-now"; +import { IPC_CHANNELS } from "../shared/ipc"; + +export interface ExtractionIpcTarget { + retryExtraction(packageId: string): Promise; + extractNow(request: ExtractNowRequest): Promise; +} + +export type TrustedIpcRegistrar = ( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown +) => void; + +function normalizeRetryPackageId(value: unknown): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("packageId muss ein nicht-leerer String sein"); + } + const packageId = value.trim(); + if (packageId.length > 256) { + throw new Error("packageId darf höchstens 256 Zeichen lang sein"); + } + return packageId; +} + +export function registerExtractionIpcHandlers(registerTrusted: TrustedIpcRegistrar, target: ExtractionIpcTarget): void { + registerTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event, ...args) => { + return target.retryExtraction(normalizeRetryPackageId(args[0])); + }); + registerTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event, ...args) => { + return target.extractNow(normalizeExtractNowRequest(args[0])); + }); +} diff --git a/src/main/extractor.ts b/src/main/extractor.ts index cf40a20..e653502 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -127,6 +127,13 @@ class ExtractionOutputCallbackError extends Error { this.name = "ExtractionOutputCallbackError"; } } + +class ExtractionPasswordAttemptCallbackError extends Error { + public constructor() { + super("extract_password_attempt_callback_failed"); + this.name = "ExtractionPasswordAttemptCallbackError"; + } +} type ExtractionErrorWithHints = Error & { suggestRedownload?: boolean; @@ -150,15 +157,23 @@ type JvmExtractResult = { aborted: boolean; timedOut: boolean; errorText: string; - usedPassword: string; + usedPassword: string; backend: string; + passwordAttempts: number; + passwordAttemptTotal: number; + passwordCandidatesExhausted: boolean; }; +type JvmExtractBaseResult = Omit; + type JvmParseState = { bestPercent: number; usedPassword: string; backend: string; reportedError: string; + passwordAttempts: number; + passwordAttemptTotal: number; + passwordCandidates: string[]; outputError?: Error; openedOutputs?: Map; }; @@ -206,8 +221,9 @@ type ExtractorCommandKind = "rar_native" | "seven_zip" | "other"; interface SubstMapping { drive: string; original: string; } interface DaemonRequest { - resolve: (result: JvmExtractResult) => void; - onArchiveProgress?: (percent: number) => void; + resolve: (result: JvmExtractResult) => void; + onArchiveProgress?: (percent: number) => void; + onPasswordAttempt?: (attempt: number, total: number) => void; signal?: AbortSignal; timeoutMs?: number; parseState: JvmParseState; @@ -911,7 +927,7 @@ function archivePasswords(listInput: string): string[] { return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS])); } -function prioritizePassword(passwords: string[], successful: string): string[] { +function prioritizePassword(passwords: string[], successful: string): string[] { const target = String(successful || ""); if (!target || passwords.length <= 1) { return passwords; @@ -926,9 +942,9 @@ function prioritizePassword(passwords: string[], successful: string): string[] { const next = [...passwords]; const [value] = next.splice(index, 1); next.unshift(value); - return next; -} - + return next; +} + export function cleanErrorText(text: string): string { const normalized = String(text || "").replace(/\s+/g, " ").trim(); if (normalized.length <= 500) { @@ -976,33 +992,45 @@ function withExtractionErrorHints( export function classifyExtractionError(errorText: unknown): ExtractErrorCategory { if (errorText instanceof ExtractionError) return errorText.category; const text = String(errorText || "").toLowerCase(); - if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted"; - if (text.includes("timeout")) return "timeout"; - if (text.includes("crc failed") || text.includes("checksum error") || text.includes("crc error")) return "crc_error"; - if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password"; + if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted"; + if (text.includes("timeout")) return "timeout"; + if (text.includes("crc failed") + || text.includes("checksum error") + || text.includes("crc error") + || text.includes("crc-fehler") + || text.includes("crc fehler") + || text.includes("crcerror") + || text.includes("dataerror") + || text.includes("fsummenfehler")) return "crc_error"; + if (text.includes("wrong password") + || text.includes("incorrect password") + || /falsches(?:[\s-]+archiv)?[\s-]+passwort/.test(text)) return "wrong_password"; if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts"; if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor"; if (isUnsupportedArchiveFormatError(text)) return "unsupported_format"; if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full"; - return "unknown"; -} + return "unknown"; +} + +export function shouldSuggestRedownloadAfterCrossBackendFailure( + legacyCategory: ExtractErrorCategory, + jvmCategory: ExtractErrorCategory, + passwordCandidatesExhausted: boolean +): boolean { + return passwordCandidatesExhausted + && legacyCategory === "crc_error" + && jvmCategory === "crc_error"; +} -export function shouldSerialRetryParallelFailures( - extractedCount: number, - failedCategories: ExtractErrorCategory[] -): boolean { - if (failedCategories.length === 0) { - return false; - } - if (extractedCount > 0) { - return true; - } - return failedCategories.every((category) => - category === "crc_error" - || category === "wrong_password" - || category === "unknown" - ); -} +export function shouldSerialRetryParallelFailures( + _extractedCount: number, + failedCategories: ExtractErrorCategory[] +): boolean { + if (failedCategories.length === 0) { + return false; + } + return failedCategories.every((category) => category === "unknown"); +} export function shouldFallbackLegacyRarToJvm( archivePath: string, @@ -1129,15 +1157,52 @@ function isRarNativeCommand(command: string): boolean { || base === "rar"; } -function extractorCommandKind(command: string): ExtractorCommandKind { +function extractorCommandKind(command: string): ExtractorCommandKind { if (isRarNativeCommand(command)) { return "rar_native"; } if (is7zCommand(command)) { return "seven_zip"; } - return "other"; -} + return "other"; +} + +export function extractorCommandsShareIdentity( + leftCommand: string, + rightCommand: string, + platform = process.platform +): boolean { + const leftKind = extractorCommandKind(leftCommand); + const rightKind = extractorCommandKind(rightCommand); + if (leftKind !== "other" && leftKind === rightKind) { + return true; + } + const normalize = (command: string): string => { + let value = String(command || "").trim(); + if (isAbsoluteCommand(value)) { + try { + value = fs.realpathSync.native(value); + } catch { + value = path.resolve(value); + } + } + return String(platform).toLowerCase() === "win32" ? value.toLowerCase() : value; + }; + return normalize(leftCommand) === normalize(rightCommand); +} + +export function shouldRunAlternativeNativeExtractor( + currentCommand: string, + archivePath: string, + configuredMode: ExtractBackendMode, + _backendMode: ExtractBackendMode, + platform = process.platform +): boolean { + return !(String(platform).toLowerCase() === "win32" + && configuredMode !== "legacy" + && isRarArchivePath(archivePath) + && extractorCommandKind(currentCommand) === "rar_native"); +} function isAbsoluteCommand(command: string): boolean { return path.isAbsolute(command) @@ -1251,10 +1316,10 @@ function extractorProbeArgs(command: string): string[] { return isRarNativeCommand(command) ? ["-?"] : ["?"]; } -async function resolveExtractorCommandInternal(archivePath = ""): Promise { - if (resolvedExtractorCommand) { - return resolvedExtractorCommand; - } +async function resolveExtractorCommandInternal(archivePath = ""): Promise { + if (resolvedExtractorCommand && cachedExtractorFitsArchive(resolvedExtractorCommand, archivePath)) { + return resolvedExtractorCommand; + } if (resolveFailureReason) { const age = Date.now() - resolveFailureAt; if (age < EXTRACTOR_RETRY_AFTER_MS) { @@ -1314,7 +1379,7 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = "" : ["seven_zip", "rar_native"]; for (const kind of preferredKinds) { for (const candidate of candidates) { - if (candidate === currentCommand) continue; + if (extractorCommandsShareIdentity(candidate, currentCommand)) continue; if (extractorCommandKind(candidate) !== kind) continue; if (isAbsoluteCommand(candidate) && !fs.existsSync(candidate)) continue; const probe = await runExtractCommand(candidate, extractorProbeArgs(candidate), undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); @@ -1671,11 +1736,65 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null { return null; } +export function parseJvmPasswordAttemptLine(line: string): { attempt: number; total: number } | null { + const match = String(line || "").trim().match(/^RD_PASSWORD_ATTEMPT ([1-9]\d*) ([1-9]\d*)$/); + if (!match) { + return null; + } + const attempt = Number(match[1]); + const total = Number(match[2]); + if (!Number.isSafeInteger(attempt) || !Number.isSafeInteger(total) || attempt > total || total > 1_000_000) { + return null; + } + return { attempt, total }; +} + +export function summarizeJvmPasswordAttempts( + attempts: number, + total: number, + extractionSucceeded: boolean +): { attempts: number; total: number; exhausted: boolean } { + if (!Number.isSafeInteger(attempts) + || !Number.isSafeInteger(total) + || attempts < 1 + || total < 1 + || attempts > total + || total > 1_000_000) { + return { attempts: 0, total: 0, exhausted: false }; + } + return { + attempts, + total, + exhausted: !extractionSucceeded && attempts === total + }; +} + +export function redactJvmDiagnosticLine(line: string): string { + const value = String(line || ""); + return value.trimStart().startsWith("RD_PASSWORD ") + ? `${value.slice(0, value.length - value.trimStart().length)}RD_PASSWORD ` + : value; +} + +function jvmPasswordAttemptResult(state: JvmParseState, extractionSucceeded: boolean): Pick { + const summary = summarizeJvmPasswordAttempts(state.passwordAttempts, state.passwordAttemptTotal, extractionSucceeded); + return { + passwordAttempts: summary.attempts, + passwordAttemptTotal: summary.total, + passwordCandidatesExhausted: summary.exhausted + }; +} + +function normalizeJvmPasswordCandidates(passwordCandidates: string[]): string[] { + return Array.from(new Set(["", ...passwordCandidates.map((candidate) => String(candidate || ""))])); +} + function parseJvmLine( line: string, onArchiveProgress: ((percent: number) => void) | undefined, state: JvmParseState, - onOutput?: (event: ExtractOutputEvent) => void + onOutput?: (event: ExtractOutputEvent) => void, + onPasswordAttempt?: (attempt: number, total: number) => void ): void { const trimmed = String(line || "").trim(); if (!trimmed) { @@ -1694,7 +1813,7 @@ function parseJvmLine( return; } - if (trimmed.startsWith("RD_PASSWORD ")) { + if (trimmed.startsWith("RD_PASSWORD ")) { const encoded = trimmed.slice("RD_PASSWORD ".length).trim(); try { state.usedPassword = Buffer.from(encoded, "base64").toString("utf8"); @@ -1709,6 +1828,28 @@ function parseJvmLine( return; } + if (trimmed === "RD_DONE") { + const successfulIndex = state.passwordAttempts - 1; + state.usedPassword = successfulIndex >= 0 && successfulIndex < state.passwordCandidates.length + ? state.passwordCandidates[successfulIndex] + : ""; + return; + } + + const passwordAttempt = parseJvmPasswordAttemptLine(trimmed); + if (passwordAttempt) { + state.passwordAttempts = passwordAttempt.attempt; + state.passwordAttemptTotal = passwordAttempt.total; + if (onPasswordAttempt) { + try { + onPasswordAttempt(passwordAttempt.attempt, passwordAttempt.total); + } catch { + state.outputError ||= new ExtractionPasswordAttemptCallbackError(); + } + } + return; + } + if (trimmed.startsWith("RD_OUTPUT ")) { const fields = trimmed.split(" "); const stateValue = fields[2]; @@ -1751,8 +1892,12 @@ let daemonStdoutBuffer = ""; let daemonStderrBuffer = ""; let daemonOutput = ""; let daemonTimeoutId: NodeJS.Timeout | null = null; -let daemonAbortHandler: (() => void) | null = null; -let daemonLayout: JvmExtractorLayout | null = null; +let daemonAbortHandler: (() => void) | null = null; +let daemonLayout: JvmExtractorLayout | null = null; + +function appendDaemonDiagnosticLine(line: string): void { + daemonOutput = appendLimited(daemonOutput, `${redactJvmDiagnosticLine(line)}\n`); +} export function shutdownDaemon(): void { if (daemonProcess) { @@ -1771,7 +1916,7 @@ export function shutdownDaemon(): void { daemonLayout = null; } -function finishDaemonRequest(result: JvmExtractResult): void { +function finishDaemonRequest(result: JvmExtractBaseResult): void { const req = daemonCurrentRequest; if (!req) return; const openedCount = reconcileJvmOpenedOutputs(req.parseState, req.onOutput, req.targetDir); @@ -1801,19 +1946,24 @@ function finishDaemonRequest(result: JvmExtractResult): void { req.signal.removeEventListener("abort", daemonAbortHandler); daemonAbortHandler = null; } - req.resolve(finalResult); -} + req.resolve({ + ...finalResult, + ...jvmPasswordAttemptResult(req.parseState, finalResult.ok) + }); +} function flushDaemonParseBuffers(req: DaemonRequest | null): void { if (!req) { return; } - if (daemonStdoutBuffer.trim()) { - parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput); + if (daemonStdoutBuffer.trim()) { + appendDaemonDiagnosticLine(daemonStdoutBuffer); + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt); daemonStdoutBuffer = ""; } - if (daemonStderrBuffer.trim()) { - parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput); + if (daemonStderrBuffer.trim()) { + appendDaemonDiagnosticLine(daemonStderrBuffer); + parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt); daemonStderrBuffer = ""; } } @@ -1879,7 +2029,7 @@ function handleDaemonLine(line: string): void { if (req.terminationStarted) { return; } - parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput); + parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt); failDaemonOutputCallback(req); } } @@ -1914,30 +2064,30 @@ function startDaemon(layout: JvmExtractorLayout): boolean { daemonProcess = child; daemonLayout = layout; - child.stdout!.on("data", (chunk) => { - const raw = String(chunk || ""); - daemonOutput = appendLimited(daemonOutput, raw); - daemonStdoutBuffer += raw; + child.stdout!.on("data", (chunk) => { + const raw = String(chunk || ""); + daemonStdoutBuffer += raw; const lines = daemonStdoutBuffer.split(/\r?\n/); - daemonStdoutBuffer = lines.pop() || ""; - for (const line of lines) { - handleDaemonLine(line); + daemonStdoutBuffer = lines.pop() || ""; + for (const line of lines) { + appendDaemonDiagnosticLine(line); + handleDaemonLine(line); } }); - child.stderr!.on("data", (chunk) => { - const raw = String(chunk || ""); - daemonOutput = appendLimited(daemonOutput, raw); + child.stderr!.on("data", (chunk) => { + const raw = String(chunk || ""); daemonStderrBuffer += raw; const lines = daemonStderrBuffer.split(/\r?\n/); daemonStderrBuffer = lines.pop() || ""; for (const line of lines) { + appendDaemonDiagnosticLine(line); if (daemonCurrentRequest) { const req = daemonCurrentRequest; if (req.terminationStarted) { continue; } - parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput); + parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput, req.onPasswordAttempt); failDaemonOutputCallback(req); } } @@ -1960,6 +2110,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean { child.on("close", () => { if (daemonCurrentRequest) { const req = daemonCurrentRequest; + flushDaemonParseBuffers(req); if (req.aborted) { finishDaemonRequest({ ok: false, missingCommand: false, missingRuntime: false, @@ -2013,7 +2164,10 @@ function abortedJvmExtractResult(): JvmExtractResult { timedOut: false, errorText: "aborted:extract", usedPassword: "", - backend: "" + backend: "", + passwordAttempts: 0, + passwordAttemptTotal: 0, + passwordCandidatesExhausted: false }; } @@ -2059,21 +2213,31 @@ function sendDaemonRequest( onArchiveProgress?: (percent: number) => void, signal?: AbortSignal, timeoutMs?: number, - onOutput?: (event: ExtractOutputEvent) => void + onOutput?: (event: ExtractOutputEvent) => void, + onPasswordAttempt?: (attempt: number, total: number) => void ): Promise { if (signal?.aborted) { return Promise.resolve(abortedJvmExtractResult()); } return new Promise((resolve) => { const mode = effectiveConflictMode(conflictMode); - const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + const parseState: JvmParseState = { + bestPercent: 0, + usedPassword: "", + backend: "", + reportedError: "", + passwordAttempts: 0, + passwordAttemptTotal: 0, + passwordCandidates: normalizeJvmPasswordCandidates(passwordCandidates) + }; const archiveName = path.basename(archivePath); daemonBusy = true; daemonOutput = ""; daemonCurrentRequest = { resolve, - onArchiveProgress, + onArchiveProgress, + onPasswordAttempt, signal, timeoutMs, parseState, @@ -2148,7 +2312,8 @@ async function runJvmExtractCommand( onArchiveProgress?: (percent: number) => void, signal?: AbortSignal, timeoutMs?: number, - onOutput?: (event: ExtractOutputEvent) => void + onOutput?: (event: ExtractOutputEvent) => void, + onPasswordAttempt?: (attempt: number, total: number) => void ): Promise { if (signal?.aborted) { return Promise.resolve(abortedJvmExtractResult()); @@ -2157,7 +2322,7 @@ async function runJvmExtractCommand( if (isDaemonAvailable(layout)) { lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`); - return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt); } if (daemonProcess) { @@ -2172,7 +2337,7 @@ async function runJvmExtractCommand( if (ready) { lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`); - return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt); } logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`); } @@ -2221,7 +2386,15 @@ async function runJvmExtractCommand( let timedOutByWatchdog = false; let abortedBySignal = false; let onAbort: (() => void) | null = null; - const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + const parseState: JvmParseState = { + bestPercent: 0, + usedPassword: "", + backend: "", + reportedError: "", + passwordAttempts: 0, + passwordAttemptTotal: 0, + passwordCandidates: normalizeJvmPasswordCandidates(passwordCandidates) + }; let stdoutBuffer = ""; let stderrBuffer = ""; let outputCallbackKillStarted = false; @@ -2229,16 +2402,16 @@ async function runJvmExtractCommand( const child = spawn(layout.javaCommand, args, { windowsHide: true }); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); - const flushLines = (rawChunk: string, fromStdErr = false): void => { + const flushLines = (rawChunk: string, fromStdErr = false): void => { if (!rawChunk) { return; } - output = appendLimited(output, rawChunk); - const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; + const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; const lines = nextBuffer.split(/\r?\n/); - const keep = lines.pop() || ""; + const keep = lines.pop() || ""; for (const line of lines) { - parseJvmLine(line, onArchiveProgress, parseState, onOutput); + output = appendLimited(output, `${redactJvmDiagnosticLine(line)}\n`); + parseJvmLine(line, onArchiveProgress, parseState, onOutput, onPasswordAttempt); } if (parseState.outputError && !outputCallbackKillStarted) { outputCallbackKillStarted = true; @@ -2255,7 +2428,7 @@ async function runJvmExtractCommand( fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); }; - const finish = (result: JvmExtractResult): void => { + const finish = (result: JvmExtractBaseResult): void => { if (settled) { return; } @@ -2285,8 +2458,11 @@ async function runJvmExtractCommand( signal.removeEventListener("abort", onAbort); } cleanupTmpDir(); - resolve(finalResult); - }; + resolve({ + ...finalResult, + ...jvmPasswordAttemptResult(parseState, finalResult.ok) + }); + }; if (timeoutMs && timeoutMs > 0) { timeoutId = setTimeout(() => { @@ -2325,9 +2501,17 @@ async function runJvmExtractCommand( }); }); - child.on("close", (code) => { - parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput); - parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput); + child.on("close", (code) => { + if (stdoutBuffer) { + output = appendLimited(output, redactJvmDiagnosticLine(stdoutBuffer)); + parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput, onPasswordAttempt); + stdoutBuffer = ""; + } + if (stderrBuffer) { + output = appendLimited(output, redactJvmDiagnosticLine(stderrBuffer)); + parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput, onPasswordAttempt); + stderrBuffer = ""; + } if (abortedBySignal) { finish({ @@ -2857,9 +3041,7 @@ function createNativeOutputCollector( }; } -const extractRetryDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -async function runExternalExtractInner( +async function runExternalExtractInner( command: string, archivePath: string, targetDir: string, @@ -2874,9 +3056,11 @@ async function runExternalExtractInner( flatModeResult?: { needed: boolean }, onLog?: ExtractOptions["onLog"], onOutput?: (event: ExtractOutputEvent) => void -): Promise { - const passwords = passwordCandidates; - let lastError = ""; +): Promise { + const passwords = passwordCandidates; + let lastError = ""; + let integrityError = ""; + let terminalError = false; const extractorName = path.basename(command).replace(/\.exe$/i, "") || command; const emptyPasswordCount = passwords.filter((candidate) => candidate === "").length; @@ -2958,10 +3142,14 @@ async function runExternalExtractInner( passwordAttempt += 1; const attemptStartedAt = Date.now(); onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=`); - logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); - if (passwords.length > 1) { - onPasswordAttempt?.(passwordAttempt, passwords.length); - } + logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); + if (passwords.length > 1) { + try { + onPasswordAttempt?.(passwordAttempt, passwords.length); + } catch { + logger.warn("Legacy-Passwortfortschritt-Callback fehlgeschlagen"); + } + } let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let result = await runNativeAttempt(args, password); @@ -2979,12 +3167,15 @@ async function runExternalExtractInner( `ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}` ); onLog?.("INFO", `Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); - if (!result.ok) { - const errorSummary = summarizeResultError(result.errorText); + if (!result.ok) { + const errorSummary = summarizeResultError(result.errorText); if (errorSummary) { logger.info(`Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`); - onLog?.("INFO", `Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`); - } + onLog?.("INFO", `Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`); + } + if (bestPercent > 0 && classifyExtractionError(result.errorText) === "crc_error") { + integrityError = result.errorText; + } } if (result.ok) { @@ -3003,9 +3194,10 @@ async function runExternalExtractInner( throw new Error("aborted:extract"); } - if (result.timedOut) { - lastError = result.errorText; - break; + if (result.timedOut) { + lastError = result.errorText; + terminalError = true; + break; } if (result.missingCommand) { @@ -3044,8 +3236,9 @@ async function runExternalExtractInner( } } - throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); -} + const finalError = terminalError ? lastError : integrityError || lastError; + throw withExtractionErrorHints(new Error(finalError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); +} async function runExternalExtract( archivePath: string, @@ -3065,9 +3258,10 @@ async function runExternalExtract( const configuredBackendMode = extractorBackendMode(); const backendMode = extractorBackendModeForArchive(archivePath); const archiveName = path.basename(archivePath); - const totalStartedAt = Date.now(); - let jvmFailureReason = ""; - let jvmCodecError = false; + const totalStartedAt = Date.now(); + let jvmFailureReason = ""; + let initialJvmFailureCategory: ExtractErrorCategory | null = null; + let initialJvmPasswordCandidatesExhausted = false; let fallbackFromJvm = false; logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); @@ -3090,9 +3284,9 @@ async function runExternalExtract( logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`); const jvmStartedAt = Date.now(); onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`); - const jvmResult = await runJvmExtractCommand( - layout, archivePath, targetDir, conflictMode, passwordCandidates, - onArchiveProgress, signal, timeoutMs, onOutput + const jvmResult = await runJvmExtractCommand( + layout, archivePath, targetDir, conflictMode, passwordCandidates, + onArchiveProgress, signal, timeoutMs, onOutput, onPasswordAttempt ); const jvmMs = Date.now() - jvmStartedAt; onLog?.("INFO", `JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); @@ -3110,16 +3304,16 @@ async function runExternalExtract( throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`); } - jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; - fallbackFromJvm = true; + jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; + initialJvmFailureCategory = classifyExtractionError(jvmFailureReason); + initialJvmPasswordCandidatesExhausted = jvmResult.passwordCandidatesExhausted; + fallbackFromJvm = true; const jvmFailureLower = jvmFailureReason.toLowerCase(); const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD"); const isCodecError = jvmFailureLower.includes("registered codecs") || jvmFailureLower.includes("can not open") || jvmFailureLower.includes("cannot open archive"); - jvmCodecError = isCodecError; - const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD") - || jvmFailureLower.includes("wrong password"); + const isWrongPassword = initialJvmFailureCategory === "wrong_password"; const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword; onLog?.("WARN", `JVM-Extractor Fallback-Analyse: archive=${archiveName}, unsupportedMethod=${isUnsupportedMethod}, codecError=${isCodecError}, wrongPassword=${isWrongPassword}, backendMode=${backendMode}`); if (backendMode === "jvm" && !shouldFallbackToLegacy) { @@ -3163,7 +3357,10 @@ async function runExternalExtract( const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName); const errText = String((primaryError as Error)?.message || primaryError || ""); const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText); - if (isRar && isPasswordOrCorrupt && !signal?.aborted) { + if (isRar + && isPasswordOrCorrupt + && shouldRunAlternativeNativeExtractor(command, archivePath, configuredBackendMode, backendMode) + && !signal?.aborted) { const alt = await findAlternativeExtractor(command, archivePath); if (alt) { const altName = path.basename(alt).replace(/\.exe$/i, ""); @@ -3189,72 +3386,14 @@ async function runExternalExtract( const initialLegacyBestPercent = Number.isFinite(initialLegacyHints.legacyBestPercent) ? Number(initialLegacyHints.legacyBestPercent || 0) : 0; - const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password"; - let finalLegacyError: Error; - - if (isCrcOrWrongPw && !signal?.aborted) { - const retryDelayMs = 2500; - logger.warn( - `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}` - ); - onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`); - await extractRetryDelay(retryDelayMs); - if (!signal?.aborted) { - try { - const retryCmd = usedCommand; - const retryPassword = await runExternalExtractInner( - retryCmd, - archivePath, - effectiveTargetDir, - conflictMode, - passwordCandidates, - onArchiveProgress, - signal, - timeoutMs, - hybridMode, - onPasswordAttempt, - forceFlatMode, - flatModeResult, - onLog, - legacyOnOutput - ); - logger.info(`Legacy-Retry erfolgreich: ${archiveName}`); - onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`); - password = retryPassword; - usedCommand = retryCmd; - const retryExtractorName = path.basename(retryCmd).replace(/\.exe$/i, ""); - const retryLegacyMs = Date.now() - legacyStartedAt; - if (jvmFailureReason) { - logger.info(`Entpackt via legacy/${retryExtractorName} (nach JVM-Fehler): ${archiveName}`); - } else { - logger.info(`Entpackt via legacy/${retryExtractorName} (nach Legacy-Retry): ${archiveName}`); - } - logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); - onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); - return password; - } catch (retryError) { - const retryText = String((retryError as Error)?.message || retryError || ""); - const retryCategory = classifyExtractionError(retryText); - logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); - onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); - const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password"); - finalLegacyError = withExtractionErrorHints(retryError, { - suggestRedownload, - jvmFailureReason: jvmFailureReason || undefined - }); - } - } else { - finalLegacyError = withExtractionErrorHints(legacyError, { - jvmFailureReason: jvmFailureReason || undefined - }); - } - } else { - const suggestRedownload = jvmCodecError && isCrcOrWrongPw; - finalLegacyError = withExtractionErrorHints(legacyError, { - suggestRedownload, - jvmFailureReason: jvmFailureReason || undefined - }); - } + let finalLegacyError = withExtractionErrorHints(legacyError, { + suggestRedownload: initialJvmFailureCategory !== null && shouldSuggestRedownloadAfterCrossBackendFailure( + initialLegacyCategory, + initialJvmFailureCategory, + initialJvmPasswordCandidatesExhausted + ), + jvmFailureReason: jvmFailureReason || undefined + }); const finalLegacyHints = finalLegacyError as ExtractionErrorWithHints; const finalLegacyText = String(finalLegacyError?.message || finalLegacyError || ""); @@ -3275,9 +3414,10 @@ async function runExternalExtract( conflictMode, passwordCandidates, onArchiveProgress, - signal, + signal, timeoutMs, - onOutput + onOutput, + onPasswordAttempt ); const jvmMs = Date.now() - jvmStartedAt; logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); @@ -3288,12 +3428,18 @@ async function runExternalExtract( onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); return jvmResult.usedPassword; } - if (jvmResult.aborted) { - throw new Error("aborted:extract"); - } - finalLegacyError = withExtractionErrorHints(finalLegacyError, { - jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen" - }); + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + const fallbackJvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; + finalLegacyError = withExtractionErrorHints(finalLegacyError, { + suggestRedownload: shouldSuggestRedownloadAfterCrossBackendFailure( + classifyExtractionError(finalLegacyText), + classifyExtractionError(fallbackJvmFailureReason), + jvmResult.passwordCandidatesExhausted + ), + jvmFailureReason: fallbackJvmFailureReason + }); logger.warn(`Legacy->JVM-Fallback ebenfalls fehlgeschlagen: ${archiveName} (${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")})`); onLog?.("WARN", `Legacy->JVM-Fallback ebenfalls fehlgeschlagen: archive=${archiveName}, error=${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")}`); } else { @@ -3851,6 +3997,15 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { + try { + options.onLog?.(level, message); + } catch { + logger.warn("Extract-Log-Callback fehlgeschlagen"); + } + } + : undefined; const outputScope = new PackageOutputScope([options.targetDir]); const emitOutput = (event: ExtractOutputEvent): void => { outputScope.add(event); @@ -3889,7 +4044,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise 0) { options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" }); @@ -3949,7 +4104,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); - options.onLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); + safeOnLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); const emptyArchivePasswordCount = archivePasswordCandidates.filter((candidate) => candidate === "").length; - options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`); + safeOnLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`); const hasManyPasswords = archivePasswordCandidates.length > 1; if (hasManyPasswords) { emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }, undefined, archivePath); @@ -4155,7 +4310,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { activePasswordProgress = mergeExtractPasswordProgress(activePasswordProgress, { passwordAttempt: attempt, passwordTotal: total }); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath); - options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); + safeOnLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); } : undefined; try { @@ -4167,7 +4322,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { reportArchiveProgress(value); - }, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); + }, signal, hybrid, onPwAttempt, false, undefined, safeOnLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { @@ -4187,7 +4342,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { reportArchiveProgress(value); - }, signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); + }, signal, hybrid, onPwAttempt, false, undefined, safeOnLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (externalError) { throw selectZipFallbackError(error, externalError); @@ -4198,7 +4353,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { reportArchiveProgress(value); - }, signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput); + }, signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, safeOnLog, emitOutput); rememberLearnedPassword(usedPassword); if (flatResult.needed) packageNeedsFlatMode = true; } @@ -4211,7 +4366,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise 1 && pendingCandidates.length > 1) { logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); - options.onLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); + safeOnLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); const first = pendingCandidates[0]; try { await scheduleArchive(first, (signal) => extractSingleArchive(first, signal)); @@ -4329,54 +4484,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise 0 && extracted === 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap))); - const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown"); - if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) { - const categorySummary = [...new Set(failedCategories)].join(","); - logger.info( - `Serielle Wiederholung nach Parallel-Fehlstart: ${failedArchives.length} Archive werden einzeln wiederholt ` + - `(categories=${categorySummary || "unknown"})` - ); - let retryRecovered = 0; - for (const archivePath of failedArchives) { - if (options.signal?.aborted || noExtractorEncountered) break; - try { - failed -= 1; - await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal)); - retryRecovered += 1; - } catch (retryError) { - const errText = String(retryError); - if (isExtractAbortError(errText)) throw retryError; - } - } - if (retryRecovered > 0) { - logger.info(`Serielle Wiederholung nach Parallel-Fehlstart: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`); - } - } - } - - if (failed > 0 && extracted > 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap))); - if (failedArchives.length > 0) { - logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`); - let retryRecovered = 0; - for (const archivePath of failedArchives) { - if (options.signal?.aborted || noExtractorEncountered) break; - try { - failed -= 1; - await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal)); - retryRecovered += 1; - } catch (retryError) { - const errText = String(retryError); - if (isExtractAbortError(errText)) throw retryError; - } - } - if (retryRecovered > 0) { - logger.info(`Serielle Wiederholung: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`); - } - } - } + const serialRecoveryByPath = new Map(); + for (const archivePath of parallelQueue) { + const archiveKey = pathSetKey(path.resolve(archivePath)); + if (extractedArchives.has(archivePath) + || resumedArchivePaths.has(archiveKey) + || failedArchiveCategories.get(archivePath) !== "unknown") { + continue; + } + serialRecoveryByPath.set(archiveKey, archivePath); + } + const serialRecoveryArchives = [...serialRecoveryByPath.values()]; + if (serialRecoveryArchives.length > 0) { + logger.info(`Serielle Wiederholung: ${serialRecoveryArchives.length} unbekannte Parallelfehler werden einmal einzeln wiederholt`); + let retryRecovered = 0; + for (const archivePath of serialRecoveryArchives) { + if (options.signal?.aborted || noExtractorEncountered) break; + const failedBeforeRetry = failed; + try { + failed -= 1; + await scheduleArchive(archivePath, (signal) => extractSingleArchive(archivePath, signal)); + if (extractedArchives.has(archivePath)) { + retryRecovered += 1; + } + } catch (retryError) { + if (!extractedArchives.has(archivePath)) { + failed = failedBeforeRetry; + } + const errText = String(retryError); + if (isExtractAbortError(errText)) throw retryError; + } + } + if (retryRecovered > 0) { + logger.info(`Serielle Wiederholung: ${retryRecovered}/${serialRecoveryArchives.length} Archive erfolgreich entpackt`); + } + } } if (noExtractorEncountered) { @@ -4445,11 +4587,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, safeOnLog, emitOutput); rememberLearnedPassword(usedPw); } } else { - const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, signal, hybrid, undefined, false, undefined, safeOnLog, emitOutput); rememberLearnedPassword(usedPw); } }); diff --git a/src/main/main.ts b/src/main/main.ts index 107384d..d6f3cb7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -26,8 +26,8 @@ import { migrateProductUserDataDirectory } from "./storage"; import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector"; import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler"; import { forceDarkNativeTheme } from "./native-theme"; -import { normalizeExtractNowRequest } from "../shared/extract-now"; import { validateClipboardWriteText } from "./clipboard-write"; +import { registerExtractionIpcHandlers } from "./extraction-ipc"; forceDarkNativeTheme(nativeTheme); @@ -642,13 +642,7 @@ function registerIpcHandlers(): void { await fs.promises.writeFile(result.filePath, exported.text, "utf8"); return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; }); - handleTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { - validateString(packageId, "packageId"); - return controller.retryExtraction(packageId); - }); - handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => { - return controller.extractNow(normalizeExtractNowRequest(request)); - }); + registerExtractionIpcHandlers((channel, listener) => handleTrusted(channel, listener), controller); handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.resetPackage(packageId); diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 6dd5153..aee0de0 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -30,7 +30,10 @@ const pairs = [ ["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"], ["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"], ["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"], - ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"], + ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["CRC-Check läuft", "CRC check running"], ["Wartet", "Waiting"], ["Offline", "Offline"], + ["Entpacken - Ausstehend", "Extracting - Pending"], ["Entpacken - Warten auf Parts", "Extracting - Waiting for parts"], + ["Archive stabilisieren...", "Stabilizing archives..."], ["Entpacken vorbereiten...", "Preparing extraction..."], ["Entpacken wird neu gestartet...", "Restarting extraction..."], ["Nested Entpacken...", "Nested extraction..."], ["Umbenennen...", "Renaming..."], + ["Tonspur...", "Audio track..."], ["Aufräumen...", "Cleaning up..."], ["Verschiebe Videos...", "Moving videos..."], ["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Laufzeit", "Runtime"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."], ["Provider-Laufzeit", "Provider runtime"], ["Account-Laufzeit", "Account runtime"], ["Aktive Downloads", "Active downloads"], ["Erfolgsquote · Diese Sitzung", "Success rate · This session"], ["Zuletzt verwendet", "Last used"], ["Cooldown / Grund", "Cooldown / reason"], ["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["gerade eben", "just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"], @@ -224,35 +227,58 @@ export function normalizeLanguage(value: unknown): AppLanguage { } function translatePackageStatusParts(value: string, language: AppLanguage): string | null { - const parts = value.split(" · "); + const parts = value.split(/( · |\n)/); if (parts.length < 2) return null; - const translated = parts.map((part): string | null => { - if (language === "en") { - const exact = deToEn.get(part); - if (exact) return exact; - const extractionError = part.match(/^(\d+) Entpackfehler$/); - if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`; - const retry = part.match(/^(\d+) Wiederholung(?:en)?$/); - if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`; - const error = part.match(/^(\d+) Fehler$/); - if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`; - const cancelled = part.match(/^(\d+) abgebrochen$/); - if (cancelled) return `${cancelled[1]} cancelled`; - return null; + let changed = false; + const translated = parts.map((part): string => { + if (part === " · " || part === "\n") return part; + const runtime = translateRuntimeStatusPart(part, language); + if (runtime !== null) { + if (runtime !== part) changed = true; + return runtime; } - const exact = enToDe.get(part); - if (exact) return exact; - const extractionError = part.match(/^(\d+) extraction errors?$/); - if (extractionError) return `${extractionError[1]} Entpackfehler`; - const retry = part.match(/^(\d+) retr(?:y|ies)$/); - if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`; - const error = part.match(/^(\d+) errors?$/); - if (error) return `${error[1]} Fehler`; - const cancelled = part.match(/^(\d+) cancelled$/); - if (cancelled) return `${cancelled[1]} abgebrochen`; - return null; + return part; }); - return translated.every((part): part is string => part !== null) ? translated.join(" · ") : null; + return changed ? translated.join("") : null; +} + +function translateRuntimeStatusPart(value: string, language: AppLanguage): string | null { + const exact = (language === "en" ? deToEn : enToDe).get(value); + if (exact) return exact; + if (language === "en") { + const extracting = value.match(/^Entpacken(\s*-\s*|\s+)(\d+%)(.*)$/); + if (extracting) return `Extracting${extracting[1].includes("-") ? " - " : " "}${extracting[2]}${extracting[3]}`; + if (value === "Passwort gefunden") return "Password found"; + const password = value.match(/^Passwort knacken:\s*(\d+%)\s*(\(\d+\/\d+\))?(.*)$/); + if (password) return `Cracking password: ${password[1]}${password[2] ? ` ${password[2]}` : ""}${password[3]}`; + const nextArchive = value.match(/^Entpacken\s*(\(\d+\/\d+\))\s*-\s*Nächstes Archiv\.\.\.(.*)$/); + if (nextArchive) return `Extracting ${nextArchive[1]} - Next archive...${nextArchive[2]}`; + const extractionError = value.match(/^(\d+) Entpackfehler$/); + if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`; + const retry = value.match(/^(\d+) Wiederholung(?:en)?$/); + if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`; + const error = value.match(/^(\d+) Fehler$/); + if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`; + const cancelled = value.match(/^(\d+) abgebrochen$/); + if (cancelled) return `${cancelled[1]} cancelled`; + } else { + const extracting = value.match(/^Extracting(\s*-\s*|\s+)(\d+%)(.*)$/); + if (extracting) return `Entpacken${extracting[1].includes("-") ? " - " : " "}${extracting[2]}${extracting[3]}`; + if (value === "Password found") return "Passwort gefunden"; + const password = value.match(/^Cracking password:\s*(\d+%)\s*(\(\d+\/\d+\))?(.*)$/); + if (password) return `Passwort knacken: ${password[1]}${password[2] ? ` ${password[2]}` : ""}${password[3]}`; + const nextArchive = value.match(/^Extracting\s*(\(\d+\/\d+\))\s*-\s*Next archive\.\.\.(.*)$/); + if (nextArchive) return `Entpacken ${nextArchive[1]} - Nächstes Archiv...${nextArchive[2]}`; + const extractionError = value.match(/^(\d+) extraction errors?$/); + if (extractionError) return `${extractionError[1]} Entpackfehler`; + const retry = value.match(/^(\d+) retr(?:y|ies)$/); + if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`; + const error = value.match(/^(\d+) errors?$/); + if (error) return `${error[1]} Fehler`; + const cancelled = value.match(/^(\d+) cancelled$/); + if (cancelled) return `${cancelled[1]} abgebrochen`; + } + return null; } function translateDynamic(value: string, language: AppLanguage): string { @@ -260,8 +286,8 @@ function translateDynamic(value: string, language: AppLanguage): string { const source = language === "en" ? german : english; if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`; } - const packageStatus = translatePackageStatusParts(value, language); - if (packageStatus) return packageStatus; + const runtimeStatus = translateRuntimeStatusPart(value, language); + if (runtimeStatus) return runtimeStatus; if (language === "en") { const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/); if (update) return `${update[1]} is available. Installed version: ${update[2]}.`; @@ -445,6 +471,8 @@ function translateDynamic(value: string, language: AppLanguage): string { [/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"], [/^(.+) kopieren$/, "Copy $1"] ]; for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement); + const packageStatus = translatePackageStatusParts(value, language); + if (packageStatus) return packageStatus; return value .replace(/Automatisch entpacken/g, "Extract automatically") .replace(/Wartet auf Wiederholung/g, "Waiting to retry") @@ -631,6 +659,8 @@ function translateDynamic(value: string, language: AppLanguage): string { [/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"], [/^Copy (.+)$/, "$1 kopieren"] ]; for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement); + const packageStatus = translatePackageStatusParts(value, language); + if (packageStatus) return packageStatus; return value .replace(/Extract automatically/g, "Automatisch entpacken") .replace(/Waiting to retry/g, "Wartet auf Wiederholung") diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 10cddd7..7a34f27 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -392,14 +392,9 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) ? "Entpacken - Ausstehend" : compactPostProcessLabel; - const detailPostProcessLabel = rawPostProcessLabel || postProcessLabel; + const detailPostProcessLabel = presentation.activeOperationLabel ? rawPostProcessLabel || postProcessLabel : ""; const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; - const status = presentation.extractFailureCount === 0 - && presentation.retryCount === 0 - && postProcessLabel - && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") - ? postProcessLabel - : presentation.status; + const status = presentation.status; const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details; const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails; return ; diff --git a/src/renderer/views/downloads/package-presentation.ts b/src/renderer/views/downloads/package-presentation.ts index 5fea16d..d7bd72b 100644 --- a/src/renderer/views/downloads/package-presentation.ts +++ b/src/renderer/views/downloads/package-presentation.ts @@ -13,6 +13,7 @@ export interface PackagePresentation { progress: PackageProgressPresentation; status: string; details: string; + activeOperationLabel: string; extractFailure?: DownloadItem; extractFailureCount: number; retryCount: number; @@ -46,6 +47,16 @@ function isLinkConversionRetry(item: DownloadItem): boolean { return /(?:Link-Umwandlung erneut|Retrying link conversion)/i.test(item.fullStatus || ""); } +function activePackageOperationLabel(packageStatus: DownloadPackageRow["package"]["status"], label: string): string { + if (!label) return ""; + if (/^(?:Entpacken\s+\d+%|Entpacken\s*\(\d+\/\d+\)\s*-\s*Nächstes Archiv|Passwort\b|Password\b|Finalisieren\b|Finalizing\b)/i.test(label)) { + return packageStatus === "extracting" ? label : ""; + } + return /^(?:Archive stabilisieren|Entpacken vorbereiten|Entpacken wird neu gestartet|Nested Entpacken|Renaming|Tonspur|Aufräumen|Verschiebe Videos)\b/i.test(label) + ? label + : ""; +} + function downloadFraction(item: DownloadItem): number { if (item.status === "completed") { return 1; @@ -71,6 +82,9 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen let retrying = 0; let linkConversionRetrying = 0; let waitsForDisk = 0; + let integrityChecking = 0; + let extractionPending = 0; + let waitingForParts = 0; const extractFailures: DownloadItem[] = []; for (const item of row.allItems) { @@ -79,12 +93,15 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen else if (item.status === "cancelled") cancelled += 1; downloadUnits += downloadFraction(item); const fullStatus = item.fullStatus || ""; + if (item.status === "integrity_check") integrityChecking += 1; + if (/^Entpacken\s*-\s*Ausstehend\b/i.test(fullStatus)) extractionPending += 1; + if (/^Entpacken\s*-\s*Warten auf Parts\b/i.test(fullStatus)) waitingForParts += 1; if (/^Entpackt\b/i.test(fullStatus)) { extractionUnits += 1; extractionLifecycle = true; } else { const progress = extractionPercent(fullStatus); - if (progress > 0 || /^(?:Entpacken|Finalisieren)\b/i.test(fullStatus)) { + if (progress > 0 || /^Entpacken\s+\d+%/i.test(fullStatus) || /^Finalisieren\b/i.test(fullStatus)) { extracting += 1; extractionUnits += progress; } @@ -119,29 +136,41 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`; const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1); const packageExtractLabel = (row.package.postProcessLabel || "").trim(); + const activeOperationLabel = activePackageOperationLabel(row.package.status, packageExtractLabel); const downloading = row.package.status === "downloading" || row.package.status === "validating" || row.allItems.some((item) => item.status === "downloading" || item.status === "validating"); let status = allExtracted ? "Entpackt" : details; - if (extractFailures.length > 0 && retrying > 0) { - status = `${extractFailures.length} Entpackfehler · ${retryLabel}`; - } else if (extractFailures.length > 0) { - status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`; + if (activeOperationLabel) { + status = activeOperationLabel; } else if (waitsForDisk > 0) { status = "Warte auf Festplatte"; - } else if (extracting > 0 || row.package.status === "extracting") { - status = packageExtractLabel || "Entpacken"; + } else if (integrityChecking > 0) { + status = "CRC-Check läuft"; + } else if (extractFailures.length > 0 && retrying > 0) { + status = `${extractFailures.length} Entpackfehler · ${retryLabel}`; } else if (retrying > 0) { status = retryLabel; } else if (downloading) { status = "Download läuft"; + } else if (extractFailures.length > 0) { + status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`; + } else if (extracting > 0) { + status = "Entpacken"; + } else if (extractionPending > 0) { + status = "Entpacken - Ausstehend"; + } else if (waitingForParts > 0) { + status = "Entpacken - Warten auf Parts"; + } else if (row.package.status === "extracting") { + status = "Entpacken"; } return { progress: { done, failed, cancelled, total, value }, status, details, + activeOperationLabel, extractFailure: extractFailures[0], extractFailureCount: extractFailures.length, retryCount: retrying, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 4e93479..2a263d0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; +import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, findCrcImplicatedArchiveItems, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveSelectedArchiveSetsFromCandidates, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { DiskReservationCoordinator } from "../src/main/disk-space"; import { ExtractionCoordinator } from "../src/main/extraction-coordinator"; @@ -113,6 +113,13 @@ describe("selected item run scope", () => { expect(internal.findNextQueuedItem()).toBeNull(); expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false }); + + internal.session.items[itemIds[1]].status = "reconnect_wait"; + internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF"; + internal.retryAfterByItem.set(itemIds[1], 99_000); + manager.stop(); + expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" })); + expect(internal.retryAfterByItem.get(itemIds[1])).toBe(99_000); }); it("stops only selected run items without erasing sibling wait state", async () => { @@ -150,6 +157,114 @@ describe("selected item run scope", () => { expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true); expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false); }); + + it("does not widen an exhausted selected scope to an unselected sibling", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-exhausted-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + + await internal.startItemsNow([itemIds[0]]); + internal.runItemIds.delete(itemIds[0]); + internal.runPackageIds.clear(); + + expect(internal.findNextQueuedItem()).toBeNull(); + expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false }); + + internal.session.items[itemIds[1]].status = "reconnect_wait"; + internal.session.items[itemIds[1]].fullStatus = "FREMDER_BACKOFF"; + internal.retryAfterByItem.set(itemIds[1], Date.now() + 60_000); + manager.stop(); + expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "FREMDER_BACKOFF" })); + expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true); + }); + + it("resuming preserves item, disk and provider cooldown state", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + await internal.startItemsNow([itemIds[0]]); + internal.session.paused = true; + const now = Date.now(); + internal.retryAfterByItem.set(itemIds[0], now + 11_000); + internal.providerStartReservations.set("provider-key", now + 12_000); + internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000); + internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 }); + + manager.togglePause(); + + expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000); + expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000); + expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000); + expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000); + expect(internal.findNextQueuedItem()).toBeNull(); + }); + + it("keeps a newly added package outside a selected running scope", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-add-package-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + await internal.startItemsNow([itemIds[0]]); + + manager.addPackages([{ name: "added-later", links: ["https://dummy/added-later"] }]); + const addedPackageId = internal.session.packageOrder.at(-1); + const addedItemId = internal.session.packages[addedPackageId].itemIds[0]; + + expect(internal.runPackageIds.has(addedPackageId)).toBe(false); + expect(internal.runItemIds.has(addedItemId)).toBe(false); + expect(internal.findNextQueuedItem()?.itemId).not.toBe(addedItemId); + }); + + it("computes provider retry only from selected run items", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-provider-retry-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + await internal.startItemsNow([itemIds[0]]); + const deadline = Date.now() + 30_000; + internal.debridService.getBlockingProviderRetryAt = (url: string) => url.endsWith("/first") ? deadline : null; + + expect(internal.getEarliestProviderRetryAt(Date.now())).toBe(deadline); + }); + + it.each(["items", "packages"] as const)("does not trigger foreign post-processing from a selected %s start", async (mode) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-selected-postprocess-${mode}-`)); + tempDirs.push(root); + const { manager, packageId, itemIds } = createSelectedItemManager(root); + manager.addPackages([{ name: "foreign-completed", links: ["https://dummy/foreign.rar"] }]); + const internal = manager as any; + const foreignPackageId = internal.session.packageOrder.find((id: string) => id !== packageId); + const foreignItemId = internal.session.packages[foreignPackageId].itemIds[0]; + internal.session.items[foreignItemId].status = "completed"; + internal.session.items[foreignItemId].downloadedBytes = 100; + internal.session.items[foreignItemId].totalBytes = 100; + internal.session.items[foreignItemId].progressPercent = 100; + internal.session.items[foreignItemId].fullStatus = "Entpacken - Ausstehend"; + internal.session.packages[foreignPackageId].status = "completed"; + internal.ensureScheduler = async () => {}; + const postProcess = vi.fn(async () => {}); + internal.runPackagePostProcessing = postProcess; + + if (mode === "items") { + await internal.startItemsNow([itemIds[0]]); + } else { + await internal.startPackagesNow([packageId]); + } + + expect(postProcess).not.toHaveBeenCalledWith(foreignPackageId); + expect(internal.runPackageIds.has(foreignPackageId)).toBe(false); + expect(internal.runItemIds.has(foreignItemId)).toBe(false); + }); }); describe("download live update cadence", () => { @@ -7228,7 +7343,7 @@ describe("download manager", () => { errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, - jvmFailureReason: "Can not open the file as archive" + jvmFailureReason: "7z-Fehler: CRCERROR" }, "hybrid" ); @@ -7324,9 +7439,9 @@ describe("download manager", () => { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", - category: "crc_error", - suggestRedownload: true, - jvmFailureReason: "Can not open the file as archive" + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" }, "hybrid" ); @@ -7343,7 +7458,7 @@ describe("download manager", () => { expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); }); - it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { + it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -7418,21 +7533,41 @@ describe("download manager", () => { archiveName: "show.s01e01.part1.rar", archivePath: path.join(outputDir, "show.s01e01.part1.rar"), errorText: "Checksum error in the encrypted file", - category: "crc_error", - suggestRedownload: true, - jvmFailureReason: "Can not open the file as archive" + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" }, "hybrid" ); expect(changed).toBe(0); - for (const itemId of itemIds) { - const item = session.items[itemId]!; - expect(item.status).toBe("completed"); - expect(item.targetPath).toContain(".rar"); - expect(item.downloadedBytes).toBe(archiveSize); - } - }); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("completed"); + expect(item.targetPath).toContain(".rar"); + expect(item.downloadedBytes).toBe(archiveSize); + } + + for (const archiveName of archiveNames) { + fs.writeFileSync(path.join(outputDir, archiveName), Buffer.alloc(archiveSize, 0x7f)); + } + const wrongPasswordChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + archivePath: path.join(outputDir, "show.s01e01.part1.rar"), + errorText: "Wrong password", + category: "wrong_password", + suggestRedownload: true, + jvmFailureReason: "WRONG_PASSWORD" + }, + "hybrid" + ); + expect(wrongPasswordChanged).toBe(0); + expect(itemIds.map((itemId) => session.items[itemId]!.status)).toEqual(["completed", "completed"]); + expect(archiveNames.map((archiveName) => fs.existsSync(path.join(outputDir, archiveName)))).toEqual([true, true]); + }); it("does not treat rev files as ready archive parts during disk fallback", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -7513,10 +7648,11 @@ describe("download manager", () => { }, session, createStoragePaths(path.join(root, "state")) - ); - - const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); - expect(Array.from(ready)).toHaveLength(0); + ); + + expect((manager as any).looksLikeArchivePart("show.s01e01.part2.rar", "show.s01e01.part1.rar")).toBe(true); + const ready = await (manager as any).findReadyArchiveSets(session.packages[packageId]); + expect(Array.from(ready)).toEqual([]); }); it("allows disk fallback when queued archive parts are fully present on disk", async () => { @@ -9804,6 +9940,140 @@ describe("download manager", () => { } }); + it("requeues confirmed corrupt volumes independently when one part is locked", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-crc-specific-volume-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = "crc-specific-volume-pkg"; + const outputDir = path.join(root, "downloads", "crc-specific-volume"); + const extractDir = path.join(root, "extract", "crc-specific-volume"); + fs.mkdirSync(outputDir, { recursive: true }); + const archiveNames = ["show.part1.rar", "show.part2.rar", "show.part3.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-specific-${index + 1}`); + const createdAt = Date.now() - 1000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc-specific-volume", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + resultGeneration: 4, + createdAt, + updatedAt: createdAt + }; + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + const bytes = Buffer.alloc(64 * 1024, index + 1); + Buffer.from("526172211a070100", "hex").copy(bytes, 0); + fs.writeFileSync(targetPath, bytes); + session.items[itemIds[index]] = { + id: itemIds[index], + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: bytes.length, + totalBytes: bytes.length, + progressPercent: 100, + fileName: archiveName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true }, + session, + createStoragePaths(path.join(root, "state")) + ); + const failure = { + archiveName: "show.part1.rar", + archivePath: path.join(outputDir, "show.part1.rar"), + errorText: `Prüfsummenfehler der gepackten Daten in Volume ${path.join(outputDir, "show.part3.rar")} Corrupt file or wrong password`, + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" + } as const; + + session.items[itemIds[0]].downloadedBytes = 0; + const lockedPath = path.join(outputDir, "show.part1.rar"); + const implicatedPath = path.join(outputDir, "show.part3.rar"); + const originalRmSync = fs.rmSync.bind(fs); + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((targetPath: fs.PathLike, options?: fs.RmDirOptions) => { + if (path.resolve(String(targetPath)) === path.resolve(lockedPath)) { + throw Object.assign(new Error("locked"), { code: "EPERM" }); + } + return originalRmSync(targetPath, options as never); + }) as typeof fs.rmSync); + const blockedChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]), + failure, + "full" + ); + rmSpy.mockRestore(); + expect(blockedChanged).toBe(1); + expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "completed", targetPath: lockedPath })); + expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" })); + expect(fs.existsSync(lockedPath)).toBe(true); + expect(fs.existsSync(implicatedPath)).toBe(false); + + fs.writeFileSync(implicatedPath, Buffer.alloc(64 * 1024, 3)); + Object.assign(session.items[itemIds[2]], { + status: "completed", + targetPath: implicatedPath, + downloadedBytes: 64 * 1024, + totalBytes: 64 * 1024, + progressPercent: 100, + fullStatus: "Entpacken - Ausstehend", + updatedAt: Date.now() + }); + const secondChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]), + failure, + "full" + ); + + expect(secondChanged).toBe(1); + expect(session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", targetPath: "" })); + expect(session.items[itemIds[1]].status).toBe("completed"); + expect(session.items[itemIds[2]]).toEqual(expect.objectContaining({ status: "completed", targetPath: implicatedPath })); + expect(fs.existsSync(path.join(outputDir, archiveNames[0]))).toBe(false); + expect(fs.existsSync(path.join(outputDir, archiveNames[1]))).toBe(true); + expect(fs.existsSync(path.join(outputDir, archiveNames[2]))).toBe(true); + + fs.writeFileSync(lockedPath, Buffer.alloc(64 * 1024, 1)); + Object.assign(session.items[itemIds[0]], { + status: "completed", + targetPath: lockedPath, + downloadedBytes: 64 * 1024, + totalBytes: 64 * 1024, + progressPercent: 100, + fullStatus: "Entpacken - Ausstehend", + updatedAt: Date.now() + }); + const repeatedChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]), + failure, + "full" + ); + expect(repeatedChanged).toBe(0); + expect(fs.existsSync(lockedPath)).toBe(true); + expect(fs.existsSync(implicatedPath)).toBe(true); + }); + it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -16713,3 +16983,576 @@ describe("download health snapshot", () => { expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1); }); }); + +describe("post-processing lifecycle audit", () => { + function createCompletedFileManager( + root: string, + entries: Array<{ packageId: string; itemId: string; fileName: string; url?: string; content?: Buffer }>, + settings: Partial = {} + ): { manager: DownloadManager; session: ReturnType } { + const session = emptySession(); + const createdAt = Date.now() - 10_000; + for (const entry of entries) { + const outputDir = path.join(root, "downloads", entry.packageId); + const extractDir = path.join(root, "extract", entry.packageId); + fs.mkdirSync(outputDir, { recursive: true }); + const targetPath = path.join(outputDir, entry.fileName); + fs.writeFileSync(targetPath, entry.content ?? Buffer.alloc(256, 1)); + if (!session.packages[entry.packageId]) { + session.packageOrder.push(entry.packageId); + session.packages[entry.packageId] = { + id: entry.packageId, + name: entry.packageId, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + } + session.packages[entry.packageId].itemIds.push(entry.itemId); + const bytes = fs.statSync(targetPath).size; + session.items[entry.itemId] = { + id: entry.itemId, + packageId: entry.packageId, + url: entry.url ?? `https://example.test/${entry.itemId}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: bytes, + totalBytes: bytes, + progressPercent: 100, + fileName: entry.fileName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoRename4sf4sj: false, + collectMkvToLibrary: false, + enableIntegrityCheck: false, + cleanupMode: "none", + ...settings + }, + session, + createStoragePaths(path.join(root, "state")) + ); + return { manager, session }; + } + + it("requeues an interrupted integrity check and clears transient package progress on restart", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "integrity-package", itemId: "integrity-item", fileName: "episode.mkv" }]); + const internal = manager as any; + session.items["integrity-item"].status = "integrity_check"; + session.items["integrity-item"].fullStatus = "CRC-Check läuft"; + session.packages["integrity-package"].status = "integrity_check"; + session.packages["integrity-package"].postProcessLabel = "Finalisieren (1/1)"; + + internal.normalizeSessionStatuses(); + + expect(session.items["integrity-item"]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); + expect(session.packages["integrity-package"].status).toBe("queued"); + expect(session.packages["integrity-package"].postProcessLabel).toBeUndefined(); + }); + + it("keeps duplicate-suffixed items with independently existing files separate on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-distinct-urls-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "distinct-package", itemId: "first-url", fileName: "episode.rar", url: "https://same.test/file" }, + { packageId: "distinct-package", itemId: "second-url", fileName: "episode (1).rar", url: "https://same.test/file" } + ]); + + const snapshot = manager.getSnapshot().session; + expect(snapshot.packages["distinct-package"].itemIds).toEqual(["first-url", "second-url"]); + expect(snapshot.items["first-url"]).toBeDefined(); + expect(snapshot.items["second-url"]).toBeDefined(); + expect(fs.existsSync(session.items["second-url"].targetPath)).toBe(true); + }); + + it("moves startup cleanup archives to the recoverable trash in trash mode", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-trash-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "trash-package", itemId: "trash-item", fileName: "episode.part1.rar" }]); + const internal = manager as any; + session.items["trash-item"].fullStatus = "Entpackt - Fertig"; + internal.settings.cleanupMode = "trash"; + const archivePath = session.items["trash-item"].targetPath; + + await internal.cleanupExistingExtractedArchives(); + + expect(fs.existsSync(archivePath)).toBe(false); + expect(fs.readdirSync(path.join(path.dirname(archivePath), ".rd-trash"))).toHaveLength(1); + }); + + it("does not let queued startup cleanup remove a newly replaced archive", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-cleanup-race-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "cleanup-race-package", itemId: "cleanup-race-item", fileName: "episode.rar" }]); + const internal = manager as any; + const archivePath = session.items["cleanup-race-item"].targetPath; + session.items["cleanup-race-item"].fullStatus = "Entpackt - Fertig"; + internal.settings.cleanupMode = "delete"; + let releaseQueue = (): void => {}; + const queueGate = new Promise((resolve) => { releaseQueue = resolve; }); + internal.cleanupQueue = queueGate; + + const cleanup = internal.cleanupExistingExtractedArchives(); + await waitFor(() => internal.cleanupQueue !== queueGate, 2_000); + fs.writeFileSync(archivePath, Buffer.from("replacement")); + releaseQueue(); + await cleanup; + + expect(fs.readFileSync(archivePath, "utf8")).toBe("replacement"); + }); + + it("never cleans an archive part claimed by another package in a shared output directory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cleanup-shared-claim-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "cleanup-owner-a", itemId: "cleanup-owner-a-item", fileName: "show.part1.rar" }, + { packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-item", fileName: "show.part2.rar" }, + { packageId: "cleanup-owner-b", itemId: "cleanup-owner-b-nfo", fileName: "show.nfo" } + ], { cleanupMode: "delete" }); + const internal = manager as any; + const sharedDir = session.packages["cleanup-owner-a"].outputDir; + const foreignOldPath = session.items["cleanup-owner-b-item"].targetPath; + const foreignNfoOldPath = session.items["cleanup-owner-b-nfo"].targetPath; + const foreignSharedPath = path.join(sharedDir, "show.part2.rar"); + const foreignNfoSharedPath = path.join(sharedDir, "show.nfo"); + fs.renameSync(foreignOldPath, foreignSharedPath); + fs.renameSync(foreignNfoOldPath, foreignNfoSharedPath); + session.packages["cleanup-owner-b"].outputDir = sharedDir; + session.items["cleanup-owner-b-item"].targetPath = foreignSharedPath; + session.items["cleanup-owner-b-nfo"].targetPath = foreignNfoSharedPath; + + const removed = await internal.cleanupRemainingArchiveArtifacts(session.packages["cleanup-owner-a"]); + + expect(removed).toBe(1); + expect(fs.existsSync(session.items["cleanup-owner-a-item"].targetPath)).toBe(false); + expect(fs.existsSync(foreignSharedPath)).toBe(true); + expect(fs.existsSync(foreignNfoSharedPath)).toBe(true); + }); + + it("rejects an ambiguous pathless selection shared by multiple archive directories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ambiguous-pathless-")); + tempDirs.push(root); + const item = { + id: "pathless-item", packageId: "pathless-package", url: "https://example.test/pathless", provider: "realdebrid", + status: "completed", retries: 0, speedBps: 0, downloadedBytes: 1, totalBytes: 1, progressPercent: 100, + fileName: "episode.part1.rar", targetPath: "", resumable: true, attempts: 1, lastError: "", + fullStatus: "Entpacken - Ausstehend", createdAt: Date.now(), updatedAt: Date.now() + } satisfies DownloadItem; + + const selection = resolveSelectedArchiveSetsFromCandidates( + [path.join(root, "set-a", "episode.part1.rar"), path.join(root, "set-b", "episode.part1.rar")], + [item], + new Set([item.id]) + ); + + expect(selection.archivePaths.size).toBe(0); + expect(selection.itemIds.size).toBe(0); + }); + + it("accepts an explicitly named first multipart volume as the CRC target", () => { + const items = [ + { id: "crc-first", fileName: "show.part1.rar", targetPath: "C:\\Downloads\\show.part1.rar" }, + { id: "crc-second", fileName: "show.part2.rar", targetPath: "C:\\Downloads\\show.part2.rar" } + ] as DownloadItem[]; + + expect(findCrcImplicatedArchiveItems("C:\\Downloads\\show.part1.rar - checksum error", items).map((item) => item.id)).toEqual(["crc-first"]); + }); + + it("marks an unexpected package post-process exception as failure but not an abort", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-exception-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "exception-package", itemId: "exception-item", fileName: "episode.rar" }]); + const internal = manager as any; + internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("unexpected-postprocess-failure"); }); + + await internal.runPackagePostProcessing("exception-package"); + + expect(session.packages["exception-package"].status).toBe("failed"); + expect(session.items["exception-item"].fullStatus).toMatch(/^Entpack-Fehler/); + + session.packages["exception-package"].status = "queued"; + session.items["exception-item"].fullStatus = "Entpacken - Ausstehend"; + internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => { + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted:extract")), { once: true }); + queueMicrotask(() => internal.abortPackagePostProcessing("exception-package", "stop")); + }); + }); + await internal.runPackagePostProcessing("exception-package"); + expect(session.packages["exception-package"].status).not.toBe("failed"); + expect(session.items["exception-item"].fullStatus).toBe("Entpacken - Ausstehend"); + + session.packages["exception-package"].status = "queued"; + internal.handlePackagePostProcessing = vi.fn(async () => { throw new Error("aborted:extract"); }); + await internal.runPackagePostProcessing("exception-package"); + expect(session.packages["exception-package"].status).toBe("failed"); + }); + + it("preserves a manual extraction plan across an automatic disk-capacity retry", async () => { + vi.useFakeTimers(); + try { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-disk-retry-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-disk-package", itemId: "manual-disk-item", fileName: "episode.zip" }]); + const internal = manager as any; + const archivePath = session.items["manual-disk-item"].targetPath; + const archiveKey = path.resolve(archivePath).toLowerCase(); + internal.manualExtractPackages.add("manual-disk-package"); + internal.manualExtractArchiveFilters.set("manual-disk-package", new Set([archiveKey])); + internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath])); + internal.diskReservations = new DiskReservationCoordinator({ + safetyBytes: 0, + retryDelayMs: 1_000, + statVolume: async (targetPath) => ({ path: targetPath, volumeKey: "manual-volume", freeBytes: 0, totalBytes: 1_024 }) + }); + const rerun = vi.fn(async () => {}); + internal.runPackagePostProcessing = rerun; + + await internal.handlePackagePostProcessing("manual-disk-package"); + expect(manager.getSnapshot().canStop).toBe(true); + manager.togglePackage("manual-disk-package"); + expect(internal.packageDiskRetryPlans.has("manual-disk-package")).toBe(true); + manager.togglePackage("manual-disk-package"); + internal.manualExtractPackages.clear(); + internal.manualExtractArchiveFilters.clear(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(rerun).toHaveBeenCalledWith("manual-disk-package"); + expect(internal.manualExtractPackages.has("manual-disk-package")).toBe(true); + expect(internal.manualExtractArchiveFilters.get("manual-disk-package")).toEqual(new Set([archiveKey])); + expect(internal.packageDiskRetryAfterByPackage.has("manual-disk-package")).toBe(false); + expect(manager.getSnapshot().diskWaitEvents?.some((entry) => entry.packageId === "manual-disk-package")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps disk retry generations and stale timer callbacks isolated", async () => { + vi.useFakeTimers(); + try { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-identity-")); + tempDirs.push(root); + const { manager } = createCompletedFileManager(root, [{ packageId: "disk-identity-package", itemId: "disk-identity-item", fileName: "episode.zip" }]); + const internal = manager as any; + const rerun = vi.fn(async () => {}); + internal.runPackagePostProcessing = rerun; + const request = { retryAt: Date.now() + 60_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null }; + + internal.schedulePackageDiskRetry("disk-identity-package", request); + const firstId = internal.packageDiskRetryPlans.get("disk-identity-package").id; + internal.schedulePackageDiskRetry("disk-identity-package", request); + const secondId = internal.packageDiskRetryPlans.get("disk-identity-package").id; + internal.packagePostProcessVersions.set("disk-identity-package", 1); + expect(internal.schedulePackageDiskRetry("disk-identity-package", request)).toBe(false); + expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId); + internal.packagePostProcessVersions.set("disk-identity-package", 0); + internal.executePackageDiskRetry("disk-identity-package", firstId); + expect(internal.packageDiskRetryPlans.get("disk-identity-package").id).toBe(secondId); + expect(rerun).not.toHaveBeenCalled(); + + const getRunOwner = internal.getPackageResultRunOwner.bind(internal); + internal.getPackageResultRunOwner = () => "new-owner"; + internal.executePackageDiskRetry("disk-identity-package", secondId); + expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); + expect(rerun).not.toHaveBeenCalled(); + internal.getPackageResultRunOwner = getRunOwner; + + internal.schedulePackageDiskRetry("disk-identity-package", request); + const thirdId = internal.packageDiskRetryPlans.get("disk-identity-package").id; + internal.session.packages["disk-identity-package"].resultGeneration += 1; + internal.executePackageDiskRetry("disk-identity-package", thirdId); + expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); + expect(rerun).not.toHaveBeenCalled(); + + manager.stop(); + internal.schedulePackageDiskRetry("disk-identity-package", request); + expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); + internal.healthManualStop = false; + manager.prepareForShutdown(); + internal.schedulePackageDiskRetry("disk-identity-package", request); + expect(internal.packageDiskRetryPlans.has("disk-identity-package")).toBe(false); + await vi.runAllTimersAsync(); + expect(rerun).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("clears a paused run-owned disk retry when re-enable has no matching owner", async () => { + vi.useFakeTimers(); + try { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-owner-rebind-")); + tempDirs.push(root); + const { manager } = createCompletedFileManager(root, [{ packageId: "disk-owner-package", itemId: "disk-owner-item", fileName: "episode.zip" }]); + const internal = manager as any; + const rerun = vi.fn(async () => {}); + internal.runPackagePostProcessing = rerun; + internal.session.running = true; + internal.runScopeKind = "selected"; + internal.runPackageIds.add("disk-owner-package"); + const context = internal.beginActiveRunContext(new Set(["disk-owner-package"]), Date.now()); + expect(internal.schedulePackageDiskRetry("disk-owner-package", { + retryAt: Date.now() + 1_000, + manualRequested: true, + postProcessVersion: 0, + runOwnerId: context.id + })).toBe(true); + + manager.togglePackage("disk-owner-package"); + expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(true); + expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false); + manager.togglePackage("disk-owner-package"); + + expect(internal.packageDiskRetryPlans.has("disk-owner-package")).toBe(false); + expect(internal.packageDiskRetryTimers.has("disk-owner-package")).toBe(false); + expect(internal.packageDiskRetryAfterByPackage.has("disk-owner-package")).toBe(false); + expect(internal.getActivePostProcessingCount()).toBe(0); + await vi.advanceTimersByTimeAsync(1_000); + expect(rerun).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a due disk retry when stop wins the race", async () => { + vi.useFakeTimers(); + try { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-retry-stop-race-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "disk-stop-package", itemId: "disk-stop-item", fileName: "episode.zip" }]); + const internal = manager as any; + const rerun = vi.fn(async () => {}); + internal.runPackagePostProcessing = rerun; + internal.session.running = true; + internal.runScopeKind = "selected"; + internal.runPackageIds.add("disk-stop-package"); + internal.runItemIds.add("disk-stop-item"); + expect(internal.schedulePackageDiskRetry("disk-stop-package", { retryAt: Date.now() + 1_000, manualRequested: true, postProcessVersion: 0, runOwnerId: null })).toBe(true); + + manager.stop(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(rerun).not.toHaveBeenCalled(); + expect(internal.packageDiskRetryPlans.size).toBe(0); + expect(internal.packageDiskRetryTimers.size).toBe(0); + expect(manager.getSnapshot().lifecycle?.phase).toBe("idle"); + expect(session.running).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("passes separate manual and partial flags to deferred extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-scope-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "manual-cleanup-package", itemId: "manual-cleanup-item", fileName: "episode.rar" }]); + const internal = manager as any; + const archivePath = session.items["manual-cleanup-item"].targetPath; + internal.findFullExtractArchiveSet = vi.fn(async () => new Set([archivePath])); + internal.runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" })); + const deferred = vi.fn(async () => {}); + internal.runDeferredPostExtraction = deferred; + internal.manualExtractPackages.add("manual-cleanup-package"); + + await internal.handlePackagePostProcessing("manual-cleanup-package"); + expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, false]); + + session.packages["manual-cleanup-package"].status = "queued"; + session.items["manual-cleanup-item"].fullStatus = "Entpacken - Ausstehend"; + internal.manualExtractArchiveFilters.set("manual-cleanup-package", new Set([path.resolve(archivePath).toLowerCase()])); + await internal.handlePackagePostProcessing("manual-cleanup-package"); + expect(deferred.mock.calls.at(-1)?.slice(6)).toEqual([true, true]); + }); + + it("cleans full manual package archives but preserves partial manual state", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-cleanup-behavior-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "manual-cleanup-behavior", itemId: "cleanup-a", fileName: "episode-a.rar" }, + { packageId: "manual-cleanup-behavior", itemId: "cleanup-b", fileName: "episode-b.rar" } + ], { cleanupMode: "delete" }); + const internal = manager as any; + const firstPath = session.items["cleanup-a"].targetPath; + const secondPath = session.items["cleanup-b"].targetPath; + + await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, true); + expect(fs.existsSync(firstPath)).toBe(true); + expect(fs.existsSync(secondPath)).toBe(true); + + await internal.runDeferredPostExtraction("manual-cleanup-behavior", session.packages["manual-cleanup-behavior"], 2, 0, true, 2, true, false); + expect(fs.existsSync(firstPath)).toBe(false); + expect(fs.existsSync(secondPath)).toBe(false); + }); + + it("rejects package extraction when no archive candidate exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-no-archive-")); + tempDirs.push(root); + const { manager } = createCompletedFileManager(root, [{ packageId: "no-archive-package", itemId: "no-archive-item", fileName: "episode.mkv" }]); + const internal = manager as any; + const postProcess = vi.fn(async () => {}); + internal.runPackagePostProcessing = postProcess; + + await expect(manager.extractNow("no-archive-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt"); + expect(manager.getSnapshot().session.items["no-archive-item"].fullStatus).toBe("Entpacken - Ausstehend"); + expect(manager.getSnapshot().session.packages["no-archive-package"].status).toBe("completed"); + expect(internal.manualExtractPackages.has("no-archive-package")).toBe(false); + expect(postProcess).not.toHaveBeenCalled(); + }); + + it("rejects retryExtraction when the failed package has no complete archive", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-no-archive-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "retry-no-archive", itemId: "retry-no-archive-item", fileName: "episode.mkv" }]); + const internal = manager as any; + session.items["retry-no-archive-item"].fullStatus = "Entpack-Fehler: vorheriger Fehler"; + internal.runPackagePostProcessing = vi.fn(async () => {}); + + await expect(manager.retryExtraction("retry-no-archive")).rejects.toThrow(/Kein .*entpackbarer Archivsatz ausgewählt/); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); + expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler"); + }); + + it("recognizes and arms an opaque archive file by signature", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); + const content = zip.toBuffer(); + const { manager } = createCompletedFileManager(root, [{ packageId: "opaque-package", itemId: "opaque-item", fileName: "download.bin", content }]); + const internal = manager as any; + + await manager.extractNow("opaque-package"); + const task = internal.packagePostProcessTasks.get("opaque-package"); + expect(task).toBeDefined(); + await task; + + expect(manager.getSnapshot().session.items["opaque-item"].fileName).toMatch(/\.zip$/i); + }); + + it("extracts a uniquely resolvable pathless legacy item through the public API", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-pathless-public-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "pathless-public", itemId: "pathless-public-item", fileName: "episode.zip", content: zip.toBuffer() }]); + const internal = manager as any; + const archivePath = session.items["pathless-public-item"].targetPath; + internal.releaseTargetPath("pathless-public-item"); + session.items["pathless-public-item"].targetPath = ""; + const postProcess = vi.fn(async () => {}); + internal.runPackagePostProcessing = postProcess; + + await manager.extractNow("pathless-public"); + + expect(session.items["pathless-public-item"].targetPath).toBe(archivePath); + expect(session.items["pathless-public-item"].fullStatus).toBe("Entpacken - Ausstehend"); + expect(postProcess).toHaveBeenCalledWith("pathless-public"); + }); + + it("rejects an incomplete multipart package before starting extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-incomplete-multipart-")); + tempDirs.push(root); + const { manager, session } = createCompletedFileManager(root, [{ packageId: "incomplete-package", itemId: "incomplete-part1", fileName: "show.part1.rar" }]); + const internal = manager as any; + const part2Id = "incomplete-part2"; + session.packages["incomplete-package"].itemIds.push(part2Id); + session.items[part2Id] = { + ...session.items["incomplete-part1"], + id: part2Id, + status: "queued", + fileName: "show.part2.rar", + targetPath: "", + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fullStatus: "Wartet" + }; + internal.runPackagePostProcessing = vi.fn(async () => {}); + + await expect(manager.extractNow("incomplete-package")).rejects.toThrow("Kein entpackbarer Archivsatz ausgewählt"); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); + }); + + it("rejects a mixed extraction batch before starting any package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-mixed-batch-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from("video")); + const { manager } = createCompletedFileManager(root, [ + { packageId: "valid-package", itemId: "valid-item", fileName: "episode.zip", content: zip.toBuffer() }, + { packageId: "invalid-package", itemId: "invalid-item", fileName: "episode.mkv" } + ]); + const internal = manager as any; + internal.runPackagePostProcessing = vi.fn(async () => {}); + + await expect(manager.extractNow({ packageIds: ["valid-package", "invalid-package"], itemIds: [] })).rejects.toThrow(/1.*nicht gestartet/i); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("valid-package"); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalledWith("invalid-package"); + }); + + it("keeps opaque archive files untouched when batch preflight rejects another target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-atomic-opaque-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("episode.mkv", crypto.randomBytes(64 * 1024)); + const { manager, session } = createCompletedFileManager(root, [ + { packageId: "atomic-opaque", itemId: "atomic-opaque-item", fileName: "download.bin", content: zip.toBuffer() }, + { packageId: "atomic-invalid", itemId: "atomic-invalid-item", fileName: "episode.mkv" } + ]); + const internal = manager as any; + internal.runPackagePostProcessing = vi.fn(async () => {}); + const opaquePath = session.items["atomic-opaque-item"].targetPath; + + await expect(manager.extractNow({ packageIds: ["atomic-opaque", "atomic-invalid"], itemIds: [] })).rejects.toThrow(/nicht gestartet/i); + + expect(session.items["atomic-opaque-item"].fileName).toBe("download.bin"); + expect(session.items["atomic-opaque-item"].targetPath).toBe(opaquePath); + expect(fs.existsSync(opaquePath)).toBe(true); + expect(fs.existsSync(path.join(path.dirname(opaquePath), "download.zip"))).toBe(false); + expect(internal.runPackagePostProcessing).not.toHaveBeenCalled(); + }); + + it("exposes and drains stop for standalone manual extraction without starting a download run", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-standalone-stop-")); + tempDirs.push(root); + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from("video")); + const { manager } = createCompletedFileManager(root, [{ packageId: "standalone-package", itemId: "standalone-item", fileName: "episode.zip", content: zip.toBuffer() }]); + const internal = manager as any; + internal.handlePackagePostProcessing = vi.fn(async (_packageId: string, signal: AbortSignal) => { + await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })); + }); + + await manager.extractNow("standalone-package"); + await waitFor(() => internal.packagePostProcessTasks.size === 1, 2_000); + expect(manager.getSnapshot().canStop).toBe(true); + expect(manager.getSnapshot().session.running).toBe(false); + + manager.stop(); + await waitFor(() => internal.packagePostProcessTasks.size === 0, 2_000); + expect(manager.getSnapshot().lifecycle?.phase).toBe("idle"); + expect(manager.getSnapshot().session.running).toBe(false); + }); +}); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 218b4b8..aef0d75 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -2187,6 +2187,7 @@ describe("download table row contracts", () => { it("keeps the complete package status and audio details in the tooltip", () => { const audioPackage = { ...pkg("audio-package", "Audio package", ["audio-item"]), + status: "extracting" as const, postProcessLabel: "Entpacken 1%", audioStripSummary: { at: now, @@ -2231,6 +2232,62 @@ describe("download table row contracts", () => { expect(html).toContain('Mega-Debrid API: Kein Server verfügbar'); }); + it("shows active extraction progress while retaining sibling extraction errors in the tooltip", () => { + const activePackage = { + ...pkg("active-extraction-package", "Active extraction", ["failed-archive", "active-archive"]), + status: "extracting" as const, + postProcessLabel: "Entpacken 42% (1/1) · active.part01.rar" + }; + const failedArchive = item("failed-archive", activePackage.id, "completed", { + fullStatus: "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv" + }); + const activeArchive = item("active-archive", activePackage.id, "completed", { + fullStatus: "Entpacken 42% · active.part01.rar" + }); + const html = renderToStaticMarkup(PackageCardContent({ + actions: createActions(), + columnOrder: ["status"], + editing: false, + editingName: "", + gridTemplate: "220px", + packageSpeedBps: 0, + row: { package: activePackage, items: [failedArchive, activeArchive], allItems: [failedArchive, activeArchive], collapsed: true }, + selectedIds: new Set(), + selectedVersion: 0 + })); + + expect(html.match(/>Entpacken - 42%<\/span>/g)).toHaveLength(2); + expect(html).toContain("1 Entpackfehler"); + expect(html).toContain("Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"); + }); + + it("does not present a persisted extraction label as active progress after restart", () => { + const restoredItem = item("restored-archive", "restored-package", "completed", { + fullStatus: "Entpacken - Ausstehend" + }); + const restoredPackage = { + ...pkg("restored-package", "Restored extraction", [restoredItem.id]), + status: "queued" as const, + postProcessLabel: "Entpacken 100% (1/1) · release.part01.rar" + }; + const html = renderToStaticMarkup(PackageCardContent({ + actions: createActions(), + columnOrder: ["status"], + editing: false, + editingName: "", + gridTemplate: "220px", + packageSpeedBps: 0, + row: { package: restoredPackage, items: [restoredItem], allItems: [restoredItem], collapsed: true }, + selectedIds: new Set(), + selectedVersion: 0 + })); + + expect(html.match(/>Entpacken - Ausstehend<\/span>/g)).toHaveLength(2); + expect(html).not.toContain(">Entpacken - 100%"); + expect(html).not.toContain("Entpacken 100%"); + expect(html).not.toContain("release.part01.rar"); + }); + it("removes redundant service suffixes from runtime statuses", () => { expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte..."); expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten"); diff --git a/tests/extraction-ipc-contract.test.ts b/tests/extraction-ipc-contract.test.ts new file mode 100644 index 0000000..877e81c --- /dev/null +++ b/tests/extraction-ipc-contract.test.ts @@ -0,0 +1,161 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IpcMainInvokeEvent } from "electron"; +import { IPC_CHANNELS } from "../src/shared/ipc"; +import type { ElectronApi } from "../src/shared/preload-api"; + +const electron = vi.hoisted(() => ({ + api: undefined as ElectronApi | undefined, + ipcHandlers: new Map unknown>(), + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), + appHandlers: new Map void>(), + app: { + isPackaged: false, + getPath: vi.fn(() => "C:\\MDD\\Test"), + getAppPath: vi.fn(() => "C:\\MDD\\App"), + requestSingleInstanceLock: vi.fn(() => true), + on: vi.fn((name: string, handler: (...args: unknown[]) => void) => { + electron.appHandlers.set(name, handler); + }), + whenReady: vi.fn(() => new Promise(() => {})), + quit: vi.fn(), + exit: vi.fn(), + setPath: vi.fn() + } +})); + +vi.mock("electron", () => ({ + app: electron.app, + BrowserWindow: class { + public static getAllWindows(): unknown[] { return []; } + }, + clipboard: { readText: vi.fn(() => ""), writeText: vi.fn() }, + contextBridge: { + exposeInMainWorld: (_name: string, api: ElectronApi) => { + electron.api = api; + } + }, + dialog: {}, + ipcMain: { + handle: vi.fn((channel: string, handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown) => { + electron.ipcHandlers.set(channel, handler); + }), + on: vi.fn() + }, + ipcRenderer: { + invoke: electron.invoke, + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn() + }, + Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() }, + nativeTheme: { themeSource: "system" }, + powerMonitor: { on: vi.fn(), removeListener: vi.fn() }, + safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() }, + shell: {}, + Tray: class {} +})); + +import { AppController } from "../src/main/app-controller"; +import { registerExtractionIpcHandlers } from "../src/main/extraction-ipc"; + +function trustedEvent(): IpcMainInvokeEvent { + return {} as IpcMainInvokeEvent; +} + +function registerHandlers(target: Parameters[1]): void { + registerExtractionIpcHandlers((channel, handler) => { + electron.ipcHandlers.set(channel, handler); + }, target); +} + +function createController(manager: { + retryExtraction: (packageId: string) => Promise; + extractNow: (request: { packageIds: string[]; itemIds: string[] }) => Promise; +}): AppController { + const controller = Object.create(AppController.prototype) as { + manager: typeof manager; + audit: ReturnType; + }; + controller.manager = manager; + controller.audit = vi.fn(); + return controller as unknown as AppController; +} + +describe("manual extraction error propagation", () => { + beforeAll(async () => { + await import("../src/preload/preload"); + }); + + beforeEach(() => { + electron.ipcHandlers.clear(); + electron.invoke.mockReset(); + }); + + it.each([ + ["stale", "Paket existiert nicht mehr"], + ["deleted", "Ausgewählte Datei wurde gelöscht"], + ["non-extractable", "Kein vollständiger entpackbarer Archivsatz ausgewählt"] + ])("keeps a %s manager rejection intact through AppController", async (_caseName, message) => { + const controller = createController({ + retryExtraction: vi.fn(async () => { throw new Error(message); }), + extractNow: vi.fn(async () => { throw new Error(message); }) + }); + + await expect(controller.retryExtraction("package-id")).rejects.toThrow(message); + await expect(controller.extractNow({ packageIds: [], itemIds: ["item-id"] })).rejects.toThrow(message); + }); + + it("rejects an empty extract-now request at the trusted main-process IPC boundary", async () => { + const controller = { + retryExtraction: vi.fn(async () => undefined), + extractNow: vi.fn(async () => undefined) + }; + registerHandlers(controller); + const handler = electron.ipcHandlers.get(IPC_CHANNELS.EXTRACT_NOW); + + await expect(Promise.resolve().then(() => handler?.(trustedEvent(), { packageIds: [], itemIds: [] }))) + .rejects.toThrow("extractNow benötigt mindestens ein Ziel"); + expect(controller.extractNow).not.toHaveBeenCalled(); + }); + + it.each([ + ["", "packageId muss ein nicht-leerer String sein"], + [" ", "packageId muss ein nicht-leerer String sein"], + ["p".repeat(257), "packageId darf höchstens 256 Zeichen lang sein"] + ])("rejects an invalid retry package ID before calling the controller", async (packageId, message) => { + const controller = { + retryExtraction: vi.fn(async () => undefined), + extractNow: vi.fn(async () => undefined) + }; + registerHandlers(controller); + const handler = electron.ipcHandlers.get(IPC_CHANNELS.RETRY_EXTRACTION); + + await expect(Promise.resolve().then(() => handler?.(trustedEvent(), packageId))).rejects.toThrow(message); + expect(controller.retryExtraction).not.toHaveBeenCalled(); + }); + + it.each([ + [IPC_CHANNELS.RETRY_EXTRACTION, "Paket existiert nicht mehr", ["stale-package"]], + [IPC_CHANNELS.EXTRACT_NOW, "Ausgewählte Datei wurde gelöscht", [{ packageIds: [], itemIds: ["deleted-item"] }]], + [IPC_CHANNELS.EXTRACT_NOW, "Kein vollständiger entpackbarer Archivsatz ausgewählt", [{ packageIds: [], itemIds: ["plain-file"] }]] + ])("returns the controller rejection from %s to ipcRenderer.invoke", async (channel, message, args) => { + const controller = { + retryExtraction: vi.fn(async () => { throw new Error(message); }), + extractNow: vi.fn(async () => { throw new Error(message); }) + }; + registerHandlers(controller); + const handler = electron.ipcHandlers.get(channel); + + await expect(Promise.resolve(handler?.(trustedEvent(), ...args))).rejects.toThrow(message); + }); + + it("exposes main-process extraction rejections unchanged to the renderer API", async () => { + electron.invoke + .mockRejectedValueOnce(new Error("Paket existiert nicht mehr")) + .mockRejectedValueOnce(new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt")); + + await expect(electron.api?.retryExtraction("stale-package")).rejects.toThrow("Paket existiert nicht mehr"); + await expect(electron.api?.extractNow({ packageIds: [], itemIds: ["plain-file"] })) + .rejects.toThrow("Kein vollständiger entpackbarer Archivsatz ausgewählt"); + }); +}); diff --git a/tests/extractor-jvm-protocol.test.ts b/tests/extractor-jvm-protocol.test.ts new file mode 100644 index 0000000..58b108b --- /dev/null +++ b/tests/extractor-jvm-protocol.test.ts @@ -0,0 +1,206 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { extractPackageArchives, shutdownDaemon, type ExtractArchiveFailureInfo } from "../src/main/extractor"; + +const hasJdk = spawnSync("javac", ["-version"], { stdio: "ignore" }).status === 0; +const originalBackend = process.env.RD_EXTRACT_BACKEND; +const originalJava = process.env.RD_JAVA_BIN; +const originalJvmRoot = process.env.RD_EXTRACTOR_JVM_DIR; +const originalFakeMode = process.env.RD_FAKE_JVM_MODE; +const originalFakeSecret = process.env.RD_FAKE_JVM_SECRET; +const tempDirs: string[] = []; +let runtimeRoot = ""; + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function createArchiveFixture(prefix: string): { packageDir: string; targetDir: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c0004", "hex")); + return { packageDir, targetDir }; +} + +function extractionOptions(fixture: ReturnType) { + return { + ...fixture, + cleanupMode: "none" as const, + conflictMode: "skip" as const, + removeLinks: false, + removeSamples: false, + passwordList: "candidate-one\ncandidate-two" + }; +} + +describe.skipIf(!hasJdk).sequential("JVM protocol integration", () => { + beforeAll(() => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-protocol-runtime-")); + tempDirs.push(root); + runtimeRoot = path.join(root, "extractor-jvm"); + const sourceDir = path.join(root, "source", "com", "sucukdeluxe", "extractor"); + const classesDir = path.join(runtimeRoot, "classes"); + const libDir = path.join(runtimeRoot, "lib"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.mkdirSync(classesDir, { recursive: true }); + fs.mkdirSync(libDir, { recursive: true }); + const javaSource = `package com.sucukdeluxe.extractor; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +public final class JBindExtractorMain { + public static void main(String[] args) throws Exception { + String mode = System.getenv("RD_FAKE_JVM_MODE"); + String secret = System.getenv("RD_FAKE_JVM_SECRET"); + boolean daemon = args.length == 1 && "--daemon".equals(args[0]); + if (daemon && mode.startsWith("oneshot")) return; + if (daemon) { + System.out.println("RD_DAEMON_READY"); + System.out.flush(); + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + while (reader.readLine() != null) { + System.out.println("RD_PASSWORD_ATTEMPT 1 4"); + System.out.println("RD_BACKEND fake-daemon"); + if ("daemon-success".equals(mode)) { + System.out.println("RD_DONE"); + System.out.println("RD_REQUEST_DONE 0"); + System.out.flush(); + continue; + } + System.out.print("RD_PASS"); + System.out.flush(); + Thread.sleep(25L); + System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8))); + System.out.flush(); + return; + } + return; + } + System.out.println("RD_PASSWORD_ATTEMPT 1 4"); + System.out.println("RD_BACKEND fake-oneshot"); + if ("oneshot-success".equals(mode)) { + System.out.println("RD_DONE"); + return; + } + System.out.print("RD_PASS"); + System.out.flush(); + Thread.sleep(25L); + System.out.print("WORD " + Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8))); + System.out.flush(); + System.exit(1); + } +}`; + const sourcePath = path.join(sourceDir, "JBindExtractorMain.java"); + fs.writeFileSync(sourcePath, javaSource, "utf8"); + const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-d", classesDir, sourcePath], { encoding: "utf8" }); + expect(compiled.status, `${compiled.stdout}\n${compiled.stderr}`).toBe(0); + const sourceLibDir = path.join(process.cwd(), "resources", "extractor-jvm", "lib"); + for (const name of ["sevenzipjbinding.jar", "sevenzipjbinding-all-platforms.jar", "zip4j.jar"]) { + fs.copyFileSync(path.join(sourceLibDir, name), path.join(libDir, name)); + } + process.env.RD_EXTRACT_BACKEND = "jvm"; + process.env.RD_JAVA_BIN = "java"; + process.env.RD_EXTRACTOR_JVM_DIR = runtimeRoot; + }, 20_000); + + afterEach(() => { + shutdownDaemon(); + delete process.env.RD_FAKE_JVM_MODE; + delete process.env.RD_FAKE_JVM_SECRET; + }); + + afterAll(() => { + shutdownDaemon(); + for (const directory of tempDirs.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } + restoreEnv("RD_EXTRACT_BACKEND", originalBackend); + restoreEnv("RD_JAVA_BIN", originalJava); + restoreEnv("RD_EXTRACTOR_JVM_DIR", originalJvmRoot); + restoreEnv("RD_FAKE_JVM_MODE", originalFakeMode); + restoreEnv("RD_FAKE_JVM_SECRET", originalFakeSecret); + }); + + it("transports daemon attempts and isolates throwing log observers", async () => { + process.env.RD_FAKE_JVM_MODE = "daemon-success"; + process.env.RD_FAKE_JVM_SECRET = "unused-daemon-secret"; + const firstFixture = createArchiveFixture("rd-jvm-daemon-callback-"); + const first = await extractPackageArchives({ + ...extractionOptions(firstFixture), + onLog: (_level, message) => { + if (message.startsWith("Passwort-Versuch ")) { + throw new Error("ui callback failed"); + } + } + }); + + expect(first).toEqual(expect.objectContaining({ extracted: 1, failed: 0, lastError: "" })); + + const secondFixture = createArchiveFixture("rd-jvm-daemon-recovery-"); + const logs: string[] = []; + const second = await extractPackageArchives({ + ...extractionOptions(secondFixture), + onLog: (_level, message) => logs.push(message) + }); + + expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 })); + expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true); + }, 20_000); + + it("redacts a one-shot password payload when the JVM exits before an error event", async () => { + const secret = "one-shot-runtime-secret"; + const encodedSecret = Buffer.from(secret, "utf8").toString("base64"); + process.env.RD_FAKE_JVM_MODE = "oneshot-crash"; + process.env.RD_FAKE_JVM_SECRET = secret; + const fixture = createArchiveFixture("rd-jvm-oneshot-redaction-"); + const failures: ExtractArchiveFailureInfo[] = []; + const logs: string[] = []; + + const result = await extractPackageArchives({ + ...extractionOptions(fixture), + onArchiveFailure: (failure) => failures.push(failure), + onLog: (_level, message) => logs.push(message) + }); + + const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n"); + expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); + expect(diagnosticText).toContain("RD_PASSWORD "); + expect(diagnosticText).not.toContain(secret); + expect(diagnosticText).not.toContain(encodedSecret); + expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true); + }, 20_000); + + it("redacts a daemon password payload when the JVM exits before request completion", async () => { + const secret = "daemon-runtime-secret"; + const encodedSecret = Buffer.from(secret, "utf8").toString("base64"); + process.env.RD_FAKE_JVM_MODE = "daemon-crash"; + process.env.RD_FAKE_JVM_SECRET = secret; + const fixture = createArchiveFixture("rd-jvm-daemon-redaction-"); + const failures: ExtractArchiveFailureInfo[] = []; + const logs: string[] = []; + + const result = await extractPackageArchives({ + ...extractionOptions(fixture), + onArchiveFailure: (failure) => failures.push(failure), + onLog: (_level, message) => logs.push(message) + }); + + const diagnosticText = [result.lastError, ...failures.map((failure) => `${failure.errorText}\n${failure.jvmFailureReason || ""}`), ...logs].join("\n"); + expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); + expect(diagnosticText).toContain("RD_PASSWORD "); + expect(diagnosticText).not.toContain(secret); + expect(diagnosticText).not.toContain(encodedSecret); + expect(logs.some((message) => message.startsWith("Passwort-Versuch 1/4:"))).toBe(true); + }, 20_000); +}); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 81bd76e..999de34 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -1,15 +1,21 @@ import fs from "node:fs"; +import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it } from "vitest"; -import { extractPackageArchives } from "../src/main/extractor"; +import { extractPackageArchives, shutdownDaemon } from "../src/main/extractor"; const tempDirs: string[] = []; const originalBackend = process.env.RD_EXTRACT_BACKEND; +const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS; const require = createRequire(import.meta.url); +const rarCliPath = [ + "C:\\Program Files\\WinRAR\\Rar.exe", + "C:\\Program Files (x86)\\WinRAR\\Rar.exe" +].find((candidate) => fs.existsSync(candidate)) || ""; type ZipFixtureEntry = { name: string; directory?: boolean; content?: string }; @@ -91,7 +97,85 @@ function hasJvmExtractorRuntime(): boolean { path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"), path.join(root, "lib", "zip4j.jar") ]; - return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); + return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); +} + +function hasCommand(command: string, args: string[]): boolean { + return spawnSync(command, args, { stdio: "ignore" }).status === 0; +} + +function compileJvmExtractorSource(root: string): string { + const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm"); + const classesDir = path.join(root, "classes"); + const libs = [ + path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"), + path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"), + path.join(runtimeRoot, "lib", "zip4j.jar") + ]; + fs.mkdirSync(classesDir, { recursive: true }); + const result = spawnSync("javac", [ + "-source", "8", + "-target", "8", + "-encoding", "UTF-8", + "-cp", libs.join(path.delimiter), + "-d", classesDir, + path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java") + ], { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(String(result.stderr || result.stdout || "javac failed")); + } + return [classesDir, ...libs].join(path.delimiter); +} + +function findZipCryptoVerifierCollision(root: string, archivePath: string): string { + const sourcePath = path.join(root, "ZipCryptoCollisionFinder.java"); + const classesDir = path.join(root, "collision-finder-classes"); + const zip4jPath = path.join(process.cwd(), "resources", "extractor-jvm", "lib", "zip4j.jar"); + fs.mkdirSync(classesDir, { recursive: true }); + fs.writeFileSync(sourcePath, `import java.io.InputStream; +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.model.FileHeader; +public final class ZipCryptoCollisionFinder { + public static void main(String[] args) throws Exception { + for (int index = 0; index < 8192; index++) { + String candidate = "collision-candidate-" + index; + ZipFile zipFile = new ZipFile(args[0]); + zipFile.setPassword(candidate.toCharArray()); + int produced = 0; + try { + FileHeader header = zipFile.getFileHeaders().get(0); + InputStream input = zipFile.getInputStream(header); + try { + byte[] buffer = new byte[8192]; + while (true) { + int read = input.read(buffer); + if (read < 0) break; + produced += read; + } + } finally { + input.close(); + } + } catch (Exception error) { + if (produced > 0) { + System.out.println(candidate); + return; + } + } finally { + zipFile.close(); + } + } + System.exit(2); + } +}`, "utf8"); + const compiled = spawnSync("javac", ["-source", "8", "-target", "8", "-encoding", "UTF-8", "-cp", zip4jPath, "-d", classesDir, sourcePath], { encoding: "utf8" }); + if (compiled.status !== 0) { + throw new Error(String(compiled.stderr || compiled.stdout || "collision finder compile failed")); + } + const run = spawnSync("java", ["-cp", [classesDir, zip4jPath].join(path.delimiter), "ZipCryptoCollisionFinder", archivePath], { encoding: "utf8", timeout: 20_000 }); + if (run.status !== 0) { + throw new Error(String(run.stderr || run.stdout || "collision finder failed")); + } + return String(run.stdout || "").trim(); } function corruptFirstZipPayload(zipPath: string): void { @@ -111,19 +195,687 @@ function corruptFirstZipPayload(zipPath: string): void { fs.writeFileSync(zipPath, bytes); } -afterEach(() => { +afterEach(() => { + shutdownDaemon(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } if (originalBackend === undefined) { delete process.env.RD_EXTRACT_BACKEND; - } else { - process.env.RD_EXTRACT_BACKEND = originalBackend; - } -}); - -describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => { - it("extracts zip archives through SevenZipJBinding backend", async () => { + } else { + process.env.RD_EXTRACT_BACKEND = originalBackend; + } + if (originalArchivePasswords === undefined) { + delete process.env.RD_ARCHIVE_PASSWORDS; + } else { + process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords; + } +}); + +describe("JVM extractor build pipeline", () => { + it("compiles the JVM runtime before main and release builds", () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8")); + + expect(packageJson.scripts["build:extractor-jvm"]).toBe("node scripts/build-extractor-jvm.mjs"); + expect(packageJson.scripts["build:main"]).toMatch(/^npm run build:extractor-jvm && /); + expect(packageJson.scripts.build).toContain("npm run build:main"); + expect(packageJson.scripts["release:win"]).toContain("npm run build"); + }); + + it("keeps packaged JVM classes current with Java 8 bytecode and the password attempt protocol", () => { + const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs"); + const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm"); + + const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" }); + + expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0); + const mainClass = fs.readFileSync(path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class")); + expect(mainClass.readUInt16BE(6)).toBe(52); + expect(mainClass.includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true); + expect(mainClass.includes(Buffer.from("RD_PASSWORD ", "utf8"))).toBe(false); + }); + + it.skipIf(!hasCommand("javac", ["-version"]))("builds current source and rejects stale classes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-build-")); + tempDirs.push(root); + const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm"); + const runtimeRoot = path.join(root, "extractor-jvm"); + fs.mkdirSync(runtimeRoot, { recursive: true }); + fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true }); + fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true }); + const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs"); + + const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8" }); + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0); + const mainClass = path.join(runtimeRoot, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"); + expect(fs.readFileSync(mainClass).includes(Buffer.from("RD_PASSWORD_ATTEMPT", "utf8"))).toBe(true); + + const current = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" }); + expect(current.status, `${current.stdout}\n${current.stderr}`).toBe(0); + + const javaSource = path.join(runtimeRoot, "src", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.java"); + fs.appendFileSync(javaSource, "\n", "utf8"); + const stale = spawnSync(process.execPath, [buildScript, "--check", "--runtime-root", runtimeRoot], { encoding: "utf8" }); + expect(stale.status).toBe(1); + expect(`${stale.stdout}\n${stale.stderr}`).toMatch(/veraltet|stale/i); + }, 30_000); + + it("fails clearly when no JDK compiler is available", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-no-jdk-")); + tempDirs.push(root); + const sourceRuntime = path.join(process.cwd(), "resources", "extractor-jvm"); + const runtimeRoot = path.join(root, "extractor-jvm"); + fs.mkdirSync(runtimeRoot, { recursive: true }); + fs.cpSync(path.join(sourceRuntime, "src"), path.join(runtimeRoot, "src"), { recursive: true }); + fs.cpSync(path.join(sourceRuntime, "lib"), path.join(runtimeRoot, "lib"), { recursive: true }); + const buildScript = path.join(process.cwd(), "scripts", "build-extractor-jvm.mjs"); + const env = { ...process.env, JAVA_HOME: "", PATH: "" }; + + const build = spawnSync(process.execPath, [buildScript, "--runtime-root", runtimeRoot], { encoding: "utf8", env }); + + expect(build.status).toBe(1); + expect(`${build.stdout}\n${build.stderr}`).toMatch(/JDK.*javac|javac.*JDK/i); + }); +}); + +describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => { + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("emits password attempt indices without exposing candidate values", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-password-attempt-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.txt"); + const archivePath = path.join(root, "protected.zip"); + const targetDir = path.join(root, "out"); + const actualPassword = "actual-secret-value"; + const wrongPassword = "wrong-secret-value"; + fs.writeFileSync(inputPath, "protected payload", "utf8"); + const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "zip4j", + "--password", + wrongPassword, + "--password", + actualPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + const attemptLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT ")); + expect(attemptLines).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3", + "RD_PASSWORD_ATTEMPT 3 3" + ]); + expect(attemptLines.every((line) => !line.includes(actualPassword) && !line.includes(wrongPassword))).toBe(true); + expect(String(run.stdout)).not.toContain("RD_PASSWORD "); + expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("protected payload"); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("keeps encrypted Zip4j corruption distinct from a wrong password", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-encrypted-crc-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "protected-corrupt.zip"); + const targetDir = path.join(root, "out"); + const actualPassword = "zip4j-corrupt-secret"; + const sentinelPassword = "must-not-run-after-zip-crc"; + fs.writeFileSync(inputPath, randomBytes(256 * 1024)); + const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + corruptFirstZipPayload(archivePath); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "zip4j", + "--password", + actualPassword, + "--password", + sentinelPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3" + ]); + expect(String(run.stderr)).toMatch(/CRC|checksum/i); + expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort"); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("validates encrypted Zip4j entries before extracting plain entries", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zip4j-mixed-password-")); + tempDirs.push(root); + const plainInput = path.join(root, "a-plain.txt"); + const secretInput = path.join(root, "b-secret.txt"); + const archivePath = path.join(root, "mixed.zip"); + const targetDir = path.join(root, "out"); + const actualPassword = "zip4j-mixed-secret"; + fs.writeFileSync(plainInput, "plain payload", "utf8"); + fs.writeFileSync(secretInput, "secret payload", "utf8"); + expect(spawnSync("7z", ["a", "-tzip", archivePath, plainInput], { encoding: "utf8" }).status).toBe(0); + expect(spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, secretInput], { encoding: "utf8" }).status).toBe(0); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "zip4j", + "--password", + actualPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 2", + "RD_PASSWORD_ATTEMPT 2 2" + ]); + expect(fs.readFileSync(path.join(targetDir, "a-plain.txt"), "utf8")).toBe("plain payload"); + expect(fs.readFileSync(path.join(targetDir, "b-secret.txt"), "utf8")).toBe("secret payload"); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("continues after a ZipCrypto verifier collision reaches CRC", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-zipcrypto-collision-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "collision.zip"); + const targetDir = path.join(root, "out"); + const actualPassword = "actual-password"; + const payload = randomBytes(4096); + fs.writeFileSync(inputPath, payload); + const created = spawnSync("7z", ["a", "-tzip", "-mx=0", "-mem=ZipCrypto", `-p${actualPassword}`, archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const collisionPassword = findZipCryptoVerifierCollision(root, archivePath); + expect(collisionPassword).toMatch(/^collision-candidate-\d+$/); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "zip4j", + "--password", + collisionPassword, + "--password", + actualPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3", + "RD_PASSWORD_ATTEMPT 3 3" + ]); + expect(fs.readFileSync(path.join(targetDir, "payload.bin"))).toEqual(payload); + + const failedTargetDir = path.join(root, "failed-out"); + const failedRun = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + failedTargetDir, + "--conflict", + "overwrite", + "--backend", + "zip4j", + "--password", + collisionPassword, + "--password", + "wrong-after-collision" + ], { encoding: "utf8" }); + expect(failedRun.status).not.toBe(0); + expect(String(failedRun.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3", + "RD_PASSWORD_ATTEMPT 3 3" + ]); + expect(String(failedRun.stderr)).toContain("zip4j-Fehler: CRCERROR"); + expect(String(failedRun.stderr)).not.toContain("Falsches Archiv-Passwort"); + }, 30_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("parses daemon password candidates with JSON metacharacters and Unicode", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-daemon-json-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.txt"); + const archivePath = path.join(root, "protected.zip"); + const targetDir = path.join(root, "out"); + const actualPassword = "actual-daemon-secret"; + const bracketPassword = "wrong]candidate"; + const escapedPassword = "päss\\\"\\漢字ß"; + fs.writeFileSync(inputPath, "daemon protected payload", "utf8"); + const created = spawnSync("7z", ["a", "-tzip", `-p${actualPassword}`, "-mem=AES256", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const classPath = compileJvmExtractorSource(root); + const request = JSON.stringify({ + archive: archivePath, + target: targetDir, + conflict: "overwrite", + backend: "zip4j", + passwords: [bracketPassword, escapedPassword, actualPassword] + }); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--daemon" + ], { encoding: "utf8", input: `${request}\n` }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(String(run.stdout)).toContain("RD_REQUEST_DONE 0"); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 4", + "RD_PASSWORD_ATTEMPT 2 4", + "RD_PASSWORD_ATTEMPT 3 4", + "RD_PASSWORD_ATTEMPT 4 4" + ]); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(bracketPassword); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(escapedPassword); + expect(fs.readFileSync(path.join(targetDir, "payload.txt"), "utf8")).toBe("daemon protected payload"); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("tries the real RAR5 password after unreliable encrypted metadata", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-password-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "protected.rar"); + const targetDir = path.join(root, "out"); + const actualPassword = "rar5-actual-secret"; + const wrongPassword = "rar5-wrong-secret"; + const payload = randomBytes(192 * 1024); + fs.writeFileSync(inputPath, payload); + const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const firstPart = fs.readdirSync(root).find((name) => /^protected\.part0*1\.rar$/i.test(name)); + expect(firstPart).toBeTruthy(); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + path.join(root, firstPart!), + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + wrongPassword, + "--password", + actualPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3", + "RD_PASSWORD_ATTEMPT 3 3" + ]); + const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin")); + expect(extractedPayload?.bytes).toBe(payload.toString("base64")); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("continues after an explicit RAR5 WRONG_PASSWORD result", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-data-password-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "protected-data.rar"); + const targetDir = path.join(root, "out"); + const actualPassword = "rar5-data-actual"; + const wrongPassword = "rar5-data-wrong"; + const payload = randomBytes(192 * 1024); + fs.writeFileSync(inputPath, payload); + const created = spawnSync(rarCliPath, ["a", "-ma5", `-p${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const firstPart = fs.readdirSync(root).find((name) => /^protected-data\.part0*1\.rar$/i.test(name)); + expect(firstPart).toBeTruthy(); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + path.join(root, firstPart!), + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + wrongPassword, + "--password", + actualPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3", + "RD_PASSWORD_ATTEMPT 3 3" + ]); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(actualPassword); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(wrongPassword); + const extractedPayload = readTargetTree(targetDir).find((entry) => entry.type === "file" && entry.path.endsWith("payload.bin")); + expect(extractedPayload?.bytes).toBe(payload.toString("base64")); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps encrypted RAR5 corruption distinct from an exhausted password list", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-encrypted-crc-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "encrypted-corrupt.rar"); + const targetDir = path.join(root, "out"); + const actualPassword = "rar5-corrupt-secret"; + const sentinelPassword = "must-not-be-attempted-after-crc"; + fs.writeFileSync(inputPath, randomBytes(256 * 1024)); + const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const parts = fs.readdirSync(root).filter((name) => /^encrypted-corrupt\.part\d+\.rar$/i.test(name)).sort(); + expect(parts.length).toBeGreaterThanOrEqual(3); + const corruptPath = path.join(root, parts[2]); + const bytes = fs.readFileSync(corruptPath); + bytes[Math.floor(bytes.length / 2)] ^= 0xff; + fs.writeFileSync(corruptPath, bytes); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + path.join(root, parts[0]), + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + actualPassword, + "--password", + sentinelPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3" + ]); + expect(String(run.stderr)).toMatch(/RD_ERROR 7z-Fehler: (?:CRCERROR|DATAERROR)/); + expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort"); + expect(String(run.stderr).toLowerCase()).not.toContain("wrong_password"); + expect(String(run.stderr).toLowerCase()).not.toContain("wrong password"); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(sentinelPassword); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an unencrypted RAR5 CRC failure terminal", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-crc-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "corrupt.rar"); + const targetDir = path.join(root, "out"); + fs.writeFileSync(inputPath, randomBytes(192 * 1024)); + const created = spawnSync(rarCliPath, ["a", "-ma5", "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const parts = fs.readdirSync(root).filter((name) => /^corrupt\.part\d+\.rar$/i.test(name)).sort(); + expect(parts.length).toBeGreaterThanOrEqual(3); + const corruptPath = path.join(root, parts[2]); + const bytes = fs.readFileSync(corruptPath); + bytes[Math.floor(bytes.length / 2)] ^= 0xff; + fs.writeFileSync(corruptPath, bytes); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + path.join(root, parts[0]), + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + "unused-one", + "--password", + "unused-two" + ], { encoding: "utf8" }); + + expect(run.status).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3" + ]); + expect(String(run.stderr)).toContain("CRCERROR"); + }, 20_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted missing RAR5 volume distinct from a wrong password", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-missing-volume-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "missing-volume.rar"); + const targetDir = path.join(root, "out"); + const actualPassword = "rar5-missing-volume-secret"; + const sentinelPassword = "must-not-run-after-missing-volume"; + fs.writeFileSync(inputPath, randomBytes(256 * 1024)); + const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const parts = fs.readdirSync(root).filter((name) => /^missing-volume\.part\d+\.rar$/i.test(name)).sort(); + expect(parts.length).toBeGreaterThanOrEqual(3); + fs.unlinkSync(path.join(root, parts[1])); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + path.join(root, parts[0]), + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + actualPassword, + "--password", + sentinelPassword + ], { encoding: "utf8" }); + + expect(run.status).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3" + ]); + expect(String(run.stdout)).not.toContain("RD_OUTPUT "); + expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort"); + expect(String(run.stderr)).toMatch(/Missing volume|Volume fehlt/i); + }, 20_000); + + it.skipIf(process.platform !== "win32" || !hasCommand("javac", ["-version"]) || !rarCliPath)("keeps an encrypted locked RAR5 volume distinct from a wrong password", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-rar5-locked-volume-")); + tempDirs.push(root); + const inputDir = path.join(root, "inputs"); + const archivePath = path.join(root, "locked.rar"); + const targetDir = path.join(root, "out"); + const actualPassword = "rar5-locked-volume-secret"; + const sentinelPassword = "must-not-run-after-volume-io"; + fs.mkdirSync(inputDir, { recursive: true }); + const inputPaths: string[] = []; + for (let index = 0; index < 12; index += 1) { + const inputPath = path.join(inputDir, `payload-${index.toString().padStart(2, "0")}.bin`); + fs.writeFileSync(inputPath, randomBytes(48 * 1024)); + inputPaths.push(inputPath); + } + const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${actualPassword}`, "-v64k", "-idq", archivePath, ...inputPaths], { encoding: "utf8" }); + expect(created.status).toBe(0); + const parts = fs.readdirSync(root).filter((name) => /^locked\.part\d+\.rar$/i.test(name)).sort(); + expect(parts.length).toBeGreaterThanOrEqual(4); + const classPath = compileJvmExtractorSource(root); + const script = `$lock = [IO.File]::Open($env:LOCK_PATH, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) +try { + & $env:JAVA_BIN '-cp' $env:CLASS_PATH 'com.sucukdeluxe.extractor.JBindExtractorMain' '--archive' $env:ARCHIVE_PATH '--target' $env:TARGET_PATH '--conflict' 'overwrite' '--backend' '7zjbinding' '--password' $env:ACTUAL_PASSWORD '--password' $env:SENTINEL_PASSWORD + exit $LASTEXITCODE +} finally { + $lock.Dispose() +}`; + + const run = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + env: { + ...process.env, + LOCK_PATH: path.join(root, parts[3]), + JAVA_BIN: "java", + CLASS_PATH: classPath, + ARCHIVE_PATH: path.join(root, parts[0]), + TARGET_PATH: targetDir, + ACTUAL_PASSWORD: actualPassword, + SENTINEL_PASSWORD: sentinelPassword + } + }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3", + "RD_PASSWORD_ATTEMPT 2 3" + ]); + expect(String(run.stderr)).toContain("Volume konnte nicht geoffnet"); + expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort"); + }, 30_000); + + it.skipIf(!hasCommand("javac", ["-version"]) || !hasCommand("7z", ["i"]))("does not convert an encrypted 7z open failure into a wrong password", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-7z-open-failure-")); + tempDirs.push(root); + const inputPath = path.join(root, "payload.bin"); + const archivePath = path.join(root, "truncated.7z"); + const targetDir = path.join(root, "out"); + const actualPassword = "sevenzip-open-secret"; + const sentinelPassword = "must-not-run-after-open-failure"; + fs.writeFileSync(inputPath, randomBytes(256 * 1024)); + const created = spawnSync("7z", ["a", "-t7z", `-p${actualPassword}`, "-mhe=on", archivePath, inputPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const archiveSize = fs.statSync(archivePath).size; + expect(archiveSize).toBeGreaterThan(256); + fs.truncateSync(archivePath, archiveSize - 128); + const classPath = compileJvmExtractorSource(root); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + archivePath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + "7zjbinding", + "--password", + actualPassword, + "--password", + sentinelPassword + ], { encoding: "utf8" }); + + expect(run.status, `${run.stdout}\n${run.stderr}`).not.toBe(0); + expect(String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_PASSWORD_ATTEMPT "))).toEqual([ + "RD_PASSWORD_ATTEMPT 1 3" + ]); + expect(String(run.stderr)).not.toContain("Falsches Archiv-Passwort"); + }, 20_000); + + it.skipIf(!hasCommand("7z", ["i"]))("routes the emitted German archive-password failure through fallback and cache invalidation", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + process.env.RD_ARCHIVE_PASSWORDS = ""; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-german-password-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const firstInput = path.join(root, "first.txt"); + const secondInput = path.join(root, "second.txt"); + const learnedPassword = "learned-package-secret"; + const unavailablePassword = "unavailable-archive-secret"; + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(firstInput, "first payload", "utf8"); + fs.writeFileSync(secondInput, "second payload", "utf8"); + expect(spawnSync("7z", ["a", "-tzip", `-p${learnedPassword}`, "-mem=AES256", path.join(packageDir, "a-first.zip"), firstInput]).status).toBe(0); + expect(spawnSync("7z", ["a", "-tzip", `-p${unavailablePassword}`, "-mem=AES256", path.join(packageDir, "b-second.zip"), secondInput]).status).toBe(0); + const failures: import("../src/main/extractor").ExtractArchiveFailureInfo[] = []; + const logs: string[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + passwordList: learnedPassword, + onArchiveFailure: (failure) => failures.push(failure), + onLog: (_level, message) => logs.push(message) + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 })); + expect(failures).toHaveLength(1); + expect(failures[0]).toEqual(expect.objectContaining({ + archiveName: "b-second.zip", + category: "wrong_password", + suggestRedownload: false + })); + expect(String(failures[0]?.jvmFailureReason || "")).toContain("Falsches Archiv-Passwort"); + expect(logs.some((message) => message.includes("JVM-Extractor Fallback-Analyse:") && message.includes("wrongPassword=true"))).toBe(true); + expect(logs.some((message) => message.startsWith("Legacy-Extractor Start: archive=b-second.zip"))).toBe(true); + expect(logs.some((message) => message.includes("Passwort-Cache Update"))).toBe(true); + expect(logs.some((message) => message.includes("Passwort-Cache verworfen"))).toBe(true); + }, 30_000); + + it("extracts zip archives through SevenZipJBinding backend", async () => { process.env.RD_EXTRACT_BACKEND = "jvm"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-")); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3335377..0cd89cf 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -1,4 +1,6 @@ import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; @@ -19,6 +21,10 @@ import { shouldSerialRetryParallelFailures, findArchiveCandidates, orderExtractorCandidatesForArchive, + extractorCommandsShareIdentity, + parseJvmPasswordAttemptLine, + redactJvmDiagnosticLine, + summarizeJvmPasswordAttempts, parseNativeExtractOutput, parseNativeArchiveEntryList, remapNativeSubstOutput, @@ -26,14 +32,44 @@ import { resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, shouldFallbackLegacyRarToJvm, + shouldRunAlternativeNativeExtractor, + shouldSuggestRedownloadAfterCrossBackendFailure, validateNativeArchiveEntryCandidates, validateNativeFlatArchiveEntryCandidates, } from "../src/main/extractor"; const tempDirs: string[] = []; const originalExtractBackend = process.env.RD_EXTRACT_BACKEND; +const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS; const originalStatfs = fs.promises.statfs; const require = createRequire(import.meta.url); +const rarCliPath = [ + "C:\\Program Files\\WinRAR\\Rar.exe", + "C:\\Program Files (x86)\\WinRAR\\Rar.exe" +].find((candidate) => fs.existsSync(candidate)) || ""; +const javaAvailable = spawnSync("java", ["-version"], { stdio: "ignore" }).status === 0; +const sevenZipAvailable = spawnSync("7z", ["i"], { stdio: "ignore" }).status === 0; + +function createEncryptedCorruptRarFixture(root: string, password: string, stem: string): string { + const packageDir = path.join(root, "pkg"); + const payloadPath = path.join(root, "payload.bin"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(payloadPath, randomBytes(256 * 1024)); + const archivePath = path.join(packageDir, `${stem}.rar`); + const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${password}`, "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" }); + if (created.status !== 0) { + throw new Error(String(created.stderr || created.stdout || `Rar Exit ${created.status}`)); + } + const parts = fs.readdirSync(packageDir).filter((name) => new RegExp(`^${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.part\\d+\\.rar$`, "i").test(name)).sort(); + if (parts.length < 3) { + throw new Error("RAR fixture has fewer than three volumes"); + } + const corruptPath = path.join(packageDir, parts[2]); + const bytes = fs.readFileSync(corruptPath); + bytes[Math.floor(bytes.length / 2)] ^= 0xff; + fs.writeFileSync(corruptPath, bytes); + return packageDir; +} type ZipFixtureEntry = { name: string; directory?: boolean; content?: string }; @@ -110,16 +146,21 @@ afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } - if (originalExtractBackend === undefined) { + if (originalExtractBackend === undefined) { delete process.env.RD_EXTRACT_BACKEND; - } else { - process.env.RD_EXTRACT_BACKEND = originalExtractBackend; + } else { + process.env.RD_EXTRACT_BACKEND = originalExtractBackend; + } + if (originalArchivePasswords === undefined) { + delete process.env.RD_ARCHIVE_PASSWORDS; + } else { + process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords; } (fs.promises as any).statfs = originalStatfs; }); describe("extractor", () => { - it("maps external extractor args by conflict mode", () => { + it("maps external extractor args by conflict mode", () => { const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite"); expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]); expect(overwriteArgs).toContain("-idc"); @@ -148,8 +189,69 @@ describe("extractor", () => { const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org"); expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]); expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar"); - expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\"); - }); + expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\"); + }); + + it.skipIf(process.platform !== "win32" || !rarCliPath || !sevenZipAvailable)("runs one five-candidate legacy pass for a deterministic multipart CRC failure", async () => { + process.env.RD_EXTRACT_BACKEND = "auto"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-crc-pass-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const payloadPath = path.join(root, "payload.bin"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(payloadPath, randomBytes(256 * 1024)); + const archivePath = path.join(packageDir, "release.test.rar"); + const created = spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" }); + expect(created.status).toBe(0); + const parts = fs.readdirSync(packageDir).filter((name) => /^release\.test\.part\d+\.rar$/i.test(name)).sort(); + expect(parts.length).toBeGreaterThanOrEqual(3); + const logs: string[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onLog: (_level, message) => logs.push(message) + }); + + expect(result.failed).toBe(1); + expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start:"))).toHaveLength(1); + expect(logs.filter((message) => /^Legacy-Passwort-Versuch \d\/5:/.test(message))).toHaveLength(5); + expect(logs.some((message) => message.startsWith("Legacy-Fallback:"))).toBe(false); + }, 30_000); + + it.skipIf(process.platform !== "win32" || !rarCliPath)("does not serially retry a deterministic CRC archive after another package archive succeeded", async () => { + process.env.RD_EXTRACT_BACKEND = "legacy"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parallel-crc-pass-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const validPayload = path.join(root, "valid.bin"); + const failedPayload = path.join(root, "failed.bin"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(validPayload, randomBytes(96 * 1024)); + fs.writeFileSync(failedPayload, randomBytes(96 * 1024)); + expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, "a.valid.rar"), validPayload]).status).toBe(0); + expect(spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-idq", path.join(packageDir, "b.failed.rar"), failedPayload]).status).toBe(0); + const logs: string[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onLog: (_level, message) => logs.push(message) + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 })); + expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start: archive=b.failed.rar"))).toHaveLength(1); + }, 30_000); it("deletes only successfully extracted archives", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); @@ -1092,15 +1194,24 @@ describe("extractor", () => { }); }); - describe("classifyExtractionError", () => { - it("classifies CRC errors", () => { - expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error"); - expect(classifyExtractionError("Checksum error in data")).toBe("crc_error"); - }); + describe("classifyExtractionError", () => { + it("classifies CRC errors", () => { + expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error"); + expect(classifyExtractionError("Checksum error in data")).toBe("crc_error"); + expect(classifyExtractionError("7z-Fehler: CRCERROR")).toBe("crc_error"); + expect(classifyExtractionError("7z-Fehler: DATAERROR")).toBe("crc_error"); + expect(classifyExtractionError("CRC-Fehler in release.part3.rar")).toBe("crc_error"); + expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); + expect(classifyExtractionError("Pr�fsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); + expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); + }); - it("classifies wrong password", () => { - expect(classifyExtractionError("Wrong password")).toBe("wrong_password"); - expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password"); + it("classifies wrong password", () => { + expect(classifyExtractionError("Wrong password")).toBe("wrong_password"); + expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password"); + expect(classifyExtractionError("Falsches Archiv-Passwort")).toBe("wrong_password"); + expect(classifyExtractionError("Falsches Archiv Passwort")).toBe("wrong_password"); + expect(classifyExtractionError("Falsches-Archiv-Passwort")).toBe("wrong_password"); }); it("classifies missing parts", () => { @@ -1140,9 +1251,9 @@ describe("extractor", () => { expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error"); }); - it("returns unknown for unrecognized errors", () => { - expect(classifyExtractionError("something weird happened")).toBe("unknown"); - }); + it("returns unknown for unrecognized errors", () => { + expect(classifyExtractionError("something weird happened")).toBe("unknown"); + }); it("keeps important tail markers when long extractor output is trimmed", () => { const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`; @@ -1152,17 +1263,19 @@ describe("extractor", () => { }); }); - describe("shouldSerialRetryParallelFailures", () => { - it("keeps serial recovery enabled after mixed parallel results", () => { - expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true); - expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true); - }); - - it("only retries a total parallel wipe-out for contention-like failures", () => { - expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true); - expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false); - expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false); - }); + describe("shouldSerialRetryParallelFailures", () => { + it("retries unknown failures that can result from parallel contention", () => { + expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true); + expect(shouldSerialRetryParallelFailures(0, ["unknown", "unknown"])).toBe(true); + }); + + it("does not retry deterministic archive failures after another archive succeeded", () => { + expect(shouldSerialRetryParallelFailures(1, ["crc_error"])).toBe(false); + expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(false); + expect(shouldSerialRetryParallelFailures(1, ["unsupported_format"])).toBe(false); + expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false); + expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false); + }); }); describe("password discovery", () => { @@ -1358,6 +1471,191 @@ describe("extractor", () => { }); }); + describe("extractorCommandsShareIdentity", () => { + it("deduplicates aliases of the same native extraction engine", () => { + expect(extractorCommandsShareIdentity("Rar.exe", "UnRAR.exe", "win32")).toBe(true); + expect(extractorCommandsShareIdentity("C:\\Program Files\\WinRAR\\Rar.exe", "rar", "win32")).toBe(true); + expect(extractorCommandsShareIdentity("7z.exe", "7za", "win32")).toBe(true); + expect(extractorCommandsShareIdentity("Rar.exe", "7z.exe", "win32")).toBe(false); + }); + + it("budgets automatic RAR recovery to one native engine before JVM", () => { + expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "auto", "legacy", "win32")).toBe(false); + expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "jvm", "jvm", "win32")).toBe(false); + expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "legacy", "legacy", "win32")).toBe(true); + expect(shouldRunAlternativeNativeExtractor("7z.exe", "C:\\release.zip", "auto", "auto", "win32")).toBe(true); + }); + + it("runs one deduplicated serial recovery pass after parallel unknown failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-serial-recovery-once-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + for (const name of ["a.zip", "b.zip", "c.zip"]) { + writeZipFixture(path.join(packageDir, name), [{ name: `${name}.txt`, content: name }]); + } + const attempts = new Map(); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + scheduleArchive: async (archivePath, execute) => { + const archiveName = path.basename(archivePath); + attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1); + return execute(new AbortController().signal); + }, + onOutput: (event) => { + const archiveName = path.basename(event.archivePath); + if (event.state === "opened" && (archiveName !== "b.zip" || attempts.get(archiveName) === 1)) { + throw new Error(`transient-${archiveName}`); + } + } + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 2 })); + expect(Object.fromEntries(attempts)).toEqual({ "a.zip": 1, "b.zip": 2, "c.zip": 2 }); + }); + + it.skipIf(process.platform !== "win32" || !rarCliPath)("isolates throwing Legacy password-log callbacks without retrying archives", async () => { + process.env.RD_EXTRACT_BACKEND = "legacy"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-callback-isolation-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + for (const name of ["a", "b"]) { + const inputPath = path.join(root, `${name}.txt`); + fs.writeFileSync(inputPath, `${name} payload`, "utf8"); + expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, `${name}.rar`), inputPath]).status).toBe(0); + } + const attempts = new Map(); + const failures: ExtractArchiveFailureInfo[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + scheduleArchive: async (archivePath, execute) => { + const archiveName = path.basename(archivePath); + attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1); + return execute(new AbortController().signal); + }, + onArchiveFailure: (failure) => failures.push(failure), + onLog: (_level, message) => { + if (message.startsWith("Passwort-Versuch ")) { + throw new Error("observer failed"); + } + } + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 2, failed: 0 })); + expect(Object.fromEntries(attempts)).toEqual({ "a.rar": 1, "b.rar": 1 }); + expect(failures).toHaveLength(0); + }, 30_000); + }); + + describe("shouldSuggestRedownloadAfterCrossBackendFailure", () => { + it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("reports redownload only after real Legacy and JVM CRC failures exhaust candidates", async () => { + process.env.RD_EXTRACT_BACKEND = "auto"; + process.env.RD_ARCHIVE_PASSWORDS = ""; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-exhausted-")); + tempDirs.push(root); + const packageDir = createEncryptedCorruptRarFixture(root, "serienjunkies.org", "cross-backend-exhausted"); + const failures: ExtractArchiveFailureInfo[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir: path.join(root, "out"), + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + passwordList: "", + onArchiveFailure: (failure) => failures.push(failure) + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); + expect(failures).toHaveLength(1); + expect(failures[0]).toEqual(expect.objectContaining({ + category: "crc_error", + suggestRedownload: true + })); + expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/); + }, 30_000); + + it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("keeps real Cross-Backend CRC recovery disabled when JVM stops before the final candidate", async () => { + process.env.RD_EXTRACT_BACKEND = "auto"; + process.env.RD_ARCHIVE_PASSWORDS = ""; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-not-exhausted-")); + tempDirs.push(root); + const actualPassword = "cross-backend-early-secret"; + const packageDir = createEncryptedCorruptRarFixture(root, actualPassword, "cross-backend-not-exhausted"); + const failures: ExtractArchiveFailureInfo[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir: path.join(root, "out"), + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + passwordList: actualPassword, + onArchiveFailure: (failure) => failures.push(failure) + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); + expect(failures).toHaveLength(1); + expect(failures[0]).toEqual(expect.objectContaining({ + category: "crc_error", + suggestRedownload: false + })); + expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/); + }, 30_000); + + it("suggests recovery when both backends report CRC failure after every password candidate", () => { + expect(shouldSuggestRedownloadAfterCrossBackendFailure("crc_error", "crc_error", true)).toBe(true); + }); + + it.each([ + ["wrong_password", "crc_error", true], + ["crc_error", "wrong_password", true], + ["crc_error", "unsupported_format", true], + ["crc_error", "crc_error", false] + ] as const)("does not suggest recovery for legacy=%s jvm=%s exhausted=%s", (legacyCategory, jvmCategory, exhausted) => { + expect(shouldSuggestRedownloadAfterCrossBackendFailure(legacyCategory, jvmCategory, exhausted)).toBe(false); + }); + }); + + describe("parseJvmPasswordAttemptLine", () => { + it("accepts only bounded attempt metadata without a password field", () => { + expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5")).toEqual({ attempt: 2, total: 5 }); + expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 0 5")).toBeNull(); + expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5 secret")).toBeNull(); + }); + + it("derives exhaustion only from a valid final JVM attempt", () => { + expect(summarizeJvmPasswordAttempts(1, 3, false)).toEqual({ attempts: 1, total: 3, exhausted: false }); + expect(summarizeJvmPasswordAttempts(3, 3, false)).toEqual({ attempts: 3, total: 3, exhausted: true }); + expect(summarizeJvmPasswordAttempts(3, 3, true)).toEqual({ attempts: 3, total: 3, exhausted: false }); + expect(summarizeJvmPasswordAttempts(4, 3, false)).toEqual({ attempts: 0, total: 0, exhausted: false }); + }); + + it("redacts successful password payloads from JVM diagnostics", () => { + expect(redactJvmDiagnosticLine("RD_PASSWORD c2VjcmV0")).toBe("RD_PASSWORD "); + expect(redactJvmDiagnosticLine("RD_PASSWORD_ATTEMPT 2 5")).toBe("RD_PASSWORD_ATTEMPT 2 5"); + expect(redactJvmDiagnosticLine("RD_ERROR CRCERROR")).toBe("RD_ERROR CRCERROR"); + }); + }); + + describe("direct output scope", () => { it.each([ ["overwrite", "new", "overwritten", ["episode.mkv"]], diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index a70a782..ce1bd9c 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -49,6 +49,35 @@ describe("renderer localization", () => { expect(translateUiText(english, "de")).toBe(german); }); + it.each([ + ["CRC-Check läuft", "CRC check running"], + ["Entpacken - Ausstehend", "Extracting - Pending"], + ["Entpacken - Warten auf Parts", "Extracting - Waiting for parts"], + ["Archive stabilisieren...", "Stabilizing archives..."], + ["Entpacken vorbereiten...", "Preparing extraction..."], + ["Entpacken wird neu gestartet...", "Restarting extraction..."], + ["Nested Entpacken...", "Nested extraction..."], + ["Umbenennen...", "Renaming..."], + ["Tonspur...", "Audio track..."], + ["Aufräumen...", "Cleaning up..."], + ["Verschiebe Videos...", "Moving videos..."] + ])("translates package runtime state %s in both directions", (german, english) => { + expect(translateUiText(german, "en")).toBe(english); + expect(translateUiText(english, "de")).toBe(german); + }); + + it.each([ + ["Entpacken - 42%", "Extracting - 42%"], + ["Passwort gefunden", "Password found"], + ["Passwort knacken: 50% (2/4)", "Cracking password: 50% (2/4)"], + ["Entpacken (1/3) - Nächstes Archiv...", "Extracting (1/3) - Next archive..."] + ])("translates compact runtime text %s for visible and attribute surfaces", (german, english) => { + expect(translateUiText(german, "en")).toBe(english); + expect(translateUiText(english, "de")).toBe(german); + expect(translateUiText(`Bereit · ${german}`, "en")).toBe(`Ready · ${english}`); + expect(translateUiText(`Ready · ${english}`, "de")).toBe(`Bereit · ${german}`); + }); + it("translates the complete history surface including status values", () => { const translations = new Map([ ["Alle Einträge", "All entries"], diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 6336c3f..fa0cda6 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DownloadManager } from "../src/main/download-manager"; import { defaultSettings } from "../src/main/constants"; @@ -13,6 +14,7 @@ import { shutdownRenameLog } from "../src/main/rename-log"; import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types"; const tempDirs: string[] = []; +const sessionRoots = new WeakMap(); afterEach(() => { vi.restoreAllMocks(); @@ -36,6 +38,7 @@ function setup(settings: Partial = {}): { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-")); tempDirs.push(root); const session = emptySession(); + sessionRoots.set(session, root); const events: NotificationEvent[] = []; const history: HistoryEntry[] = []; const manager = new DownloadManager( @@ -70,11 +73,15 @@ function addPackage( packageId = "pkg-1" ): PackageEntry { const startedAt = Date.now() - 30_000; + const root = sessionRoots.get(session) || os.tmpdir(); + const outputDir = path.join(root, "out", packageId); + const extractDir = path.join(root, "extract", packageId); + fs.mkdirSync(outputDir, { recursive: true }); const pkg: PackageEntry = { id: packageId, name: `Test ${packageId}`, - outputDir: `C:/out/${packageId}`, - extractDir: `C:/extract/${packageId}`, + outputDir, + extractDir, status: "queued", itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`), cancelled: false, @@ -90,6 +97,14 @@ function addPackage( session.packageOrder.push(packageId); statuses.forEach((status, index) => { const itemId = `${packageId}-item-${index}`; + const fileName = `${packageId}-${index}.zip`; + const targetPath = path.join(outputDir, fileName); + if (status === "completed") { + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from(`video-${packageId}-${index}`)); + zip.writeZip(targetPath); + } + const downloadedBytes = status === "completed" ? fs.statSync(targetPath).size : 0; session.items[itemId] = { id: itemId, packageId, @@ -98,11 +113,11 @@ function addPackage( status, retries: 0, speedBps: 0, - downloadedBytes: status === "completed" ? 1_000 : 0, - totalBytes: 1_000, + downloadedBytes, + totalBytes: status === "completed" ? downloadedBytes : 1_000, progressPercent: status === "completed" ? 100 : 0, - fileName: `${packageId}-${index}.rar`, - targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`, + fileName, + targetPath, resumable: true, attempts: 1, lastError: status === "failed" ? "offline" : "", @@ -249,7 +264,7 @@ describe("authoritative package completion", () => { const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined); session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error"; - manager.retryExtraction(pkg.id); + await manager.retryExtraction(pkg.id); expect(postProcess).toHaveBeenCalledWith(pkg.id); pkg.archiveOperations = [{ id: "archive-2", @@ -293,7 +308,7 @@ describe("authoritative package completion", () => { session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error"; vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined); - manager.retryExtraction(pkg.id); + await manager.retryExtraction(pkg.id); expect(pkg.resultGeneration).toBe(8); pkg.archiveOperations = [{ @@ -897,7 +912,7 @@ describe("authoritative run completion", () => { postProcessGate = new Promise((resolve) => { releasePostProcess = resolve; }); - manager.retryExtraction(pkg.id); + await manager.retryExtraction(pkg.id); const retriedPostProcess = state.packagePostProcessTasks.get(pkg.id); expect(retriedPostProcess).toBeDefined(); releasePostProcess(); diff --git a/tests/package-presentation.test.ts b/tests/package-presentation.test.ts index 10efcd4..de88d55 100644 --- a/tests/package-presentation.test.ts +++ b/tests/package-presentation.test.ts @@ -94,6 +94,104 @@ describe("download package presentation", () => { expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar"); }); + it("shows the active CRC check instead of a completed fraction", () => { + const presentation = buildPackagePresentation(row([ + item("archive", "CRC-Check läuft", { status: "integrity_check" }) + ], { status: "downloading" })); + + expect(presentation.status).toBe("CRC-Check läuft"); + }); + + it("keeps an active CRC check ahead of historical sibling extraction errors", () => { + const presentation = buildPackagePresentation(row([ + item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"), + item("active", "CRC-Check läuft", { status: "integrity_check" }) + ], { status: "downloading" })); + + expect(presentation.status).toBe("CRC-Check läuft"); + expect(presentation.details).toContain("1 Entpackfehler"); + }); + + it.each([ + ["extracting", "Archive stabilisieren..."], + ["extracting", "Entpacken vorbereiten..."], + ["queued", "Entpacken wird neu gestartet..."], + ["completed", "Nested Entpacken..."], + ["completed", "Renaming..."], + ["completed", "Tonspur..."], + ["completed", "Aufräumen..."], + ["completed", "Verschiebe Videos..."] + ] as const)("keeps the active package phase %s / %s ahead of historical sibling errors", (packageStatus, postProcessLabel) => { + const presentation = buildPackagePresentation(row([ + item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"), + item("active", "Fertig") + ], { status: packageStatus, postProcessLabel })); + + expect(presentation.status).toBe(postProcessLabel); + expect(presentation.details).toContain("1 Entpackfehler"); + expect(presentation.extractFailure?.id).toBe("failed"); + }); + + it("keeps a running download ahead of historical sibling extraction errors", () => { + const presentation = buildPackagePresentation(row([ + item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"), + item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 }) + ], { status: "downloading" })); + + expect(presentation.status).toBe("Download läuft"); + expect(presentation.details).toContain("1 Entpackfehler"); + }); + + it.each([ + "Entpacken - Ausstehend", + "Entpacken - Warten auf Parts" + ])("keeps a running download ahead of the sibling state %s", (fullStatus) => { + const presentation = buildPackagePresentation(row([ + item("pending", fullStatus), + item("active", "Download läuft", { status: "downloading", downloadedBytes: 50, progressPercent: 50 }) + ], { status: "downloading" })); + + expect(presentation.status).toBe("Download läuft"); + }); + + it.each([ + ["queued", "Entpacken - Ausstehend", "Entpacken - Ausstehend"], + ["extracting", "Entpacken - Ausstehend", "Entpacken - Ausstehend"], + ["queued", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"], + ["extracting", "Entpacken - Warten auf Parts", "Entpacken - Warten auf Parts"] + ] as const)("keeps the pending extraction state %s / %s visible on the package", (packageStatus, fullStatus, expectedStatus) => { + const presentation = buildPackagePresentation(row([ + item("archive", fullStatus) + ], { status: packageStatus })); + + expect(presentation.status).toBe(expectedStatus); + }); + + it.each([ + "Entpacken 42% (1/1) · release.part01.rar", + "Passwort knacken: 50% (2/4)", + "Finalisieren - 99% (0/1) · release.part01.rar" + ])("keeps the active extraction phase %s ahead of historical sibling errors", (postProcessLabel) => { + const presentation = buildPackagePresentation(row([ + item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"), + item("active", postProcessLabel) + ], { status: "extracting", postProcessLabel })); + + expect(presentation.status).toBe(postProcessLabel); + expect(presentation.details).toContain("1 Entpackfehler"); + expect(presentation.extractFailure?.id).toBe("failed"); + }); + + it("keeps the active disk wait ahead of historical sibling errors", () => { + const presentation = buildPackagePresentation(row([ + item("failed", "Entpack-Fehler [old.part01.rar]: Checksum/CRC-Fehler im Archiv"), + item("active", "Warte auf Festplatte") + ], { status: "queued" })); + + expect(presentation.status).toBe("Warte auf Festplatte"); + expect(presentation.details).toContain("1 Entpackfehler"); + }); + it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => { const items = [ ...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")), diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts index 31cd321..ec23110 100644 --- a/tests/public-release-metadata.test.ts +++ b/tests/public-release-metadata.test.ts @@ -58,14 +58,22 @@ async function createFixtureAsar(version: string): Promise { } const validAppAsar = await createFixtureAsar("1.7.233"); const staleAppAsar = await createFixtureAsar("1.7.232"); -const redistributionFiles = [ +const redistributionFiles = [ "LICENSE", "THIRD_PARTY_NOTICES.md", "resources/extractor-jvm/licenses/LGPL-2.1.txt", "resources/extractor-jvm/licenses/7-Zip-license.txt", "resources/extractor-jvm/licenses/Apache-2.0.txt", "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt" -] as const; +] as const; +const jvmRuntimeFiles = Object.freeze({ + "resources/extractor-jvm/classes/.source.sha256": "source-digest\n", + "resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x01]), + "resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class": Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x02]), + "resources/extractor-jvm/lib/sevenzipjbinding.jar": Buffer.from("sevenzip-binding"), + "resources/extractor-jvm/lib/sevenzipjbinding-all-platforms.jar": Buffer.from("sevenzip-platforms"), + "resources/extractor-jvm/lib/zip4j.jar": Buffer.from("zip4j") +}); function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void { const filePath = path.join(rootDir, ...relativePath.split("/")); @@ -73,7 +81,7 @@ function writeFile(rootDir: string, relativePath: string, content: string | Buff fs.writeFileSync(filePath, content); } -function writeRedistributionFiles(rootDir: string, packaged = false): void { +function writeRedistributionFiles(rootDir: string, packaged = false): void { for (const relativePath of redistributionFiles) { const content = fs.readFileSync(path.resolve(...relativePath.split("/"))); let targetPath: string = relativePath; @@ -104,21 +112,44 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void { if (omittedName !== "app_icon.ico") { writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon"); } + for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) { + if (path.basename(relativePath) !== omittedName) { + writeFile(outputDir, `resources/app.asar.unpacked/${relativePath}`, content); + } + } +} + +function writeJvmRuntimeFiles(rootDir: string, packaged = false): void { + for (const [relativePath, content] of Object.entries(jvmRuntimeFiles)) { + const targetPath = packaged + ? `win-unpacked/resources/app.asar.unpacked/${relativePath}` + : relativePath; + writeFile(rootDir, targetPath, content); + } } -function createArchiveCommandRunner(omittedName = "") { +function createArchiveCommandRunner(omittedName = "", corruptArchiveName = "") { + let currentArchiveName = ""; return (command: string, args: string[]): CommandResult => { const archivePath = args[1] || ""; const outputArg = args.find((arg) => arg.startsWith("-o")); if (!outputArg) { return { status: 2, stderr: "missing output directory" }; } - const outputDir = outputArg.slice(2); - if (archivePath.toLowerCase().endsWith(".exe")) { - writeFile(outputDir, "payload/app-64.7z", "nested archive"); - } else if (archivePath.toLowerCase().endsWith(".7z")) { - writeArchivePayload(outputDir, omittedName); - } + const outputDir = outputArg.slice(2); + if (archivePath.toLowerCase().endsWith(".exe")) { + currentArchiveName = path.basename(archivePath); + writeFile(outputDir, "payload/app-64.7z", "nested archive"); + } else if (archivePath.toLowerCase().endsWith(".7z")) { + writeArchivePayload(outputDir, omittedName); + if (currentArchiveName === corruptArchiveName) { + writeFile( + outputDir, + "resources/app.asar.unpacked/resources/extractor-jvm/lib/zip4j.jar", + "corrupt-zip4j" + ); + } + } return { status: command ? 0 : 2, stdout: "ok", stderr: "" }; }; } @@ -139,15 +170,18 @@ function createReleaseFixture(): string { owner: "Sucukdeluxe", repo: "Multi-Debrid-Downloader" }, - files: [ + files: [ "build/main/**/*", "build/renderer/**/*", "resources/extractor-jvm/**/*", "LICENSE", "THIRD_PARTY_NOTICES.md", - "package.json" - ], - extraResources: [ + "package.json" + ], + asarUnpack: [ + "resources/extractor-jvm/**/*" + ], + extraResources: [ { from: "LICENSE", to: "LICENSE" @@ -189,6 +223,8 @@ function createReleaseFixture(): string { writeFile(rootDir, "Multi-Debrid-Downloader-1.7.233-portable.exe", "portable"); writeRedistributionFiles(rootDir); writeRedistributionFiles(rootDir, true); + writeJvmRuntimeFiles(rootDir); + writeJvmRuntimeFiles(rootDir, true); writeFile(rootDir, "assets/app_icon.ico", "application-icon"); writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon"); writeFile(rootDir, "win-unpacked/resources/app.asar", validAppAsar); @@ -347,15 +383,74 @@ describe("public release metadata", () => { expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i); }); - it("rejects build metadata that omits the project license", () => { + it("rejects build metadata that omits the project license", () => { const rootDir = createReleaseFixture(); const packagePath = path.join(rootDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE"); fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); - expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/); - }); + expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/); + }); + + it("rejects build metadata that does not unpack the JVM runtime", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + delete packageJson.build.asarUnpack; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/asarUnpack|JVM runtime/i); + }); + + it("rejects a missing JVM class in the unpacked application", () => { + const rootDir = createReleaseFixture(); + fs.rmSync(path.join( + rootDir, + "win-unpacked", + "resources", + "app.asar.unpacked", + "resources", + "extractor-jvm", + "classes", + "com", + "sucukdeluxe", + "extractor", + "JBindExtractorMain$Backend.class" + )); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Backend\.class|missing/i); + }); + + it("rejects changed JVM bytecode in the unpacked application", () => { + const rootDir = createReleaseFixture(); + fs.writeFileSync(path.join( + rootDir, + "win-unpacked", + "resources", + "app.asar.unpacked", + "resources", + "extractor-jvm", + "classes", + "com", + "sucukdeluxe", + "extractor", + "JBindExtractorMain.class" + ), "stale-bytecode"); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|JBindExtractorMain\.class|SHA-?256|content/i); + }); + + it("rejects stale extra JVM bytecode in the unpacked application", () => { + const rootDir = createReleaseFixture(); + writeFile( + rootDir, + "win-unpacked/resources/app.asar.unpacked/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/Stale.class", + "stale-bytecode" + ); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/JVM runtime|Stale\.class|unexpected/i); + }); it("rejects build metadata that does not copy the project license into resources", () => { const rootDir = createReleaseFixture(); @@ -429,14 +524,26 @@ describe("public release metadata", () => { ]); }); - it("rejects an archive whose nested application payload omits a license", () => { + it("rejects an archive whose nested application payload omits a license", () => { const rootDir = createReleaseFixture(); expect(() => verifyReleaseArchives(rootDir, { sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe", runCommand: createArchiveCommandRunner("Apache-2.0.txt") - })).toThrow(/Apache-2\.0\.txt|missing redistribution file/i); - }); + })).toThrow(/Apache-2\.0\.txt|missing redistribution file/i); + }); + + it("rejects a portable archive whose JVM runtime differs from the repository", () => { + const rootDir = createReleaseFixture(); + + expect(() => verifyReleaseArchives(rootDir, { + sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe", + runCommand: createArchiveCommandRunner( + "", + "Multi-Debrid-Downloader-1.7.233-portable.exe" + ) + })).toThrow(/portable|JVM runtime|zip4j\.jar|SHA-?256|content/i); + }); it("exposes archive verification as a nonzero CLI gate", () => { const rootDir = createReleaseFixture(); diff --git a/tests/resolve-archive-items.test.ts b/tests/resolve-archive-items.test.ts index 6307e11..7635e79 100644 --- a/tests/resolve-archive-items.test.ts +++ b/tests/resolve-archive-items.test.ts @@ -122,11 +122,11 @@ describe("resolveArchiveItemsFromList", () => { expect(result).toHaveLength(1); }); - it("returns single archive item when no pattern matches", () => { - const items = makeItems(["totally-different-name.rar"]); - const result = resolveArchiveItemsFromList("Original.rar", items as any); - expect(result).toHaveLength(1); - }); + it("does not guess a different single archive when no pattern matches", () => { + const items = makeItems(["totally-different-name.rar"]); + const result = resolveArchiveItemsFromList("Original.rar", items as any); + expect(result).toHaveLength(0); + }); it("returns empty when items have no archive extensions", () => { const items = makeItems(["video.mkv", "subtitle.srt"]); @@ -217,6 +217,17 @@ describe("resolveSelectedArchiveSetsFromCandidates", () => { expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]); expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]); }); + + it("resolves a uniquely matching pathless legacy item", () => { + const selected = resolveSelectedArchiveSetsFromCandidates( + ["C:\\Downloads\\Episode.E01.rar"], + [{ id: "legacy", fileName: "Episode.E01.rar", targetPath: "", status: "completed" }] as any, + new Set(["legacy"]) + ); + + expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.rar"]); + expect([...selected.itemIds]).toEqual(["legacy"]); + }); }); describe("markPlannedHybridArchiveItemsPending", () => {