From 9049489e87c66153ecfe85fea44ee04b93d226bb Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 13:37:19 +0200 Subject: [PATCH] feat: track direct extraction outputs Replace package staging assumptions with validated per-generation output records for internal ZIP and both JVM backends. Persist resume v2 using relative archive identities, complete member fingerprints, and concrete verified outputs so basename collisions, changed members, missing outputs, and unknown versions fail closed. --- .../JBindExtractorMain$Backend.class | Bin 2260 -> 2310 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 1966 -> 1996 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 6088 -> 6690 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2014 -> 2059 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2539 bytes .../JBindExtractorMain$OutputTarget.class | Bin 0 -> 884 bytes .../JBindExtractorMain$ProgressTracker.class | Bin 1387 -> 1387 bytes ...ExtractorMain$SevenZipArchiveContext.class | Bin 1663 -> 1663 bytes ...ExtractorMain$SevenZipVolumeCallback.class | Bin 3667 -> 3667 bytes ...ExtractorMain$WrongPasswordException.class | Bin 458 -> 458 bytes .../extractor/JBindExtractorMain.class | Bin 23830 -> 25394 bytes .../extractor/JBindExtractorMain.java | 159 +++-- src/main/extractor.ts | 652 +++++++++++++----- src/main/package-output-scope.ts | 220 ++++++ tests/extractor-jvm.test.ts | 73 +- tests/extractor.test.ts | 198 +++++- tests/package-output-scope.test.ts | 159 +++++ 17 files changed, 1227 insertions(+), 234 deletions(-) create mode 100644 resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class create mode 100644 src/main/package-output-scope.ts create mode 100644 tests/package-output-scope.test.ts diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index fe9414c16c2d42e23e76145188d97b38e1689fcf..e8fcab393ee02f477d9930b478fe8e527dd409a4 100644 GIT binary patch delta 269 zcmXYq&q@MO7{$+>Z~i$Fjv)j=MjT0Igc|6h1>lH|fCPY<}mQuk!P%_wh4-2XF-S=6#!Gzm-mgAK$VHgUW1+%APR@STeC{UkG>wIEo1&IQxNMP{X-l<5WdesuZ6fm%j9*7tA zZqS2G7bw!@5?CR?*WjPE`q~D%a53P(dD*-|g|4wd5w<8sgAz2Uk5d|;MK>7or?aN> umKRPoeo9n-&kTiAx delta 229 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 OZ=iMl_c+a6IOf04-GsFO delta 2697 zcmai03s_Xg75?we-M#x5kVkkdh!Ufy3%Gz<6ts;XR49rms0FQY!K<#WKzEl&EOkk3 zYMW||852cqV$%kzw#p;BprndTgT|z3>?@6Z*raWeHjn1fwEaSp_RL)%wx3_K-*@NC z`RCl3IsZR%?mjZ-lG}dy!?WiA%)#G#>^?<2tlVsw#4#C}Vpy5BYzoIM$W-8HZVk4y z2FtxZJz#?g(>SsuZreaejdyz#oxw2^E<*LDM*p2HL4CafrC!G8aO7f^@S3Jt@;LHQ zAcQH)GKXU>J|WJV(k$~h=A%elGbLLVa4f_p#fZr@<5rGExJf}mqpvxjm$v%-dQ-3_ z=+`}4?I^`!4P_kV__SEc<~u4-so^smRah$au|-*>sODIX+Z0&6O~I8OU#qTw&6ru? zKn-d&tdOBA#Xnj0tj{Unn>}}U+}>ul%uz$Vf>;Cbd73u5tD2iO+EIr!9G}NpahrOR zV;wvi8aNuUUbLv|VmDx;hD{t^Y*w&92(#(n0Rq%0aMU$9oo)+7JFKF;{1Q1k^ zBlc-I4h42kW1}7jOr0l=np0C-a0kaWIX;HZ&&$(rr?_IynBImjawHTcbw-rn>HtDx6c07tM z4aYdXi{s*gb!*WZx zotLLzj(CoziS9T}6j|de^)u@=To9-2OKy2WL43K#7iiq12Qrs?0)cJK{-7Q0cv7-3 ztRQKIh_feKhj2*(PlEf#6S>*%TQMRzf6p6-|K4p#JnR&kGicJ>Pd;-~08adSW^n@zKk|%Sx zUF_8s3r~EOC11Mb(v4kRd0naneb?eOCG=DL9I;413R0;oBp@9Vq2QO)H{p5msWOcF zzapQ?!?^zf`BWCh{j21gVKL^R!eCe`1BRsx?DKypPw1lC!lC&_UyPVcAvRzJF7;cx-9)`o^bc8Y6<>X;# zE@w;_g)V1o7{xs_fr8&sTY@-%?Sabfg^le+EV~bhY#*HLeoSTp)7XAwvj>pR9z-EK zfQ9TJN?9i=*dbK2&|$1#M@A|79Zh)+yQ!7?%pj|fHM<@|cK7&t774PW)cziusg+E(&<@|AmF3aVIJ6CHx`Ekm4cQ%{1LrS3f>Qt2@&NP;v%KOc+AxvfPU> zE8PSavfK!kRa4K+sJU5sXYg4WwW@}I*^Jea(d6LN>jvv0SiL-_7q>qO9-=WfQ)-L} z-K^ju5(rU@*W3~4V|WBx2GDE-7Yv}4Iu167wz??NkL@+bkX57(Vn^K|I_moI<%FFP z-0f5&_&V7L_Bhpk+;@HuqK>k9pqmojiIWJOq8n#2gj|UvV(~Ny*@Gn3O9J*GorOuj z2#Q%hO4tBav$JTRg}v-?Y+>gSWJ9=%UBFKEB=)jl9Ap=9l3m6rb_w0=DO_RC;E%*) zJk0+>x`xsbRHtm#&^`r!B3P9rcL#0dWxPV|n3Sd)N%;~5yox`MN^}4ky$y^+nX>JF z2r1P4h&ThcEzj=7!7i*LZ#GkFNA)aZvkv14!rUxWFpRU*%bez0X-U3eVGFp+h8FdtJg0`|+cGT$Eyt4z!Q!d6{&~j^LT7oD^8oLu8baej)VH zhoTF|$wswokOhdnOo!wZq_S7(BEE(!_BwLe8*~xhqzm{K>e$=Z9AfXFiM@+<_78;E zd*~p$lf92!Yy?Nx2k2t|#&Px`PN)j{R1?mr49}}(ysz2}VH04TJc2bP8u}Q0`TLMF zrmPg!B>#bo3VWPxs1&x65_3qVq+X7Yr0OTcPL%;G(R(xdkdgn#5LzYkw^8{^qIHW= zh9%?qyJQ_yIczGGV>J#*>I5XK@yJjUQLHAROijTOH5nBl)oJ7r1M>>GeG47VKEzq^ z7rZvQ@$2i51(H*OHB-6-@;{Pn*>#S2cpP?phX9crVD-XhP0xA6|yDRjt9 X^lg0?Y1lyaZ)64G@;5~Odr12a=A38X 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 80669bf8401e3a69b64c7a7b7029367e05909b68..771c8feb13cc6af562246562952c1b25c9fd7d9e 100644 GIT binary patch delta 219 zcmcb|-z~s(>ff$?3=9k=44E6bma+&6_@*?+7#bKDm>A?3ff$?3=9k=3>h1_maNI2PpswIUl|N diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index d06ab5efc6b675fd1a73dd311fd4d282e8239071..7a3b3e4de48ad60ac9f116126b7fa07d72c37aa7 100644 GIT binary patch delta 126 zcmaDY{91U!H+J4e1{UTP25#n71|jCQ$;=#DjE$4^ICOwyG=~m*qW}kk5rgsML=Mr( zi#Y_rd=my!F#iIFAW+pKunt~MEmn|PtI2|#qC)%|7#J8B8FYap8<1vTZk(LV2{O13 JOzz|~0sw)y9C-i$ delta 126 zcmaDY{91U!H+J4E1{UUA25#m&1|jDB$;=#Dj9HWQICOwyG=~m*mH-EX5rgsML=Mr( zi#Y_rd=my!F#iIFAW+pKunt~MEmn|PtI2|#qC)%|7#J8B8FYap8<1vT&YGOe2{O13 JOzz|~0stvF8#e#| diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class new file mode 100644 index 0000000000000000000000000000000000000000..e1b2fc97efbf646b6c69c18c94aa10dbc2b51013 GIT binary patch literal 884 zcmb_aTTc@~6#k~ObZNV`r64GZxA0I_U~6K$1mc546Sdk0i0{+gp&2Z*&FrkE@CD%A{)_MUhvqQ20* zXxkd4dmBz`IzS0!hQP={YOGA2s=j2X9-~znxp;&O0$gxNZ^6QF@vhWaUY zr@JR(QZH^Qt?VNP-duUjaHn~eaZ)tio2&7*(u8`cwUw5y^8KzfJEH5lD~mXmS+;zy z)na(jZ2$9+HdfB|aY}vPQJ?5Ma6#uk=#2l6=(p3{#PWFJ3+MN_?xsqsd8V~ATYZsb zGV^hRVfAl5Oa$Y1ffjAOM#si!b+l0O8tw!}mTr!$H}dDeaFM)C!n@2|_#M^o_7IhD zbqGIP8-jhMJi|P_^K`G^@8R(eDDjV|@=uuMpX<|Dz@^_zOJo^gr^0WjeECyabV>e> kpqD2?{YlV8T$#Yi&Oxd9tE5!G60VUgQ&9oeaTANb03%(~EdT%j literal 0 HcmV?d00001 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 471ebab45d5ad83fc710cfab44c2499eb667ab12..cfe8652366cf9f52010c4288133032bcd503804e 100644 GIT binary patch delta 113 zcmaFO^_pwLQD#AZ1{M}y22Pd$1|gO}262|4$=8^*Swk6ES;8i>v1H0dGO)2kF|f15 zFbJ~5GRU&TF{rX6FxasqGB~p&P3~c_<4k4XU`b=(V@aQUnI%>eu~kH-gq_Chm2=RAqrJ$3LTyg!a<(tk@Tv}_ Q`rI_&u9$~1o>scaA2{|A(*OVf diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index 507bea209c6ef81f9548fe6b18cd218ac9cf4cd7..e85dfd4f823a32bedd78366650add6d527740f8c 100644 GIT binary patch delta 108 zcmey*^PgwKIc7%7&F7iR8AU4@SXinUI9aM0_*iNfgjs4CBw6YvN3*tBHZicXv@i&; zv;jq18AMn*7^GRc7*tq#7}Qz188le>81z{tFqpASVz6YH$Y8}Xg~5?!DuV~h^vP0e LPK?EqW7(7db$S=~ delta 108 zcmey*^PgwKIc7$)&F7iR8AXE`SXe?BI9Wm&_*lXigjvEFBv~RRN3*tB#xSt6#4!l4 zBmhO@8AMo;7^GQJ7*tr&7}Qx(88lcj81z}P7|d957%W+`8LU|H7#vyh89Z1DCrhz8 LG5Su9Wm5tG(!Uh2 diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index b282d773d93e6efbd3923a9b7cce3fec02d9bf3b..c3d2f41a5352c58ad5e2992ee23307e3b1ddf37a 100644 GIT binary patch delta 218 zcmV<0044v^9Mc@I%K`y7v&;gK2Nj?I1O%P{4+NnA8U&&MA_SuVFa)HNatdS#r~n59 zsQ?TFtCQafuPv|u1_ZGH7zDBa8U(cf9|X1lE(Es#GX%H*KLoh|Oa!|CQUtsJTm-!U zT?D?9y9-+Z#gi8dCIQNmJ`5@W&XZ{js6EmE4g}Kx5Cql$90b<@BLvt0Fa+5EHw4-M zIRxAQLIm9aM+DvgVFcd*X$0T^Yy{!}cm(4Bdj#G9fduH2P7SLW?EoSK?*JwQ@c=0V U@Bk_V@&GLa^8hvk^pg?}L5U?ldjJ3c delta 218 zcmWNL&ntrg9EU&eda}%Gq~u3$DQaaktt@gFaoJ@#xZ@P zZ1Q7qaghy&{Q>?J@Ac`a=jnJI@4gjq67_bV7#!RYWYtGbqwH#&J;m9#?a*fTp@JOA zC2xPBYV25DoM@O+-z#X0Gfh#{H0N5tl;%P!TxyLgty8jQ&#H56LvF;mv3WP@lr7_y zlXnVJQ9m_#JZOTt-)d--LsMJ4Da*TZK0DyUmV0kUKK;WlMQCf1 QmZE$s#*b$BwSn;BKe@L!k^lez diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index 12a9fe1abe248ad7ca601a1f8acd54dc39f0a798..c2bc8268d3bc6daa81cea7a082eff78082fcbb7f 100644 GIT binary patch delta 17 YcmX@be2RI)YDQK!238jL$r~7@05fO=@Bjb+ delta 17 YcmX@be2RI)YDQKC238iu$r~7@05MbqqyPW_ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 63dbc49286d6e5699c9b13287f43a34270284fbf..894704ba9c329e8fcd2ebec24b607a549afe5dcf 100644 GIT binary patch literal 25394 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 a-HK-sp10u{;iKB^bA7l_Q>@)VN&g3SS0nrY literal 23830 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 diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 5d64b33..a49f0f4 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -274,11 +274,13 @@ public final class JBindExtractorMain { } long itemUnits = safeSize(header.getUncompressedSize()); - File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); - if (output == null) { - progress.advance(itemUnits); - continue; - } + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + File output = outputTarget.file; + if (output == null) { + emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); + progress.advance(itemUnits); + continue; + } ensureDirectory(output.getParentFile()); rejectSymlink(output); @@ -319,17 +321,21 @@ public final class JBindExtractorMain { progress.advance(remaining[0]); } long modified = header.getLastModifiedTimeEpoch(); - if (modified > 0) { - output.setLastModified(modified); - } - extractionSuccess = true; - } catch (ZipException error) { + if (modified > 0) { + output.setLastModified(modified); + } + extractionSuccess = true; + emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition); + } catch (ZipException error) { if (isWrongPassword(error, encrypted)) { throw new WrongPasswordException(error); } throw error; - } finally { - if (!extractionSuccess && output.exists()) { + } finally { + if (!extractionSuccess && output.exists()) { + emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition); + } + if (!extractionSuccess && output.exists()) { try { output.delete(); } catch (Throwable ignored) { @@ -368,9 +374,11 @@ public final class JBindExtractorMain { long totalUnits = 0; boolean encrypted = false; - List fileIndices = new ArrayList(); - List outputFiles = new ArrayList(); - List fileSizes = new ArrayList(); + List fileIndices = new ArrayList(); + List outputFiles = new ArrayList(); + List fileSizes = new ArrayList(); + List entryNames = new ArrayList(); + List dispositions = new ArrayList(); Set reserved = new HashSet(); for (int i = 0; i < itemCount; i++) { @@ -396,10 +404,16 @@ public final class JBindExtractorMain { long itemSize = safeSize(rawSize); totalUnits += itemSize; - File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); - fileIndices.add(i); - outputFiles.add(output); - fileSizes.add(itemSize); + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + File output = outputTarget.file; + if (output == null) { + emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); + } + fileIndices.add(i); + outputFiles.add(output); + fileSizes.add(itemSize); + entryNames.add(entryName); + dispositions.add(outputTarget.disposition); } if (fileIndices.isEmpty()) { @@ -434,7 +448,7 @@ public final class JBindExtractorMain { try { archive.extract(indices, false, new BulkExtractCallback( - archive, indexToPos, fileIndices, outputFiles, fileSizes, + archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, progress, encryptedFinal, effectivePassword, currentOutput, currentStream, currentSuccess, currentRemaining, currentPos, firstError )); @@ -566,26 +580,26 @@ public final class JBindExtractorMain { return directory; } - private static File resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { - File base = secureResolve(targetDir, entryName); + private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { + File base = secureResolve(targetDir, entryName); String key = pathKey(base); boolean exists = base.exists() || reserved.contains(key); - if (!exists) { - reserved.add(key); - return base; - } - - if (conflictMode == ConflictMode.SKIP) { - return null; - } + if (!exists) { + reserved.add(key); + return new OutputTarget(base, base, "written"); + } + + if (conflictMode == ConflictMode.SKIP) { + return new OutputTarget(null, base, "skipped"); + } if (conflictMode == ConflictMode.OVERWRITE) { if (base.exists()) { deleteRecursively(base); - } - reserved.add(key); - return base; + } + reserved.add(key); + return new OutputTarget(base, base, "overwritten"); } File parent = base.getParentFile(); @@ -600,8 +614,8 @@ public final class JBindExtractorMain { File candidate = new File(parent, candidateName); String candidateKey = pathKey(candidate); if (!candidate.exists() && !reserved.contains(candidateKey)) { - reserved.add(candidateKey); - return candidate; + reserved.add(candidateKey); + return new OutputTarget(candidate, candidate, "renamed"); } counter += 1; } @@ -816,9 +830,23 @@ public final class JBindExtractorMain { System.out.println("RD_DONE"); } - private static void emitError(String message) { - System.err.println("RD_ERROR " + message); - } + private static void emitError(String message) { + System.err.println("RD_ERROR " + message); + } + + private static String encodeField(String value) { + return Base64.getEncoder().encodeToString((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + } + + private static void emitOutput(File archiveFile, String entryPath, File outputFile, String state, String disposition) { + if (archiveFile == null || outputFile == null) { + return; + } + System.out.println("RD_OUTPUT 1 " + state + " " + disposition + " " + + encodeField(archiveFile.getAbsolutePath()) + " " + + encodeField(entryPath == null ? "" : entryPath.replace('\\', '/')) + " " + + encodeField(outputFile.getAbsolutePath())); + } private enum Backend { AUTO("auto"), @@ -866,20 +894,35 @@ public final class JBindExtractorMain { } } - private static final class ExtractionRequest { + private static final class ExtractionRequest { private File archiveFile; private File targetDir; private ConflictMode conflictMode = ConflictMode.SKIP; private Backend backend = Backend.AUTO; private final List passwords = new ArrayList(); - } + } + + private static final class OutputTarget { + private final File file; + private final File reportedFile; + private final String disposition; + + OutputTarget(File file, File reportedFile, String disposition) { + this.file = file; + this.reportedFile = reportedFile; + this.disposition = disposition; + } + } private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { - private final IInArchive archive; + private final IInArchive archive; + private final File archiveFile; private final Map indexToPos; private final List fileIndices; private final List outputFiles; - private final List fileSizes; + private final List fileSizes; + private final List entryNames; + private final List dispositions; private final ProgressTracker progress; private final boolean encrypted; private final String password; @@ -890,17 +933,21 @@ public final class JBindExtractorMain { private final int[] currentPos; private final Throwable[] firstError; - BulkExtractCallback(IInArchive archive, Map indexToPos, - List fileIndices, List outputFiles, List fileSizes, + BulkExtractCallback(IInArchive archive, File archiveFile, 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) { - this.archive = archive; + this.archive = archive; + this.archiveFile = archiveFile; this.indexToPos = indexToPos; this.fileIndices = fileIndices; this.outputFiles = outputFiles; - this.fileSizes = fileSizes; + this.fileSizes = fileSizes; + this.entryNames = entryNames; + this.dispositions = dispositions; this.progress = progress; this.encrypted = encrypted; this.password = password; @@ -992,21 +1039,23 @@ public final class JBindExtractorMain { if (result == ExtractOperationResult.OK) { currentSuccess[0] = true; closeCurrentStream(); - if (currentPos[0] >= 0 && currentOutput[0] != null) { + if (currentPos[0] >= 0 && currentOutput[0] != null) { try { int archiveIndex = fileIndices.get(currentPos[0]); java.util.Date modified = (java.util.Date) archive.getProperty(archiveIndex, PropID.LAST_MODIFICATION_TIME); if (modified != null) { currentOutput[0].setLastModified(modified.getTime()); } - } catch (Throwable ignored) { - - } - } - } else { - closeCurrentStream(); - if (currentOutput[0] != null && currentOutput[0].exists()) { - try { + } catch (Throwable ignored) { + + } + emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "complete", dispositions.get(currentPos[0])); + } + } else { + closeCurrentStream(); + if (currentOutput[0] != null && currentOutput[0].exists()) { + emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "partial", dispositions.get(currentPos[0])); + try { currentOutput[0].delete(); } catch (Throwable ignored) { } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 1d01ff9..a97d0b4 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -4,8 +4,11 @@ import os from "node:os"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import AdmZip from "adm-zip"; import { CleanupMode, ConflictMode } from "../shared/types"; -import { logger } from "./logger"; -import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { logger } from "./logger"; +import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { PackageOutputScope, type ExtractOutputEvent } from "./package-output-scope"; + +export type { ExtractOutputEvent } from "./package-output-scope"; import crypto from "node:crypto"; @@ -59,9 +62,10 @@ export interface ExtractOptions { hybridMode?: boolean; maxParallel?: number; extractCpuPriority?: string; - onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; - onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; -} + onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; + onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; + onOutput?: (event: ExtractOutputEvent) => void; +} export interface ExtractProgressUpdate { current: number; @@ -131,7 +135,7 @@ type JvmExtractorLayout = { rootDir: string; }; -type JvmExtractResult = { +type JvmExtractResult = { ok: boolean; missingCommand: boolean; missingRuntime: boolean; @@ -139,8 +143,15 @@ type JvmExtractResult = { timedOut: boolean; errorText: string; usedPassword: string; - backend: string; -}; + backend: string; +}; + +export interface ExtractResult { + extracted: number; + failed: number; + lastError: string; + outputFiles: string[]; +} type ExtractSpawnResult = { ok: boolean; @@ -150,15 +161,34 @@ type ExtractSpawnResult = { errorText: string; }; -type ExtractResumeState = { - completedArchives: string[]; -}; +type ExtractResumeMember = { + path: string; + size: number; + mtimeMs: number; +}; + +type ExtractResumeOutput = { + entryPath: string; + path: string; + disposition: ExtractOutputEvent["disposition"]; +}; + +type ExtractResumeArchive = { + archivePath: string; + members: ExtractResumeMember[]; + outputs: ExtractResumeOutput[]; +}; + +type ExtractResumeState = { + version: 2; + archives: ExtractResumeArchive[]; +}; type ExtractorCommandKind = "rar_native" | "seven_zip" | "other"; interface SubstMapping { drive: string; original: string; } -interface DaemonRequest { +interface DaemonRequest { resolve: (result: JvmExtractResult) => void; onArchiveProgress?: (percent: number) => void; signal?: AbortSignal; @@ -166,8 +196,9 @@ interface DaemonRequest { parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; archiveName: string; startedAt: number; - passwordCount: number; -} + passwordCount: number; + onOutput?: (event: ExtractOutputEvent) => void; +} const activeSubstDrives = new Set(); @@ -247,10 +278,6 @@ export function pathSetKey(filePath: string): string { return process.platform === "win32" ? filePath.toLowerCase() : filePath; } -function archiveNameKey(fileName: string): string { - return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); -} - function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); } @@ -326,14 +353,30 @@ export async function findArchiveCandidates(packageDir: string): Promise entry.isFile()) - .map((entry) => path.join(packageDir, entry.name)); - } catch { - return []; - } + let files: string[] = []; + const stack = [packageDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + if (!/^\.rd-(?:output|replace)-/i.test(entry.name)) { + stack.push(fullPath); + } + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase())); const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath))); @@ -1510,11 +1553,12 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null { return null; } -function parseJvmLine( - line: string, - onArchiveProgress: ((percent: number) => void) | undefined, - state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string } -): void { +function parseJvmLine( + line: string, + onArchiveProgress: ((percent: number) => void) | undefined, + state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }, + onOutput?: (event: ExtractOutputEvent) => void +): void { const trimmed = String(line || "").trim(); if (!trimmed) { return; @@ -1542,10 +1586,31 @@ function parseJvmLine( return; } - if (trimmed.startsWith("RD_BACKEND ")) { - state.backend = trimmed.slice("RD_BACKEND ".length).trim(); - return; - } + if (trimmed.startsWith("RD_BACKEND ")) { + state.backend = trimmed.slice("RD_BACKEND ".length).trim(); + return; + } + + if (trimmed.startsWith("RD_OUTPUT ")) { + const fields = trimmed.split(" "); + const stateValue = fields[2]; + const disposition = fields[3]; + if (fields.length !== 7 + || fields[1] !== "1" + || (stateValue !== "complete" && stateValue !== "partial") + || !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) { + return; + } + onOutput?.({ + version: 1, + archivePath: Buffer.from(fields[4], "base64").toString("utf8"), + entryPath: Buffer.from(fields[5], "base64").toString("utf8"), + outputPath: Buffer.from(fields[6], "base64").toString("utf8"), + state: stateValue, + disposition: disposition as ExtractOutputEvent["disposition"] + }); + return; + } if (trimmed.startsWith("RD_ERROR ")) { state.reportedError = trimmed.slice("RD_ERROR ".length).trim(); @@ -1601,11 +1666,11 @@ function flushDaemonParseBuffers(req: DaemonRequest | null): void { return; } if (daemonStdoutBuffer.trim()) { - parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState); + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput); daemonStdoutBuffer = ""; } if (daemonStderrBuffer.trim()) { - parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState); + parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput); daemonStderrBuffer = ""; } } @@ -1663,7 +1728,7 @@ function handleDaemonLine(line: string): void { } if (daemonCurrentRequest) { - parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); } } @@ -1716,7 +1781,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean { daemonStderrBuffer = lines.pop() || ""; for (const line of lines) { if (daemonCurrentRequest) { - parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); } } }); @@ -1778,14 +1843,15 @@ function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise void, - signal?: AbortSignal, - timeoutMs?: number + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number, + onOutput?: (event: ExtractOutputEvent) => void ): Promise { return new Promise((resolve) => { const mode = effectiveConflictMode(conflictMode); @@ -1801,8 +1867,9 @@ function sendDaemonRequest( timeoutMs, parseState, archiveName, - startedAt: Date.now(), - passwordCount: passwordCandidates.length + startedAt: Date.now(), + passwordCount: passwordCandidates.length, + onOutput }; logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`); @@ -1858,15 +1925,16 @@ function sendDaemonRequest( }); } -async function runJvmExtractCommand( +async function runJvmExtractCommand( layout: JvmExtractorLayout, archivePath: string, targetDir: string, conflictMode: ConflictMode, passwordCandidates: string[], - onArchiveProgress?: (percent: number) => void, - signal?: AbortSignal, - timeoutMs?: number + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number, + onOutput?: (event: ExtractOutputEvent) => void ): Promise { if (signal?.aborted) { return Promise.resolve({ @@ -1879,7 +1947,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); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); } if (daemonProcess) { @@ -1891,7 +1959,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); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); } logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`); } @@ -1947,7 +2015,7 @@ async function runJvmExtractCommand( const lines = nextBuffer.split(/\r?\n/); const keep = lines.pop() || ""; for (const line of lines) { - parseJvmLine(line, onArchiveProgress, parseState); + parseJvmLine(line, onArchiveProgress, parseState, onOutput); } if (fromStdErr) { stderrBuffer = keep; @@ -2022,8 +2090,8 @@ async function runJvmExtractCommand( }); child.on("close", (code) => { - parseJvmLine(stdoutBuffer, onArchiveProgress, parseState); - parseJvmLine(stderrBuffer, onArchiveProgress, parseState); + parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput); + parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput); if (abortedBySignal) { finish({ @@ -2102,10 +2170,11 @@ async function runExternalExtractInner( signal: AbortSignal | undefined, timeoutMs: number, hybridMode = false, - onPasswordAttempt?: (attempt: number, total: number) => void, - forceFlatMode = false, - flatModeResult?: { needed: boolean }, - onLog?: ExtractOptions["onLog"] + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"], + onOutput?: (event: ExtractOutputEvent) => void ): Promise { const passwords = passwordCandidates; let lastError = ""; @@ -2281,11 +2350,12 @@ async function runExternalExtract( onArchiveProgress?: (percent: number) => void, signal?: AbortSignal, hybridMode = false, - onPasswordAttempt?: (attempt: number, total: number) => void, - forceFlatMode = false, - flatModeResult?: { needed: boolean }, - onLog?: ExtractOptions["onLog"] -): Promise { + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"], + onOutput?: (event: ExtractOutputEvent) => void +): Promise { const timeoutMs = await computeExtractTimeoutMs(archivePath); const configuredBackendMode = extractorBackendMode(); const backendMode = extractorBackendModeForArchive(archivePath); @@ -2317,7 +2387,7 @@ async function runExternalExtract( 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 + onArchiveProgress, signal, timeoutMs, onOutput ); 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"}`); @@ -2497,7 +2567,8 @@ async function runExternalExtract( passwordCandidates, onArchiveProgress, signal, - timeoutMs + timeoutMs, + onOutput ); 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"}`); @@ -2575,7 +2646,13 @@ export function selectZipFallbackError(internalError: unknown, externalError: un return externalError; } -async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise { +async function extractZipArchive( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + signal?: AbortSignal, + onOutput?: (event: ExtractOutputEvent) => void +): Promise { const mode = effectiveConflictMode(conflictMode); const memoryLimitBytes = zipEntryMemoryLimitBytes(); const zip = new AdmZip(archivePath); @@ -2631,16 +2708,25 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } - let outputPath = baseOutputPath; - let outputKey = pathSetKey(outputPath); - - await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); - const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); - if (outputExists) { - if (mode === "skip") { - continue; - } - if (mode === "rename") { + let outputPath = baseOutputPath; + let outputKey = pathSetKey(outputPath); + let disposition: ExtractOutputEvent["disposition"] = "written"; + + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); + if (outputExists) { + if (mode === "skip") { + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: entry.entryName.replace(/\\/g, "/"), + outputPath: baseOutputPath, + state: "complete", + disposition: "skipped" + }); + continue; + } + if (mode === "rename") { const parsed = path.parse(baseOutputPath); const counterKey = pathSetKey(baseOutputPath); let n = renameCounters.get(counterKey) || 1; @@ -2661,9 +2747,12 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic if (signal?.aborted) { throw new Error("aborted:extract"); } - outputPath = candidate; - outputKey = candidateKey; - } + outputPath = candidate; + outputKey = candidateKey; + disposition = "renamed"; + } else { + disposition = "overwritten"; + } } if (signal?.aborted) { @@ -2679,10 +2768,32 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) { throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`); } - await fs.promises.writeFile(outputPath, data); - usedOutputs.add(outputKey); - } -} + try { + await fs.promises.writeFile(outputPath, data); + usedOutputs.add(outputKey); + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: entry.entryName.replace(/\\/g, "/"), + outputPath, + state: "complete", + disposition + }); + } catch (error) { + if (await fs.promises.access(outputPath).then(() => true, () => false)) { + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: entry.entryName.replace(/\\/g, "/"), + outputPath, + state: "partial", + disposition + }); + } + throw error; + } + } +} async function estimateArchivesTotalBytes(candidates: string[]): Promise { let total = 0; @@ -2774,30 +2885,135 @@ function extractProgressFilePath(packageDir: string, packageId?: string): string return path.join(packageDir, EXTRACT_PROGRESS_FILE); } -async function readExtractResumeState(packageDir: string, packageId?: string): Promise> { - const progressPath = extractProgressFilePath(packageDir, packageId); +function relativeResumePath(rootDir: string, filePath: string): string | null { + const relativePath = path.relative(path.resolve(rootDir), path.resolve(filePath)); + if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + return null; + } + return relativePath.replace(/\\/g, "/"); +} + +function archiveResumeIdentity(archivePath: string, packageDir: string, targetDir: string): string | null { + const packageRelative = relativeResumePath(packageDir, archivePath); + if (packageRelative) { + return `package:${packageRelative}`; + } + const targetRelative = relativeResumePath(targetDir, archivePath); + return targetRelative ? `target:${targetRelative}` : null; +} + +function resolveResumePath(identity: string, packageDir: string, targetDir: string): string | null { + const separator = identity.indexOf(":"); + if (separator <= 0) { + return null; + } + const prefix = identity.slice(0, separator); + const relativePath = identity.slice(separator + 1).replace(/\\/g, "/"); + const segments = relativePath.split("/"); + if (!relativePath || segments.some((segment) => !segment || segment === "..") || path.posix.isAbsolute(relativePath) || /^[a-zA-Z]:/.test(relativePath)) { + return null; + } + const rootDir = prefix === "package" ? packageDir : prefix === "target" ? targetDir : ""; + if (!rootDir) { + return null; + } + const resolved = path.resolve(rootDir, ...segments); + return relativeResumePath(rootDir, resolved) ? resolved : null; +} + +async function buildArchiveFingerprint(archivePath: string, packageDir: string, targetDir: string): Promise { + const members: ExtractResumeMember[] = []; + const candidates = collectArchiveCleanupTargets(archivePath); + for (const memberPath of candidates) { + const identity = archiveResumeIdentity(memberPath, packageDir, targetDir); + if (!identity) { + return []; + } + try { + const stat = await fs.promises.stat(memberPath); + if (!stat.isFile()) { + return []; + } + members.push({ path: identity, size: stat.size, mtimeMs: stat.mtimeMs }); + } catch { + return []; + } + } + return members.sort((left, right) => left.path.localeCompare(right.path)); +} + +function sameArchiveFingerprint(left: readonly ExtractResumeMember[], right: readonly ExtractResumeMember[]): boolean { + return left.length > 0 + && left.length === right.length + && left.every((member, index) => member.path === right[index]?.path + && member.size === right[index]?.size + && member.mtimeMs === right[index]?.mtimeMs); +} + +async function readExtractResumeState(packageDir: string, targetDir: string, packageId?: string): Promise> { + const progressPath = extractProgressFilePath(packageDir, packageId); try { await fs.promises.access(progressPath); } catch { - return new Set(); - } - try { - const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial; - const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : []; - return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean)); - } catch { - return new Set(); - } -} - -async function writeExtractResumeState(packageDir: string, completedArchives: Set, packageId?: string): Promise { + return new Map(); + } + try { + const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial; + if (payload.version !== 2 || !Array.isArray(payload.archives)) { + return new Map(); + } + const archives = new Map(); + for (const rawArchive of payload.archives) { + if (!rawArchive || typeof rawArchive !== "object") { + continue; + } + const archivePath = String(rawArchive.archivePath || "").trim(); + if (!resolveResumePath(archivePath, packageDir, targetDir)) { + continue; + } + const members = Array.isArray(rawArchive.members) + ? rawArchive.members.flatMap((member) => { + const memberPath = String(member?.path || "").trim(); + const size = Number(member?.size); + const mtimeMs = Number(member?.mtimeMs); + return resolveResumePath(memberPath, packageDir, targetDir) + && Number.isFinite(size) && size >= 0 + && Number.isFinite(mtimeMs) && mtimeMs >= 0 + ? [{ path: memberPath, size, mtimeMs }] + : []; + }).sort((left, right) => left.path.localeCompare(right.path)) + : []; + const outputs = Array.isArray(rawArchive.outputs) + ? rawArchive.outputs.flatMap((output) => { + const outputPath = String(output?.path || "").trim().replace(/\\/g, "/"); + const entryPath = String(output?.entryPath || "").trim().replace(/\\/g, "/"); + const disposition = output?.disposition; + const resolvedOutput = resolveResumePath(`target:${outputPath}`, packageDir, targetDir); + return resolvedOutput + && entryPath + && !entryPath.split("/").some((segment) => segment === "..") + && (["written", "overwritten", "renamed"] as const).includes(disposition as ExtractResumeOutput["disposition"]) + ? [{ entryPath, path: outputPath, disposition: disposition as ExtractResumeOutput["disposition"] }] + : []; + }) + : []; + if (members.length > 0 && outputs.length > 0) { + archives.set(archivePath.toLocaleLowerCase("en-US"), { archivePath, members, outputs }); + } + } + return archives; + } catch { + return new Map(); + } +} + +async function writeExtractResumeState(packageDir: string, completedArchives: ReadonlyMap, packageId?: string): Promise { try { await fs.promises.mkdir(packageDir, { recursive: true }); - const progressPath = extractProgressFilePath(packageDir, packageId); - const payload: ExtractResumeState = { - completedArchives: Array.from(completedArchives) - .map((name) => archiveNameKey(name)) - .sort((a, b) => a.localeCompare(b)) + const progressPath = extractProgressFilePath(packageDir, packageId); + const payload: ExtractResumeState = { + version: 2, + archives: [...completedArchives.values()].sort((left, right) => left.archivePath.localeCompare(right.archivePath)) }; const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp"; await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8"); @@ -2841,10 +3057,15 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" return "skip"; } -export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { - if (options.signal?.aborted) { - throw new Error("aborted:extract"); - } +export async function extractPackageArchives(options: ExtractOptions): Promise { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } + const outputScope = new PackageOutputScope([options.targetDir]); + const emitOutput = (event: ExtractOutputEvent): void => { + outputScope.add(event); + options.onOutput?.(event); + }; options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" }); const allCandidates = await findArchiveCandidates(options.packageDir); const candidates = options.onlyArchives @@ -2864,12 +3085,29 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ await checkDiskSpaceForExtraction(options.targetDir, candidates); } - if (candidates.length === 0) { - if (!options.onlyArchives) { - const existingResume = await readExtractResumeState(options.packageDir, options.packageId); - if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) { - await clearExtractResumeState(options.packageDir, options.packageId); - logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); + if (candidates.length === 0) { + if (!options.onlyArchives) { + const existingResume = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId); + const validResume = [...existingResume.values()].filter((archive) => archive.outputs.length > 0); + const allOutputsExist = validResume.length > 0 + && (await Promise.all(validResume.map((archive) => resumeOutputsExist(archive, options.packageDir, options.targetDir)))).every(Boolean); + if (allOutputsExist) { + for (const archive of validResume) { + const archivePath = resolveResumePath(archive.archivePath, options.packageDir, options.targetDir) as string; + for (const output of archive.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } + await clearExtractResumeState(options.packageDir, options.packageId); + logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); options.onProgress?.({ current: existingResume.size, total: existingResume.size, @@ -2877,12 +3115,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archiveName: "", phase: "done" }); - return { extracted: existingResume.size, failed: 0, lastError: "" }; + outputScope.pruneMissing(); + return { extracted: existingResume.size, failed: 0, lastError: "", outputFiles: outputScope.completeFiles() }; } await clearExtractResumeState(options.packageDir, options.packageId); } logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); - return { extracted: 0, failed: 0, lastError: "" }; + return { extracted: 0, failed: 0, lastError: "", outputFiles: [] }; } const conflictMode = effectiveConflictMode(options.conflictMode); @@ -2898,28 +3137,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); } - const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId); - const resumeCompletedAtStart = resumeCompleted.size; - const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); - for (const archiveName of Array.from(resumeCompleted.values())) { - // Nested-archive progress (keyed "nested:") has no top-level candidate on - // disk to validate against, so it must NOT be pruned here — otherwise every - // extractPackageArchives call wiped it and nested archives were re-extracted on - // resume. It is cleared together with the rest once the package fully completes. - if (archiveName.startsWith("nested:")) { - continue; - } - if (!allCandidateNames.has(archiveName)) { - resumeCompleted.delete(archiveName); - } - } + const resumeCompleted = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId); + const resumeCompletedAtStart = resumeCompleted.size; + const resumedArchivePaths = new Set(); + for (const archivePath of candidates) { + const identity = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir); + const saved = identity ? resumeCompleted.get(identity.toLocaleLowerCase("en-US")) : undefined; + if (!identity || !saved) { + continue; + } + const currentFingerprint = await buildArchiveFingerprint(archivePath, options.packageDir, options.targetDir); + if (!sameArchiveFingerprint(saved.members, currentFingerprint) + || !(await resumeOutputsExist(saved, options.packageDir, options.targetDir))) { + resumeCompleted.delete(identity.toLocaleLowerCase("en-US")); + continue; + } + resumedArchivePaths.add(pathSetKey(archivePath)); + for (const output of saved.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } if (resumeCompleted.size > 0) { await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); } else { await clearExtractResumeState(options.packageDir, options.packageId); } - const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath)))); + const pendingCandidates = candidates.filter((archivePath) => !resumedArchivePaths.has(pathSetKey(archivePath))); let extracted = candidates.length - pendingCandidates.length; let failed = 0; let lastError = ""; @@ -2929,7 +3181,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const skippedNonArchives = new Set(); const failedArchiveCategories = new Map(); for (const archivePath of candidates) { - if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + if (resumedArchivePaths.has(pathSetKey(archivePath))) { const resumedName = path.basename(archivePath); const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName); if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) { @@ -2998,8 +3250,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ emitProgress(extracted, "", "extracting"); - for (const archivePath of candidates) { - if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + for (const archivePath of candidates) { + if (resumedArchivePaths.has(pathSetKey(archivePath))) { emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }, archivePath); } } @@ -3016,7 +3268,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ throw new Error("noextractor:skipped"); } const archiveName = path.basename(archivePath); - const archiveResumeKey = archiveNameKey(archiveName); + const archiveResumeKey = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir)?.toLocaleLowerCase("en-US") || ""; const archiveStartedAt = Date.now(); const startedCurrent = extracted + failed; if (lastArchiveFinishedAt !== null) { @@ -3053,9 +3305,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (!sig) { logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`); extracted += 1; - resumeCompleted.add(archiveResumeKey); - skippedNonArchives.add(pathSetKey(archivePath)); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + skippedNonArchives.add(pathSetKey(archivePath)); clearInterval(pulseTimer); archiveOutcome = "skipped"; const skippedAt = Date.now(); @@ -3089,18 +3339,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { - await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); } else { throw error; } } } else { try { - await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); archivePercent = 100; } catch (error) { if (!shouldFallbackToExternalZip(error)) { @@ -3109,7 +3359,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (externalError) { throw selectZipFallbackError(error, externalError); @@ -3120,15 +3370,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const flatResult = { needed: false }; const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog); + }, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); if (flatResult.needed) packageNeedsFlatMode = true; } extracted += 1; extractedArchives.add(archivePath); failedArchiveCategories.delete(archivePath); - resumeCompleted.add(archiveResumeKey); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + const resumeArchive = await buildResumeArchive(archivePath, options.packageDir, options.targetDir, outputScope.records()); + if (archiveResumeKey && resumeArchive) { + resumeCompleted.set(archiveResumeKey, resumeArchive); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`); archiveOutcome = "success"; @@ -3248,7 +3501,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (abortError) throw new Error("aborted:extract"); if (failed > 0 && extracted === 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + 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(","); @@ -3275,7 +3528,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (failed > 0 && extracted > 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + 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; @@ -3308,8 +3561,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) { try { - const nestedCandidates = (await findArchiveCandidates(options.targetDir)) - .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); + const nestedCandidates = outputScope.archiveFiles() + .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); if (nestedCandidates.length > 0) { logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`); let nestedExtracted = 0; @@ -3321,13 +3574,31 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ nestedCandidates.length = 0; } for (const nestedArchive of nestedCandidates) { - if (options.signal?.aborted) throw new Error("aborted:extract"); - const nestedName = path.basename(nestedArchive); - const nestedKey = archiveNameKey(`nested:${nestedName}`); - if (resumeCompleted.has(nestedKey)) { - logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); - continue; - } + if (options.signal?.aborted) throw new Error("aborted:extract"); + const nestedName = path.basename(nestedArchive); + const nestedIdentity = archiveResumeIdentity(nestedArchive, options.packageDir, options.targetDir); + const nestedKey = nestedIdentity?.toLocaleLowerCase("en-US") || ""; + const savedNested = nestedKey ? resumeCompleted.get(nestedKey) : undefined; + const nestedFingerprint = savedNested + ? await buildArchiveFingerprint(nestedArchive, options.packageDir, options.targetDir) + : []; + if (savedNested + && sameArchiveFingerprint(savedNested.members, nestedFingerprint) + && await resumeOutputsExist(savedNested, options.packageDir, options.targetDir)) { + for (const output of savedNested.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath: nestedArchive, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); + continue; + } const nestedStartedAt = Date.now(); let nestedPercent = 0; emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0, undefined, undefined, nestedArchive); @@ -3340,22 +3611,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const ext = path.extname(nestedArchive).toLowerCase(); if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) { try { - await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput); nestedPercent = 100; } catch (zipErr) { if (!shouldFallbackToExternalZip(zipErr)) throw zipErr; - const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPw); } } else { - const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPw); } extracted += 1; nestedExtracted += 1; extractedArchives.add(nestedArchive); - resumeCompleted.add(nestedKey); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + const nestedResume = await buildResumeArchive(nestedArchive, options.packageDir, options.targetDir, outputScope.records()); + if (nestedKey && nestedResume) { + resumeCompleted.set(nestedKey, nestedResume); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`); if (options.cleanupMode !== "none") { await cleanupArchives([nestedArchive], options.cleanupMode); @@ -3450,5 +3724,59 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); - return { extracted, failed, lastError }; -} + outputScope.pruneMissing(); + return { extracted, failed, lastError, outputFiles: outputScope.completeFiles() }; +} + +async function resumeOutputsExist( + archive: ExtractResumeArchive, + packageDir: string, + targetDir: string +): Promise { + const archivePath = resolveResumePath(archive.archivePath, packageDir, targetDir); + if (!archivePath || archive.outputs.length === 0) { + return false; + } + const scope = new PackageOutputScope([targetDir]); + try { + for (const output of archive.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, packageDir, targetDir); + if (!outputPath) { + return false; + } + scope.add({ + version: 1, + archivePath, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } catch { + return false; + } + return scope.completeFiles().length === archive.outputs.length; +} + +async function buildResumeArchive( + archivePath: string, + packageDir: string, + targetDir: string, + outputRecords: readonly ExtractOutputEvent[] +): Promise { + const archiveIdentity = archiveResumeIdentity(archivePath, packageDir, targetDir); + const members = await buildArchiveFingerprint(archivePath, packageDir, targetDir); + if (!archiveIdentity || members.length === 0) { + return null; + } + const archiveKey = pathSetKey(path.resolve(archivePath)); + const outputs = outputRecords.flatMap((record) => { + if (record.state !== "complete" || record.disposition === "skipped" || pathSetKey(record.archivePath) !== archiveKey) { + return []; + } + const outputPath = relativeResumePath(targetDir, record.outputPath); + return outputPath ? [{ entryPath: record.entryPath, path: outputPath, disposition: record.disposition }] : []; + }); + return outputs.length > 0 ? { archivePath: archiveIdentity, members, outputs } : null; +} diff --git a/src/main/package-output-scope.ts b/src/main/package-output-scope.ts new file mode 100644 index 0000000..cec6ed5 --- /dev/null +++ b/src/main/package-output-scope.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type ExtractOutputState = "complete" | "partial"; +export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped"; + +export interface ExtractOutputEvent { + version: 1; + archivePath: string; + entryPath: string; + outputPath: string; + state: ExtractOutputState; + disposition: ExtractOutputDisposition; +} + +export class PackageOutputScope { + private readonly authorizedRoots: string[]; + + private readonly outputRecords = new Map(); + + public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) { + this.authorizedRoots = [...new Map( + authorizedRoots + .map((root) => path.resolve(String(root || "").trim())) + .filter(Boolean) + .map((root) => [this.pathKey(root), root]) + ).values()]; + if (this.authorizedRoots.length === 0) { + throw new Error("PackageOutputScope benötigt mindestens einen autorisierten Root"); + } + this.addMany(records); + } + + private pathKey(value: string): string { + return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US"); + } + + private validateEntryPath(entryPath: string): string { + const normalized = String(entryPath || "").trim().replace(/\\/g, "/"); + const segments = normalized.split("/"); + if (!normalized + || normalized.startsWith("/") + || /^[a-zA-Z]:/.test(normalized) + || path.posix.isAbsolute(normalized) + || segments.some((segment) => segment === ".." || segment === "")) { + throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`); + } + return segments.filter((segment) => segment !== ".").join("/"); + } + + private findAuthorizedRoot(outputPath: string): string { + const resolvedOutput = path.resolve(outputPath); + const outputKey = this.pathKey(resolvedOutput); + const root = this.authorizedRoots.find((candidate) => { + const rootKey = this.pathKey(candidate); + return outputKey === rootKey || outputKey.startsWith(`${rootKey}${path.sep.toLocaleLowerCase("en-US")}`); + }); + if (!root || this.pathKey(root) === outputKey) { + throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`); + } + return root; + } + + private rejectLinkedPath(outputPath: string, authorizedRoot: string): void { + let current = path.resolve(outputPath); + const rootKey = this.pathKey(authorizedRoot); + while (true) { + try { + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + throw new Error(`Symbolischer Link oder Reparse Point im Ausgabepfad: ${outputPath}`); + } + } catch (error) { + const code = String((error as NodeJS.ErrnoException)?.code || ""); + if (code !== "ENOENT") { + throw error; + } + } + if (this.pathKey(current) === rootKey) { + break; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`); + } + current = parent; + } + } + + private normalizeEvent(event: ExtractOutputEvent): ExtractOutputEvent { + if (Number(event.version) !== 1) { + throw new Error(`Nicht unterstützte Extract-Output-Version: ${String(event.version)}`); + } + if (!path.isAbsolute(String(event.archivePath || ""))) { + throw new Error(`Archivpfad muss absolut sein: ${event.archivePath}`); + } + if (event.state !== "complete" && event.state !== "partial") { + throw new Error(`Ungültiger Extract-Output-Status: ${String(event.state)}`); + } + if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) { + throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`); + } + const entryPath = this.validateEntryPath(event.entryPath); + if (!path.isAbsolute(String(event.outputPath || ""))) { + throw new Error(`Finaler Ausgabepfad muss absolut sein: ${event.outputPath}`); + } + const outputPath = path.resolve(event.outputPath); + const authorizedRoot = this.findAuthorizedRoot(outputPath); + this.rejectLinkedPath(outputPath, authorizedRoot); + if (event.disposition !== "skipped") { + let stat: fs.Stats; + try { + stat = fs.lstatSync(outputPath); + } catch { + throw new Error(`Gemeldete Extract-Ausgabe existiert nicht: ${outputPath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Gemeldete Extract-Ausgabe ist keine reguläre Datei: ${outputPath}`); + } + } + return { + version: 1, + archivePath: path.resolve(event.archivePath), + entryPath, + outputPath, + state: event.state, + disposition: event.disposition + }; + } + + public add(event: ExtractOutputEvent): boolean { + const normalized = this.normalizeEvent(event); + if (normalized.disposition === "skipped") { + return false; + } + const key = this.pathKey(normalized.outputPath); + const current = this.outputRecords.get(key); + if (current) { + if (current.state === "partial" && normalized.state === "complete") { + this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath }); + } + return false; + } + this.outputRecords.set(key, normalized); + return true; + } + + public addMany(events: readonly ExtractOutputEvent[]): number { + let added = 0; + for (const event of events) { + if (this.add(event)) { + added += 1; + } + } + return added; + } + + public records(): ExtractOutputEvent[] { + return [...this.outputRecords.values()]; + } + + public completeFiles(): string[] { + return this.records().filter((record) => record.state === "complete").map((record) => record.outputPath); + } + + public partialFiles(): string[] { + return this.records().filter((record) => record.state === "partial").map((record) => record.outputPath); + } + + public files(): string[] { + return this.records().map((record) => record.outputPath); + } + + public archiveFiles(): string[] { + return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|001)$/i.test(filePath)); + } + + public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean { + const sourceKey = this.pathKey(sourcePath); + const current = this.outputRecords.get(sourceKey); + if (!current) { + return false; + } + const next = this.normalizeEvent({ + ...current, + outputPath: targetPath, + entryPath: path.basename(targetPath), + state: state || current.state, + disposition: targetPath === current.outputPath ? current.disposition : "renamed" + }); + this.outputRecords.delete(sourceKey); + this.outputRecords.set(this.pathKey(next.outputPath), next); + return true; + } + + public removePath(outputPath: string): boolean { + return this.outputRecords.delete(this.pathKey(outputPath)); + } + + public has(outputPath: string): boolean { + return this.outputRecords.has(this.pathKey(outputPath)); + } + + public pruneMissing(): number { + let removed = 0; + for (const record of this.records()) { + try { + const stat = fs.lstatSync(record.outputPath); + if (stat.isFile() && !stat.isSymbolicLink()) { + continue; + } + } catch { + } + if (this.removePath(record.outputPath)) { + removed += 1; + } + } + return removed; + } +} diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 334b95c..bc2b065 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -47,23 +47,76 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b fs.mkdirSync(packageDir, { recursive: true }); const zipPath = path.join(packageDir, "release.zip"); - const zip = new AdmZip(); - zip.addFile("episode.txt", Buffer.from("ok")); - zip.writeZip(zipPath); - - const result = await extractPackageArchives({ + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, - removeSamples: false - }); + removeSamples: false, + onOutput: (event) => events.push(event) + }); expect(result.extracted).toBe(1); - expect(result.failed).toBe(0); - expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); - }); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); + expect(events).toEqual([ + expect.objectContaining({ + version: 1, + archivePath: path.resolve(zipPath), + entryPath: "episode.txt", + outputPath: path.join(targetDir, "episode.txt"), + state: "complete", + disposition: "written" + }) + ]); + expect(result.outputFiles).toEqual([path.join(targetDir, "episode.txt")]); + }); + + it.each(["7zjbinding", "zip4j"])("emits versioned Base64 output lines from %s", (backend) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-output-${backend}-`)); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const zipPath = path.join(root, "release.zip"); + const zip = new AdmZip(); + zip.addFile("folder/episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm"); + const classPath = [ + path.join(runtimeRoot, "classes"), + path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"), + path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"), + path.join(runtimeRoot, "lib", "zip4j.jar") + ].join(path.delimiter); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + zipPath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + backend + ], { encoding: "utf8" }); + + expect(run.status).toBe(0); + const outputLine = String(run.stdout).split(/\r?\n/).find((line) => line.startsWith("RD_OUTPUT ")); + expect(outputLine).toBeTruthy(); + const fields = String(outputLine).split(" "); + expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]); + expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath)); + expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt"); + expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt")); + }); it("emits progress callbacks with archiveName and percent", async () => { process.env.RD_EXTRACT_BACKEND = "jvm"; diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3dec775..4989aff 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -16,7 +16,7 @@ import { selectZipFallbackError, shouldSerialRetryParallelFailures, findArchiveCandidates, - orderExtractorCandidatesForArchive, + orderExtractorCandidatesForArchive, resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, shouldFallbackLegacyRarToJvm, @@ -610,7 +610,7 @@ describe("extractor", () => { expect(selectZipFallbackError(internalError, externalError)).toBe(externalError); }); - it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => { + it.skipIf(process.platform !== "win32")("invalidates legacy basename-only resume state on Windows", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -630,8 +630,8 @@ describe("extractor", () => { removeSamples: false }); - expect(result.extracted).toBe(1); - expect(result.failed).toBe(0); + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); }); describe("disk space check", () => { @@ -1256,7 +1256,7 @@ describe("extractor", () => { }); }); - describe("orderExtractorCandidatesForArchive", () => { + describe("orderExtractorCandidatesForArchive", () => { it("prefers RAR-native CLIs over 7-Zip for rar archives", () => { const ordered = orderExtractorCandidatesForArchive( ["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"], @@ -1283,5 +1283,189 @@ describe("extractor", () => { expect(ordered[0]).toBe("WinRAR.exe"); expect(ordered[1]).toBe("UnRAR.exe"); }); - }); -}); + }); + + describe("direct output scope", () => { + it.each([ + ["overwrite", "new", "overwritten", ["episode.mkv"]], + ["ask", "old", "skipped", []], + ["rename", "old", "renamed", ["episode (1).mkv"]] + ] as const)("emits exact internal ZIP outputs for %s conflicts", async (conflictMode, originalContent, disposition, outputNames) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-direct-${conflictMode}-`)); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "episode.mkv"), "old"); + const archivePath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from("new")); + zip.writeZip(archivePath); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode, + removeLinks: false, + removeSamples: false, + onOutput: (event) => events.push(event) + }); + + expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent); + expect(events).toEqual([expect.objectContaining({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: "episode.mkv", + outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"), + state: "complete", + disposition + })]); + expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]); + }); + + it("extracts only nested archives produced by the package in a shared root", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-nested-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "shared"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + const foreign = new AdmZip(); + foreign.addFile("foreign.txt", Buffer.from("foreign")); + foreign.writeZip(path.join(targetDir, "foreign.zip")); + const nested = new AdmZip(); + nested.addFile("owned.txt", Buffer.from("owned")); + const outer = new AdmZip(); + outer.addFile("owned.zip", nested.toBuffer()); + outer.writeZip(path.join(packageDir, "outer.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(2); + expect(fs.existsSync(path.join(targetDir, "owned.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false); + }); + + it("resumes same-basename archives by relative path and invalidates changed multipart fingerprints", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-v2-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const firstDir = path.join(packageDir, "first"); + const secondDir = path.join(packageDir, "second"); + fs.mkdirSync(firstDir, { recursive: true }); + fs.mkdirSync(secondDir, { recursive: true }); + const firstArchive = path.join(firstDir, "release.zip"); + const secondArchive = path.join(secondDir, "release.zip"); + const companionPath = path.join(firstDir, "release.sfv"); + const firstZip = new AdmZip(); + firstZip.addFile("first.txt", Buffer.from("first")); + firstZip.writeZip(firstArchive); + fs.writeFileSync(companionPath, "part-a"); + const secondZip = new AdmZip(); + secondZip.addFile("second.txt", Buffer.from("second")); + secondZip.writeZip(secondArchive); + + const firstResult = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onlyArchives: new Set([path.resolve(firstArchive).toLowerCase()]) + }); + expect(firstResult.extracted).toBe(1); + expect(firstResult.failed).toBe(0); + expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(true); + fs.writeFileSync(companionPath, "part-b-changed"); + const emittedArchives: string[] = []; + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onOutput: (event) => emittedArchives.push(event.archivePath) + }); + + expect(result.extracted).toBe(2); + expect(emittedArchives).toContain(path.resolve(firstArchive)); + expect(emittedArchives).toContain(path.resolve(secondArchive)); + expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(true); + }); + + it("fails closed for an unknown resume-state version", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-unknown-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip"); + fs.writeFileSync(path.join(targetDir, "foreign.txt"), "foreign"); + fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ + version: 99, + completedArchives: ["broken.zip"] + })); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + }); + + it("retains concrete completed outputs when a later entry aborts", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const archivePath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("first.txt", Buffer.from("first")); + zip.addFile("second.txt", Buffer.from("second")); + zip.writeZip(archivePath); + const controller = new AbortController(); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + await expect(extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + signal: controller.signal, + onOutput: (event) => { + events.push(event); + controller.abort(); + } + })).rejects.toThrow("aborted:extract"); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") })); + expect(fs.existsSync(path.join(targetDir, "first.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false); + }); + + }); +}); diff --git a/tests/package-output-scope.test.ts b/tests/package-output-scope.test.ts new file mode 100644 index 0000000..8fa57d1 --- /dev/null +++ b/tests/package-output-scope.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { PackageOutputScope } from "../src/main/package-output-scope"; + +const tempDirs: string[] = []; + +function createRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-scope-")); + tempDirs.push(root); + return root; +} + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("PackageOutputScope", () => { + it("normalizes and deduplicates complete Windows paths case-insensitively", () => { + const root = createRoot(); + const outputPath = path.join(root, "Season", "Episode.mkv"); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "video"); + const scope = new PackageOutputScope([root]); + + scope.add({ + version: 1, + archivePath: path.join(root, "archive-a.rar"), + entryPath: "Season/Episode.mkv", + outputPath, + state: "complete", + disposition: "written" + }); + scope.add({ + version: 1, + archivePath: path.join(root, "archive-b.rar"), + entryPath: "season/episode.mkv", + outputPath: outputPath.toUpperCase(), + state: "complete", + disposition: "overwritten" + }); + + expect(scope.completeFiles()).toEqual([path.resolve(outputPath)]); + expect(scope.partialFiles()).toEqual([]); + expect(scope.records()).toHaveLength(1); + }); + + it.each([ + "../foreign.mkv", + "folder/../../foreign.mkv", + "/absolute.mkv", + "C:\\absolute.mkv" + ])("rejects unsafe archive entry path %s", (entryPath) => { + const root = createRoot(); + const outputPath = path.join(root, "safe.mkv"); + fs.writeFileSync(outputPath, "video"); + const scope = new PackageOutputScope([root]); + + expect(() => scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath, + outputPath, + state: "complete", + disposition: "written" + })).toThrow(/Ausgabepfad|entry/i); + }); + + it("rejects outputs outside authorized roots and unknown event versions", () => { + const root = createRoot(); + const foreignRoot = createRoot(); + const foreignPath = path.join(foreignRoot, "foreign.mkv"); + fs.writeFileSync(foreignPath, "foreign"); + const scope = new PackageOutputScope([root]); + const event = { + version: 1 as const, + archivePath: path.join(root, "archive.rar"), + entryPath: "foreign.mkv", + outputPath: foreignPath, + state: "complete" as const, + disposition: "written" as const + }; + + expect(() => scope.add(event)).toThrow(/autorisiert/i); + expect(() => scope.add({ ...event, outputPath: path.join(root, "safe.mkv"), version: 2 as 1 })).toThrow(/Version/i); + }); + + it("rejects symlinked parent chains and keeps complete and partial outputs separate", () => { + const root = createRoot(); + const foreignRoot = createRoot(); + const completePath = path.join(root, "complete.mkv"); + const partialPath = path.join(root, "partial.mkv"); + fs.writeFileSync(completePath, "complete"); + fs.writeFileSync(partialPath, "partial"); + const scope = new PackageOutputScope([root]); + + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "complete.mkv", + outputPath: completePath, + state: "complete", + disposition: "written" + }); + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "partial.mkv", + outputPath: partialPath, + state: "partial", + disposition: "written" + }); + + expect(scope.completeFiles()).toEqual([path.resolve(completePath)]); + expect(scope.partialFiles()).toEqual([path.resolve(partialPath)]); + + const link = path.join(root, "linked"); + try { + fs.symlinkSync(foreignRoot, link, "junction"); + } catch { + return; + } + const linkedOutput = path.join(link, "linked.mkv"); + fs.writeFileSync(path.join(foreignRoot, "linked.mkv"), "foreign"); + expect(() => scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "linked/linked.mkv", + outputPath: linkedOutput, + state: "complete", + disposition: "written" + })).toThrow(/symbol|reparse/i); + }); + + it("updates ownership after rename and removal without enumerating the root", () => { + const root = createRoot(); + const sourcePath = path.join(root, "source.mkv"); + const targetPath = path.join(root, "renamed.mkv"); + fs.writeFileSync(sourcePath, "video"); + const scope = new PackageOutputScope([root]); + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "source.mkv", + outputPath: sourcePath, + state: "complete", + disposition: "written" + }); + fs.renameSync(sourcePath, targetPath); + + scope.replacePath(sourcePath, targetPath); + expect(scope.completeFiles()).toEqual([path.resolve(targetPath)]); + expect(scope.removePath(targetPath)).toBe(true); + expect(scope.completeFiles()).toEqual([]); + }); +});