From 11d63dd2be8ceb6183cd84d6e22478c1da715da7 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 15:24:19 +0200 Subject: [PATCH] fix: validate extraction ownership before writes Reject symlink and reparse boundaries before internal ZIP or JVM outputs are opened, track opened, committed, partial, removed output lifecycle events, and convert output callback failures into controlled extractor failures without poisoning the JVM daemon. Gate legacy recovery behind an atomically created package-generation owner marker and keep unmarked reused or shared directories fail closed. Parse native RAR output from strictly verified locale-independent candidates while retaining ambiguous rename rejection. --- .../JBindExtractorMain$Backend.class | Bin 2310 -> 2310 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 1996 -> 1996 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 6690 -> 7032 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2059 -> 2059 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2539 bytes .../JBindExtractorMain$OutputTarget.class | Bin 884 -> 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 25394 -> 26254 bytes .../extractor/JBindExtractorMain.java | 232 ++++----- src/main/download-manager.ts | 161 ++++++- src/main/extractor.ts | 203 ++++++-- src/main/package-output-scope.ts | 49 +- src/main/storage.ts | 2 + src/shared/types.ts | 2 + tests/download-manager.test.ts | 443 ++++++++++-------- tests/extractor-jvm.test.ts | 162 ++++++- tests/extractor.test.ts | 111 ++++- tests/package-output-scope.test.ts | 23 + 21 files changed, 1010 insertions(+), 378 deletions(-) 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 e8fcab393ee02f477d9930b478fe8e527dd409a4..d2f137d1269140fa0b973d87a1c3ce084ca0dcca 100644 GIT binary patch delta 96 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 literal 6690 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 OZ=iMl_c+a6IOf04-GsFO 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 771c8feb13cc6af562246562952c1b25c9fd7d9e..aed1e17823ddd2f2c3527166ff8ba0737627c5bd 100644 GIT binary patch delta 83 zcmeAc=oZ)@%EnkcS&U5|NV>6^1IaqJ*|L=kBFt3`vdq;Cip;eP#>{mL=FANY4$O@V jF3io7&Dev0>N?qjc}p00nM)a@n9CT{nTsdCWLE+JI0X|* delta 83 zcmeAc=oZ)@%Ep*GS&U5|NV>6^1IaqJ*|Nn9BFrTWvdpCnip=E<#>^EA=FC+L4$RdI jF3h!)&Dev0>N?qjdGi=}ne!Q>m^ zF$l6`Gsv>!FsQQRG1#%>GdQypOzvT^<1A+2U@2kXV=0|{nI%@Vf`OZ*l0lfIia~^> RhC!00mcg8*Zn7(@8URD>7P9~V 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%>Yc>Hgm=82YPI$r5|q34ahcLqL5eK7RJ$Tw3zp&5@0i!hJd EKa4*XZU6uP delta 101 zcmey*^Pgvf8>?s~0}D$P11C#010PEbgD^`igCtAc&21O_vfNeq@O6B(>nrZ70NOl9z3nLb&H&55yiax9w? E08qabIsgCw 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 c3d2f41a5352c58ad5e2992ee23307e3b1ddf37a..6c93e304d7198b3225a01f05c1f4307de6c7fe16 100644 GIT binary patch delta 211 zcmV;^04)F09Mc@IT?!Sh00acA01pJO02&0a03rmj05AlylX41V3AO+S1h)VT1i6#n z3a>4`00soV02l2ClRgY80o;>m45&Te01gD<01yP^02~D603!tE05AmS05=5a067He073-o z07nGu0AU2}0BHp70Bi*C0C)uP0DA=N0D%PdlTHn*8T$Yt1pNRe1pfdj1pWXj1pojo N1pxpy1p<>24nZq5JXin# delta 211 zcmV;^04)F09Mc@IT?!ST00ab{01pJA02&0M03rmV05AlklX41V38(-E1gQWF1gn$Z z3a>4&00soH02l1}lRgY80nU?Y45&TQ01gDx01yP$02~C@03!t005AmE05=5M067HQ073-a z07nGg0AU2*0BHo^0Bi)}0C)uB0DA=90D%PPlTHn*8SMZf1n&SQ1n~eV1n>YV1o8ka N1oHqk1oV><4naKHJi`D0 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 c2bc8268d3bc6daa81cea7a082eff78082fcbb7f..2aee3aef1db0831a8ac1b7041e11ff7e2cd3c746 100644 GIT binary patch delta 17 YcmX@be2RI)YDU%&23D5P$r~7@05mKG3;+NC delta 17 YcmX@be2RI)YDQK!238jL$r~7@05fO=@Bjb+ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 894704ba9c329e8fcd2ebec24b607a549afe5dcf..265e1ccec4e8fbd9aec2c080920be60beeff4c29 100644 GIT binary patch literal 26254 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 iOr85Utk1{ld^X2PbnTA@W+ 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 a-HK-sp10u{;iKB^bA7l_Q>@)VN&g3SS0nrY diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index a49f0f4..8761ec6 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -27,8 +27,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; @@ -266,10 +269,11 @@ public final class JBindExtractorMain { } String entryName = normalizeEntryName(header.getFileName(), "file"); - if (header.isDirectory()) { - File dir = resolveDirectory(request.targetDir, entryName); - ensureDirectory(dir); - reserved.add(pathKey(dir)); + if (header.isDirectory()) { + File dir = resolveDirectory(request.targetDir, entryName); + ensureDirectory(dir); + rejectLinkedPath(request.targetDir, dir); + reserved.add(pathKey(dir)); continue; } @@ -282,8 +286,10 @@ public final class JBindExtractorMain { continue; } - ensureDirectory(output.getParentFile()); - rejectSymlink(output); + rejectLinkedPath(request.targetDir, output); + emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition); + ensureDirectory(output.getParentFile()); + rejectLinkedPath(request.targetDir, output); long[] remaining = new long[] { itemUnits }; boolean extractionSuccess = false; try { @@ -335,12 +341,9 @@ public final class JBindExtractorMain { if (!extractionSuccess && output.exists()) { emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition); } - if (!extractionSuccess && output.exists()) { - try { - output.delete(); - } catch (Throwable ignored) { - } - } + if (!extractionSuccess && output.exists() && output.delete()) { + emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition); + } } } @@ -386,9 +389,10 @@ public final class JBindExtractorMain { String entryPath = (String) archive.getProperty(i, PropID.PATH); String entryName = normalizeEntryName(entryPath, "item-" + i); - if (Boolean.TRUE.equals(isFolder)) { - File dir = resolveDirectory(request.targetDir, entryName); - ensureDirectory(dir); + if (Boolean.TRUE.equals(isFolder)) { + File dir = resolveDirectory(request.targetDir, entryName); + ensureDirectory(dir); + rejectLinkedPath(request.targetDir, dir); reserved.add(pathKey(dir)); continue; } @@ -446,18 +450,21 @@ public final class JBindExtractorMain { final Throwable[] firstError = new Throwable[1]; final int[] currentPos = new int[] { -1 }; - try { - archive.extract(indices, false, new BulkExtractCallback( - archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, - progress, encryptedFinal, effectivePassword, currentOutput, - currentStream, currentSuccess, currentRemaining, currentPos, firstError - )); - } catch (SevenZipException error) { - if (looksLikeWrongPassword(error, encryptedFinal)) { - throw new WrongPasswordException(error); - } - throw error; - } + BulkExtractCallback extractCallback = new BulkExtractCallback( + archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, + progress, encryptedFinal, effectivePassword, currentOutput, + currentStream, currentSuccess, currentRemaining, currentPos, firstError + ); + try { + archive.extract(indices, false, extractCallback); + } catch (SevenZipException error) { + if (looksLikeWrongPassword(error, encryptedFinal)) { + throw new WrongPasswordException(error); + } + throw error; + } finally { + extractCallback.finishCurrentOutput(); + } if (firstError[0] != null) { if (firstError[0] instanceof WrongPasswordException) { @@ -575,13 +582,15 @@ public final class JBindExtractorMain { return false; } - private static File resolveDirectory(File targetDir, String entryName) throws IOException { - File directory = secureResolve(targetDir, entryName); - return directory; - } + private static File resolveDirectory(File targetDir, String entryName) throws IOException { + File directory = secureResolve(targetDir, entryName); + rejectLinkedPath(targetDir, directory); + return directory; + } private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { File base = secureResolve(targetDir, entryName); + rejectLinkedPath(targetDir, base); String key = pathKey(base); boolean exists = base.exists() || reserved.contains(key); @@ -594,9 +603,11 @@ public final class JBindExtractorMain { return new OutputTarget(null, base, "skipped"); } - if (conflictMode == ConflictMode.OVERWRITE) { - if (base.exists()) { - deleteRecursively(base); + if (conflictMode == ConflictMode.OVERWRITE) { + if (base.exists()) { + if (!base.isFile() || !base.delete()) { + throw new IOException("Konnte Datei nicht uberschreiben: " + base.getAbsolutePath()); + } } reserved.add(key); return new OutputTarget(base, base, "overwritten"); @@ -610,8 +621,9 @@ public final class JBindExtractorMain { int counter = 1; while (counter <= 10000) { - String candidateName = stem + " (" + counter + ")" + ext; - File candidate = new File(parent, candidateName); + String candidateName = stem + " (" + counter + ")" + ext; + File candidate = new File(parent, candidateName); + rejectLinkedPath(targetDir, candidate); String candidateKey = pathKey(candidate); if (!candidate.exists() && !reserved.contains(candidateKey)) { reserved.add(candidateKey); @@ -623,23 +635,6 @@ public final class JBindExtractorMain { throw new IOException("Rename-Limit erreicht fur " + entryName); } - private static void deleteRecursively(File file) throws IOException { - if (file == null || !file.exists()) { - return; - } - if (file.isDirectory()) { - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - deleteRecursively(child); - } - } - } - if (!file.delete()) { - throw new IOException("Konnte Datei nicht uberschreiben: " + file.getAbsolutePath()); - } - } - private static File secureResolve(File targetDir, String entryName) throws IOException { String normalized = normalizeEntryName(entryName, "file"); while (normalized.startsWith("/")) { @@ -657,18 +652,20 @@ public final class JBindExtractorMain { normalized = normalized.substring(1); } } - File targetCanonical = targetDir.getCanonicalFile(); - File output = new File(targetCanonical, normalized); - File outputCanonical = output.getCanonicalFile(); - String targetPath = targetCanonical.getPath(); - String outputPath = outputCanonical.getPath(); + File targetCanonical = targetDir.getCanonicalFile(); + Path targetPathValue = targetCanonical.toPath().toAbsolutePath().normalize(); + Path outputPathValue = targetPathValue.resolve(normalized).normalize(); + String targetPath = targetPathValue.toString(); + String outputPath = outputPathValue.toString(); String targetPathNorm = isWindows() ? targetPath.toLowerCase(Locale.ROOT) : targetPath; String outputPathNorm = isWindows() ? outputPath.toLowerCase(Locale.ROOT) : outputPath; String targetPrefix = targetPathNorm.endsWith(File.separator) ? targetPathNorm : targetPathNorm + File.separator; if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPrefix)) { throw new IOException("Path Traversal blockiert: " + entryName); } - return outputCanonical; + File output = outputPathValue.toFile(); + rejectLinkedPath(targetCanonical, output); + return output; } private static String normalizeEntryName(String value, String fallback) { @@ -710,22 +707,31 @@ public final class JBindExtractorMain { return size; } - private static void rejectSymlink(File file) throws IOException { - if (file == null) { - return; - } - if (Files.isSymbolicLink(file.toPath())) { - throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath()); - } - - File parent = file.getParentFile(); - while (parent != null) { - if (Files.isSymbolicLink(parent.toPath())) { - throw new IOException("Elternverzeichnis ist ein Symlink, Schreiben verweigert: " + parent.getAbsolutePath()); - } - parent = parent.getParentFile(); - } - } + private static void rejectLinkedPath(File targetDir, File file) throws IOException { + if (targetDir == null || file == null) { + return; + } + Path root = targetDir.getCanonicalFile().toPath().toAbsolutePath().normalize(); + Path current = file.toPath().toAbsolutePath().normalize(); + String rootValue = isWindows() ? root.toString().toLowerCase(Locale.ROOT) : root.toString(); + while (current != null) { + String currentValue = isWindows() ? current.toString().toLowerCase(Locale.ROOT) : current.toString(); + String prefix = rootValue.endsWith(File.separator) ? rootValue : rootValue + File.separator; + if (!currentValue.equals(rootValue) && !currentValue.startsWith(prefix)) { + throw new IOException("Path Traversal blockiert: " + file.getAbsolutePath()); + } + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes attributes = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() || attributes.isOther()) { + throw new IOException("Symlink oder Reparse Point blockiert: " + current.toString()); + } + } + if (currentValue.equals(rootValue)) { + break; + } + current = current.getParent(); + } + } private static void ensureDirectory(File dir) throws IOException { if (dir == null) { @@ -917,6 +923,7 @@ public final class JBindExtractorMain { private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { private final IInArchive archive; private final File archiveFile; + private final File targetDir; private final Map indexToPos; private final List fileIndices; private final List outputFiles; @@ -933,7 +940,7 @@ public final class JBindExtractorMain { private final int[] currentPos; private final Throwable[] firstError; - BulkExtractCallback(IInArchive archive, File archiveFile, Map indexToPos, + 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, @@ -942,6 +949,7 @@ public final class JBindExtractorMain { Throwable[] firstError) { this.archive = archive; this.archiveFile = archiveFile; + this.targetDir = targetDir; this.indexToPos = indexToPos; this.fileIndices = fileIndices; this.outputFiles = outputFiles; @@ -974,9 +982,9 @@ public final class JBindExtractorMain { } - @Override - public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException { - closeCurrentStream(); + @Override + public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException { + discardCurrentOutput(); Integer pos = indexToPos.get(index); if (pos == null) { @@ -995,12 +1003,14 @@ public final class JBindExtractorMain { if (currentOutput[0] == null) { progress.advance(currentRemaining[0]); return null; - } - - try { - ensureDirectory(currentOutput[0].getParentFile()); - rejectSymlink(currentOutput[0]); - currentStream[0] = new FileOutputStream(currentOutput[0]); + } + + try { + rejectLinkedPath(targetDir, currentOutput[0]); + emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "opened", dispositions.get(currentPos[0])); + ensureDirectory(currentOutput[0].getParentFile()); + rejectLinkedPath(targetDir, currentOutput[0]); + currentStream[0] = new FileOutputStream(currentOutput[0]); } catch (IOException error) { throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error); } @@ -1036,9 +1046,9 @@ public final class JBindExtractorMain { currentRemaining[0] = 0; } - if (result == ExtractOperationResult.OK) { - currentSuccess[0] = true; - closeCurrentStream(); + if (result == ExtractOperationResult.OK) { + currentSuccess[0] = true; + closeCurrentStreamOnly(); if (currentPos[0] >= 0 && currentOutput[0] != null) { try { int archiveIndex = fileIndices.get(currentPos[0]); @@ -1052,14 +1062,7 @@ public final class JBindExtractorMain { 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) { - } - } + discardCurrentOutput(); if (firstError[0] == null) { if (isPasswordFailure(result, encrypted)) { firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort")); @@ -1070,20 +1073,31 @@ public final class JBindExtractorMain { } } - private void closeCurrentStream() { - if (currentStream[0] != null) { + void finishCurrentOutput() { + discardCurrentOutput(); + } + + private void closeCurrentStreamOnly() { + if (currentStream[0] != null) { try { currentStream[0].close(); } catch (Throwable ignored) { - } - currentStream[0] = null; - } - if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) { - try { - currentOutput[0].delete(); - } catch (Throwable ignored) { - } - } + } + currentStream[0] = null; + } + } + + private void discardCurrentOutput() { + closeCurrentStreamOnly(); + if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) { + int pos = currentPos[0]; + if (pos >= 0) { + emitOutput(archiveFile, entryNames.get(pos), currentOutput[0], "partial", dispositions.get(pos)); + } + if (currentOutput[0].delete() && pos >= 0) { + emitOutput(archiveFile, entryNames.get(pos), currentOutput[0], "removed", dispositions.get(pos)); + } + } } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 5785da6..87b8bc9 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -480,6 +480,15 @@ type DownloadManagerOptions = { protectEmptyClobber?: boolean; }; +type PackageOutputOwnerMarker = { + version: 1; + packageId: string; + generation: number; + ownerId: string; +}; + +const PACKAGE_OUTPUT_OWNER_MARKER = ".rd-package-output-owner-v1.json"; + type RunLifecycleContext = { id: string; startedAt: number; @@ -4429,19 +4438,139 @@ export class DownloadManager extends EventEmitter { return scope; } + private packageOutputOwnerMarkerPath(pkg: PackageEntry): string { + return path.join(pkg.extractDir, PACKAGE_OUTPUT_OWNER_MARKER); + } + + private async readPackageOutputOwnerMarker(pkg: PackageEntry, requireSessionMatch: boolean): Promise { + const markerPath = this.packageOutputOwnerMarkerPath(pkg); + try { + const stat = await fs.promises.lstat(markerPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + return null; + } + const raw = JSON.parse(await fs.promises.readFile(markerPath, "utf8")) as Partial; + const marker: PackageOutputOwnerMarker = { + version: 1, + packageId: String(raw.packageId || ""), + generation: Math.max(0, Math.floor(Number(raw.generation) || 0)), + ownerId: String(raw.ownerId || "").toLowerCase() + }; + if (raw.version !== 1 + || marker.packageId !== pkg.id + || marker.generation < 1 + || !/^[a-f0-9-]{36}$/.test(marker.ownerId)) { + return null; + } + if (requireSessionMatch + && (marker.ownerId !== String(pkg.outputOwnerId || "").toLowerCase() + || marker.generation !== Number(pkg.outputOwnerGeneration || 0) + || marker.generation !== this.getPackageResultGeneration(pkg.id))) { + return null; + } + return marker; + } catch { + return null; + } + } + + private async writePackageOutputOwnerMarkerAtomic(pkg: PackageEntry, marker: PackageOutputOwnerMarker): Promise { + const markerPath = this.packageOutputOwnerMarkerPath(pkg); + const tempPath = path.join(pkg.extractDir, `.${PACKAGE_OUTPUT_OWNER_MARKER}.${uuidv4()}.tmp`); + const handle = await fs.promises.open(tempPath, "wx"); + try { + await handle.writeFile(JSON.stringify(marker), "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.promises.link(tempPath, markerPath); + await fs.promises.rm(tempPath, { force: true }); + } catch (error) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } + } + + private async ensurePackageOutputOwnerMarker(pkg: PackageEntry): Promise { + if (!this.isPackageSpecificExtractDir(pkg) + || this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir)) { + return false; + } + await fs.promises.mkdir(pkg.extractDir, { recursive: true }); + new PackageOutputScope([pkg.extractDir]).validateTarget(PACKAGE_OUTPUT_OWNER_MARKER, this.packageOutputOwnerMarkerPath(pkg)); + const current = await this.readPackageOutputOwnerMarker(pkg, true); + if (current) { + return true; + } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(pkg.extractDir, { withFileTypes: true }); + } catch { + return false; + } + const rawExistingMarker = await this.readPackageOutputOwnerMarker(pkg, false); + const nonMarkerEntries = entries.filter((entry) => entry.name !== PACKAGE_OUTPUT_OWNER_MARKER); + if (nonMarkerEntries.length > 0) { + return false; + } + if (entries.some((entry) => entry.name === PACKAGE_OUTPUT_OWNER_MARKER)) { + if (!rawExistingMarker || rawExistingMarker.packageId !== pkg.id) { + return false; + } + await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true }); + } + const marker: PackageOutputOwnerMarker = { + version: 1, + packageId: pkg.id, + generation: this.getPackageResultGeneration(pkg.id), + ownerId: uuidv4().toLowerCase() + }; + await this.writePackageOutputOwnerMarkerAtomic(pkg, marker); + pkg.outputOwnerId = marker.ownerId; + pkg.outputOwnerGeneration = marker.generation; + if (this.session.packages[pkg.id] === pkg) { + try { + await saveSessionAsync(this.storagePaths, this.session); + } catch (error) { + pkg.outputOwnerId = ""; + pkg.outputOwnerGeneration = 0; + await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true }).catch(() => {}); + throw error; + } + } + return true; + } + + private async removePackageOutputOwnerMarker(pkg: PackageEntry): Promise { + if (!await this.readPackageOutputOwnerMarker(pkg, true)) { + return false; + } + try { + await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true }); + pkg.outputOwnerId = ""; + pkg.outputOwnerGeneration = 0; + return true; + } catch { + return false; + } + } + private async adoptLegacyPackageOutputsIfExclusive(pkg: PackageEntry, scope: PackageOutputScope): Promise { - if (pkg.outputScopeAdopted || scope.records().length > 0) { + if (scope.records().length > 0) { pkg.outputScopeAdopted = true; return; } + if (pkg.outputScopeAdopted) { + return; + } pkg.outputScopeAdopted = true; if (pkg.outputProvenanceVersion !== undefined && pkg.outputProvenanceVersion !== PACKAGE_OUTPUT_PROVENANCE_VERSION) { return; } - const packageExclusive = (this.settings.createExtractSubfolder || this.isPackageSpecificExtractDir(pkg)) - && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir); - if (!packageExclusive || !await this.existsAsync(pkg.extractDir)) { + if (!await this.readPackageOutputOwnerMarker(pkg, true)) { return; } const candidates: string[] = []; @@ -4473,6 +4602,8 @@ export class DownloadManager extends EventEmitter { } else if (entry.isFile() && !/^\.rd-(?:output|replace)-/i.test(entry.name) && !/^\.rd_extract_progress(?:_[^.]+)?\.json$/i.test(entry.name) + && entry.name !== PACKAGE_OUTPUT_OWNER_MARKER + && !entry.name.startsWith(`.${PACKAGE_OUTPUT_OWNER_MARKER}.`) && !isIgnorableEmptyDirFileName(entry.name)) { candidates.push(fullPath); } @@ -4507,10 +4638,19 @@ export class DownloadManager extends EventEmitter { const scope = this.getPackageOutputScope(pkg); try { await fs.promises.mkdir(pkg.extractDir, { recursive: true }); + await this.ensurePackageOutputOwnerMarker(pkg); return await operation(pkg.extractDir, scope); } finally { if (!packageWasInSession || this.session.packages[pkg.id] === pkg) { this.syncPackageOutputScope(pkg, scope); + if (scope.records().length === 0 && await this.removePackageOutputOwnerMarker(pkg)) { + try { + if ((await fs.promises.readdir(pkg.extractDir)).length === 0) { + await fs.promises.rmdir(pkg.extractDir); + } + } catch { + } + } } } } @@ -6138,9 +6278,12 @@ export class DownloadManager extends EventEmitter { if ((sourceArtifactsChanged || sourceCleanupRelevant) && cleanupDir && await this.existsAsync(cleanupDir)) { const removedResidual = await this.cleanupNonMkvResidualFiles(scope, targetDir, touchedParents); - if (removedResidual > 0) { - logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`); - } + if (removedResidual > 0) { + logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`); + } + if (!scope.files().some((filePath) => isPathInsideDir(filePath, cleanupDir))) { + await this.removePackageOutputOwnerMarker(pkg); + } const removedDirs = await this.removeEmptyScopedParentChains(cleanupDir, touchedParents); if (removedDirs > 0) { logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedDirs}`); @@ -9096,6 +9239,8 @@ export class DownloadManager extends EventEmitter { pkg.outputProvenance = []; pkg.outputRecords = []; pkg.outputScopeAdopted = false; + pkg.outputOwnerId = ""; + pkg.outputOwnerGeneration = 0; } for (const itemId of itemIds) { this.retryAfterByItem.delete(itemId); @@ -12411,6 +12556,8 @@ export class DownloadManager extends EventEmitter { pkg.outputProvenance = []; pkg.outputRecords = []; pkg.outputScopeAdopted = false; + pkg.outputOwnerId = ""; + pkg.outputOwnerGeneration = 0; this.packageOutputScopes.delete(packageId); pkg.cleanupErrorCategory = ""; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 31b6542..3debcee 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -119,6 +119,13 @@ export class ExtractionError extends Error { this.name = "ExtractionError"; } } + +class ExtractionOutputCallbackError extends Error { + public constructor(error: unknown) { + super(`extract_output_callback_failed: ${cleanErrorText(String(error))}`); + this.name = "ExtractionOutputCallbackError"; + } +} type ExtractionErrorWithHints = Error & { suggestRedownload?: boolean; @@ -146,6 +153,15 @@ type JvmExtractResult = { backend: string; }; +type JvmParseState = { + bestPercent: number; + usedPassword: string; + backend: string; + reportedError: string; + outputError?: Error; + openedOutputs?: Map; +}; + export interface ExtractResult { extracted: number; failed: number; @@ -193,7 +209,7 @@ interface DaemonRequest { onArchiveProgress?: (percent: number) => void; signal?: AbortSignal; timeoutMs?: number; - parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; + parseState: JvmParseState; archiveName: string; startedAt: number; passwordCount: number; @@ -1556,7 +1572,7 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null { function parseJvmLine( line: string, onArchiveProgress: ((percent: number) => void) | undefined, - state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }, + state: JvmParseState, onOutput?: (event: ExtractOutputEvent) => void ): void { const trimmed = String(line || "").trim(); @@ -1597,18 +1613,33 @@ function parseJvmLine( const disposition = fields[3]; if (fields.length !== 7 || fields[1] !== "1" - || (stateValue !== "complete" && stateValue !== "partial") + || !(["opened", "complete", "partial", "removed"] as const).includes(stateValue as ExtractOutputEvent["state"]) || !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) { return; } - onOutput?.({ + const event: ExtractOutputEvent = { 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, + state: stateValue as ExtractOutputEvent["state"], disposition: disposition as ExtractOutputEvent["disposition"] - }); + }; + const outputKey = pathSetKey(path.resolve(event.outputPath)); + state.openedOutputs ||= new Map(); + if (event.state === "opened") { + state.openedOutputs.set(outputKey, event); + } else if (event.state === "complete" || event.state === "removed") { + state.openedOutputs.delete(outputKey); + } + if (!state.outputError) { + try { + onOutput?.(event); + } catch (error) { + state.outputError = error instanceof Error ? error : new Error(String(error)); + state.reportedError = state.outputError.message; + } + } return; } @@ -1661,7 +1692,7 @@ function finishDaemonRequest(result: JvmExtractResult): void { req.resolve(result); } -function flushDaemonParseBuffers(req: DaemonRequest | null): void { +function flushDaemonParseBuffers(req: DaemonRequest | null): void { if (!req) { return; } @@ -1693,7 +1724,11 @@ function handleDaemonLine(line: string): void { if (daemonCurrentRequest !== req) { return; } - flushDaemonParseBuffers(req); + flushDaemonParseBuffers(req); + if (req.parseState.outputError) { + failDaemonOutputCallback(req); + return; + } const elapsedMs = Date.now() - req.startedAt; logger.info( `JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` + @@ -1727,9 +1762,11 @@ function handleDaemonLine(line: string): void { return; } - if (daemonCurrentRequest) { - parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); - } + if (daemonCurrentRequest) { + const req = daemonCurrentRequest; + parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput); + failDaemonOutputCallback(req); + } } function startDaemon(layout: JvmExtractorLayout): boolean { @@ -1780,9 +1817,11 @@ function startDaemon(layout: JvmExtractorLayout): boolean { const lines = daemonStderrBuffer.split(/\r?\n/); daemonStderrBuffer = lines.pop() || ""; for (const line of lines) { - if (daemonCurrentRequest) { - parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); - } + if (daemonCurrentRequest) { + const req = daemonCurrentRequest; + parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput); + failDaemonOutputCallback(req); + } } }); @@ -1999,9 +2038,10 @@ async function runJvmExtractCommand( let timedOutByWatchdog = false; let abortedBySignal = false; let onAbort: (() => void) | null = null; - const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; - let stdoutBuffer = ""; - let stderrBuffer = ""; + const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; + let stdoutBuffer = ""; + let stderrBuffer = ""; + let outputCallbackKillStarted = false; const child = spawn(layout.javaCommand, args, { windowsHide: true }); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); @@ -2014,9 +2054,13 @@ async function runJvmExtractCommand( const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; const lines = nextBuffer.split(/\r?\n/); const keep = lines.pop() || ""; - for (const line of lines) { + for (const line of lines) { parseJvmLine(line, onArchiveProgress, parseState, onOutput); - } + } + if (parseState.outputError && !outputCallbackKillStarted) { + outputCallbackKillStarted = true; + killProcessTree(child); + } if (fromStdErr) { stderrBuffer = keep; } else { @@ -2101,17 +2145,31 @@ async function runJvmExtractCommand( }); return; } - if (timedOutByWatchdog) { + if (timedOutByWatchdog) { finish({ ok: false, missingCommand: false, missingRuntime: false, aborted: false, timedOut: true, errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`, usedPassword: parseState.usedPassword, backend: parseState.backend }); - return; - } - - const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`; + return; + } + + if (parseState.outputError) { + finish({ + ok: false, + missingCommand: false, + missingRuntime: false, + aborted: false, + timedOut: false, + errorText: cleanErrorText(parseState.outputError.message || String(parseState.outputError)), + usedPassword: parseState.usedPassword, + backend: parseState.backend + }); + return; + } + + const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`; if (code === 0) { onArchiveProgress?.(100); finish({ @@ -2171,8 +2229,9 @@ export function parseNativeExtractOutput( const match = trimmed.match(/^[-+]\s+(.+)$/); reportedPath = match?.[1]?.trim() || ""; } else if (isRarNativeCommand(command)) { - const match = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i); - reportedPath = match?.[1]?.trim() || ""; + const localizedMatch = trimmed.match(/^.+?\s{2,}(.+?)\s{2,}OK$/); + const legacyMatch = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i); + reportedPath = localizedMatch?.[1]?.trim() || legacyMatch?.[1]?.trim() || ""; } if (!reportedPath) { return []; @@ -2212,6 +2271,24 @@ export function parseNativeExtractOutput( } } +function failDaemonOutputCallback(req: DaemonRequest): void { + if (daemonCurrentRequest !== req || !req.parseState.outputError) { + return; + } + const message = cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError)); + finishDaemonRequest({ + ok: false, + missingCommand: false, + missingRuntime: false, + aborted: false, + timedOut: false, + errorText: message, + usedPassword: req.parseState.usedPassword, + backend: req.parseState.backend + }); + shutdownDaemon(); +} + function createNativeOutputCollector( command: string, archivePath: string, @@ -2224,7 +2301,7 @@ function createNativeOutputCollector( const collectLine = (value: string): void => { const trimmed = value.trim(); if ((extractorCommandKind(command) === "seven_zip" && /^[-+]\s+/.test(trimmed)) - || (isRarNativeCommand(command) && /^Extracting\s+/i.test(trimmed))) { + || (isRarNativeCommand(command) && (/^.+?\s{2,}.+?\s{2,}OK$/.test(trimmed) || /^Extracting\s+/i.test(trimmed)))) { lines.add(trimmed); } }; @@ -2697,9 +2774,12 @@ async function runExternalExtract( function isZipSafetyGuardError(error: unknown): boolean { const text = String(error || "").toLowerCase(); - return text.includes("path traversal") - || text.includes("zip-eintrag verdächtig groß") - || text.includes("zip-eintrag verdaechtig gross"); + return text.includes("path traversal") + || text.includes("zip-eintrag verdächtig groß") + || text.includes("zip-eintrag verdaechtig gross") + || text.includes("symbolischer link") + || text.includes("reparse point") + || text.includes("extract_output_callback_failed"); } function isZipInternalLimitError(error: unknown): boolean { @@ -2736,7 +2816,8 @@ async function extractZipArchive( targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal, - onOutput?: (event: ExtractOutputEvent) => void + onOutput?: (event: ExtractOutputEvent) => void, + validateTarget?: (entryPath: string, outputPath: string) => void ): Promise { const mode = effectiveConflictMode(conflictMode); const memoryLimitBytes = zipEntryMemoryLimitBytes(); @@ -2755,9 +2836,11 @@ async function extractZipArchive( logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); continue; } - if (entry.isDirectory) { - await fs.promises.mkdir(baseOutputPath, { recursive: true }); - continue; + if (entry.isDirectory) { + validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath); + await fs.promises.mkdir(baseOutputPath, { recursive: true }); + validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath); + continue; } const header = (entry as unknown as { @@ -2797,7 +2880,6 @@ async function extractZipArchive( 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") { @@ -2840,10 +2922,22 @@ async function extractZipArchive( } } - if (signal?.aborted) { - throw new Error("aborted:extract"); - } - const data = entry.getData(); + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + const normalizedEntryPath = entry.entryName.replace(/\\/g, "/"); + validateTarget?.(normalizedEntryPath, outputPath); + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: normalizedEntryPath, + outputPath, + state: "opened", + disposition + }); + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + validateTarget?.(normalizedEntryPath, outputPath); + const data = entry.getData(); if (data.length > memoryLimitBytes) { const entryMb = Math.ceil(data.length / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); @@ -2856,14 +2950,6 @@ async function extractZipArchive( 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?.({ @@ -2877,6 +2963,14 @@ async function extractZipArchive( } throw error; } + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: normalizedEntryPath, + outputPath, + state: "complete", + disposition + }); } } @@ -3149,7 +3243,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { outputScope.add(event); - options.onOutput?.(event); + try { + options.onOutput?.(event); + } catch (error) { + throw new ExtractionOutputCallbackError(error); + } + }; + const validateOutputTarget = (entryPath: string, outputPath: string): void => { + outputScope.validateTarget(entryPath, outputPath); }; options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" }); const allCandidates = await findArchiveCandidates(options.packageDir); @@ -3428,14 +3529,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise(); + private readonly outputRecords = new Map(); public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) { this.authorizedRoots = [...new Map( @@ -94,20 +96,14 @@ export class PackageOutputScope { if (!path.isAbsolute(String(event.archivePath || ""))) { throw new Error(`Archivpfad muss absolut sein: ${event.archivePath}`); } - if (event.state !== "complete" && event.state !== "partial") { + if (!(["opened", "complete", "partial", "removed"] as const).includes(event.state)) { 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") { + const { entryPath, outputPath } = this.validateTarget(event.entryPath, event.outputPath); + if (event.disposition !== "skipped" && event.state !== "opened" && event.state !== "removed") { let stat: fs.Stats; try { stat = fs.lstatSync(outputPath); @@ -128,20 +124,35 @@ export class PackageOutputScope { }; } + public validateTarget(entryPath: string, outputPath: string): { entryPath: string; outputPath: string } { + const normalizedEntryPath = this.validateEntryPath(entryPath); + if (!path.isAbsolute(String(outputPath || ""))) { + throw new Error(`Finaler Ausgabepfad muss absolut sein: ${outputPath}`); + } + const normalizedOutputPath = path.resolve(outputPath); + const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath); + this.rejectLinkedPath(normalizedOutputPath, authorizedRoot); + return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath }; + } + public add(event: ExtractOutputEvent): boolean { const normalized = this.normalizeEvent(event); - if (normalized.disposition === "skipped") { + const key = this.pathKey(normalized.outputPath); + if (normalized.state === "removed") { + return this.outputRecords.delete(key); + } + if (normalized.disposition === "skipped" || normalized.state === "opened") { return false; } - const key = this.pathKey(normalized.outputPath); + const owned = normalized as OwnedExtractOutputEvent; const current = this.outputRecords.get(key); if (current) { - if (current.state === "partial" && normalized.state === "complete") { - this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath }); + if (current.state === "partial" && owned.state === "complete") { + this.outputRecords.set(key, { ...owned, outputPath: current.outputPath }); } return false; } - this.outputRecords.set(key, normalized); + this.outputRecords.set(key, owned); return true; } @@ -155,7 +166,7 @@ export class PackageOutputScope { return added; } - public records(): ExtractOutputEvent[] { + public records(): OwnedExtractOutputEvent[] { return [...this.outputRecords.values()]; } @@ -175,7 +186,7 @@ export class PackageOutputScope { return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|tgz|tbz2|txz|001)$/i.test(filePath)); } - public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean { + public replacePath(sourcePath: string, targetPath: string, state?: OwnedExtractOutputEvent["state"]): boolean { const sourceKey = this.pathKey(sourcePath); const current = this.outputRecords.get(sourceKey); if (!current) { @@ -187,7 +198,7 @@ export class PackageOutputScope { entryPath: path.basename(targetPath), state: state || current.state, disposition: targetPath === current.outputPath ? current.disposition : "renamed" - }); + }) as OwnedExtractOutputEvent; this.outputRecords.delete(sourceKey); this.outputRecords.set(this.pathKey(next.outputPath), next); return true; diff --git a/src/main/storage.ts b/src/main/storage.ts index c94c391..f50084e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1063,6 +1063,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState { outputProvenance, outputRecords, outputScopeAdopted: Boolean(pkg.outputScopeAdopted), + outputOwnerId: /^[a-f0-9-]{36}$/i.test(asText(pkg.outputOwnerId)) ? asText(pkg.outputOwnerId).toLowerCase() : "", + outputOwnerGeneration: clampNumber(pkg.outputOwnerGeneration, 0, 0, Number.MAX_SAFE_INTEGER), cleanupErrorCategory: asText(pkg.cleanupErrorCategory), resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), diff --git a/src/shared/types.ts b/src/shared/types.ts index be4f4a1..fff9288 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -602,6 +602,8 @@ export interface PackageEntry { outputProvenance?: string[]; outputRecords?: PackageOutputRecord[]; outputScopeAdopted?: boolean; + outputOwnerId?: string; + outputOwnerGeneration?: number; cleanupErrorCategory?: string; resultGeneration?: number; createdAt: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 7281774..80ca676 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -25,9 +25,23 @@ import { resetVideoToolingCache } from "../src/main/video-processor"; import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor"; import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; -const tempDirs: string[] = []; +const tempDirs: string[] = []; const originalFetch = globalThis.fetch; +function writePackageOutputOwnerMarker(pkg: PackageEntry): void { + const ownerId = crypto.randomUUID().toLowerCase(); + const generation = Math.max(1, Number(pkg.resultGeneration || 1)); + pkg.outputOwnerId = ownerId; + pkg.outputOwnerGeneration = generation; + fs.mkdirSync(pkg.extractDir, { recursive: true }); + fs.writeFileSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ + version: 1, + packageId: pkg.id, + generation, + ownerId + })); +} + describe("runWithLimitedConcurrency", () => { it("processes the full batch without exceeding the configured worker count", async () => { let active = 0; @@ -6760,7 +6774,6 @@ describe("download manager", () => { createdAt, updatedAt: createdAt }; - const manager = new DownloadManager( { ...defaultSettings(), @@ -11596,17 +11609,18 @@ describe("download manager", () => { downloadedBytes: 123, totalBytes: 123, progressPercent: 100, - fileName: "missing-source-ok.part01.rar", + fileName: "missing-source-ok.part01.rar", targetPath: path.join(outputDir, "missing-source-ok.part01.rar"), resumable: true, attempts: 1, lastError: "", fullStatus: "Fertig (123 B)", createdAt, - updatedAt: createdAt - }; - - const manager = new DownloadManager( + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", @@ -11715,9 +11729,10 @@ describe("download manager", () => { fs.writeFileSync(path.join(extractDir, "Season 1", "Episode01.mkv"), "video", "utf8"); fs.writeFileSync(path.join(extractDir, "Season 1", "episode.links.txt"), "https://example.com/file", "utf8"); fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "sample.mkv"), "sample-video", "utf8"); - fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8"); - - const mkvLibraryDir = path.join(root, "mkv-library"); + fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8"); + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -11764,9 +11779,10 @@ describe("download manager", () => { } = createCompletedArchiveSession(root, packageName, sourceFileName); session.packages[packageId].status = "completed"; - session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; - fs.mkdirSync(extractDir, { recursive: true }); - fs.writeFileSync(originalExtractedPath, "video", "utf8"); + session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(originalExtractedPath, "video", "utf8"); + writePackageOutputOwnerMarker(session.packages[packageId]); const manager = new DownloadManager( { @@ -12664,20 +12680,21 @@ describe("download manager", () => { const packageId = `${packageName}-pkg`; const createdAt = Date.now() - 20_000; session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "downloading", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "downloading", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -12718,28 +12735,29 @@ describe("download manager", () => { const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV"; const epDir = path.join(extractDir, episodeFolder); fs.mkdirSync(epDir, { recursive: true }); - const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; - const rawPath = path.join(epDir, rawName); - fs.writeFileSync(rawPath, Buffer.alloc(4096, 7)); - - const session = emptySession(); - const packageId = `${packageName}-pkg`; - const createdAt = Date.now() - 60_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "completed", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; + const rawPath = path.join(epDir, rawName); + fs.writeFileSync(rawPath, Buffer.alloc(4096, 7)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -12831,30 +12849,31 @@ describe("download manager", () => { const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC"; const outputDir = path.join(root, "downloads", packageName); const extractDir = path.join(root, "extract", packageName); - const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; - const epDir = path.join(extractDir, episodeFolder); - fs.mkdirSync(epDir, { recursive: true }); - const epName = `${episodeFolder}.mkv`; - fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9)); - - const session = emptySession(); - const packageId = `${packageName}-pkg`; - const createdAt = Date.now() - 60_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "completed", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; + const epDir = path.join(extractDir, episodeFolder); + fs.mkdirSync(epDir, { recursive: true }); + const epName = `${episodeFolder}.mkv`; + fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -12887,29 +12906,30 @@ describe("download manager", () => { const outputDir = path.join(root, "downloads", packageName); const extractDir = path.join(root, "extract", packageName); fs.mkdirSync(extractDir, { recursive: true }); - const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv"; - const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv"; - fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1)); - fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2)); - - const session = emptySession(); - const packageId = `${packageName}-pkg`; - const createdAt = Date.now() - 60_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "completed", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv"; + const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv"; + fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1)); + fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -12947,29 +12967,30 @@ describe("download manager", () => { fs.mkdirSync(epDir, { recursive: true }); fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8)); }; - const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; - const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD"; - mk(folderA, "safari-fm-s04e08a.avi"); - mk(folderB, "safari-fm-s04e08b.avi"); - - const session = emptySession(); - const packageId = `${packageName}-pkg`; - const createdAt = Date.now() - 60_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "completed", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; + const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD"; + mk(folderA, "safari-fm-s04e08a.avi"); + mk(folderB, "safari-fm-s04e08b.avi"); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -13003,30 +13024,31 @@ describe("download manager", () => { const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG"; const outputDir = path.join(root, "downloads", packageName); const extractDir = path.join(root, "extract", packageName); - const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; - const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv"; - const epDir = path.join(extractDir, epFolder); - fs.mkdirSync(epDir, { recursive: true }); - fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5)); - - const session = emptySession(); - const packageId = `${packageName}-pkg`; - const createdAt = Date.now() - 60_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: packageName, - outputDir, - extractDir, - status: "completed", - itemIds: [], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; + const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv"; + const epDir = path.join(extractDir, epFolder); + fs.mkdirSync(epDir, { recursive: true }); + fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5)); + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 60_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + writePackageOutputOwnerMarker(session.packages[packageId]); + + const mkvLibraryDir = path.join(root, "mkv-library"); const manager = new DownloadManager( { ...defaultSettings(), @@ -13274,13 +13296,10 @@ describe("download manager", () => { } fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo")); - const s01Mkvs = [ - "Ugly.Americans.S01E01.German.mkv", - "Ugly.Americans.S01E02.German.mkv" - ]; - for (const mkv of s01Mkvs) { - fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9)); - } + const s01Mkvs = [ + "Ugly.Americans.S01E01.German.mkv", + "Ugly.Americans.S01E02.German.mkv" + ]; const session = emptySession(); const packageId = `${packageName}-pkg`; @@ -13311,13 +13330,25 @@ describe("download manager", () => { collectMkvToLibrary: true, mkvLibraryDir, enableIntegrityCheck: false, - cleanupMode: "delete" - }, - session, - createStoragePaths(path.join(root, "state")) - ); - - await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + expect(await (manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).toBe(true); + const ownerMarkerPath = path.join(extractDir, ".rd-package-output-owner-v1.json"); + const ownerMarker = JSON.parse(fs.readFileSync(ownerMarkerPath, "utf8")); + expect(ownerMarker).toEqual(expect.objectContaining({ + version: 1, + packageId, + generation: 1, + ownerId: session.packages[packageId].outputOwnerId + })); + for (const mkv of s01Mkvs) { + fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9)); + } + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); for (const mkv of s01Mkvs) { expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true); @@ -13373,8 +13404,54 @@ describe("download manager", () => { expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false); expect(session.packages["package-a"].outputRecords).toEqual([]); }); - - it("does NOT move bonus files from Extras subdirectory to flat library", async () => { + + it("does not adopt foreign files from a reused package-name directory without its generation owner marker", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reused-owner-")); + tempDirs.push(root); + const packageName = "reused-package"; + const extractDir = path.join(root, "extract", packageName); + const libraryDir = path.join(root, "library"); + fs.mkdirSync(extractDir, { recursive: true }); + const foreignPath = path.join(extractDir, "foreign.mkv"); + fs.writeFileSync(foreignPath, "foreign"); + const session = emptySession(); + const packageId = "reused-package-id"; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir: path.join(root, "downloads", packageName), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + outputProvenanceVersion: 1, + outputRecords: [], + createdAt: 1_000, + updatedAt: 1_000 + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + autoExtract: true, + createExtractSubfolder: true, + collectMkvToLibrary: true, + mkvLibraryDir: libraryDir + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); + + expect(fs.existsSync(foreignPath)).toBe(true); + expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false); + expect(session.packages[packageId].outputRecords).toEqual([]); + expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); + }); + + it("does NOT move bonus files from Extras subdirectory to flat library", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -13425,12 +13502,12 @@ describe("download manager", () => { resumable: true, attempts: 1, lastError: "", - fullStatus: "Fertig (100 MB)", - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); new DownloadManager( { @@ -13516,12 +13593,12 @@ describe("download manager", () => { resumable: true, attempts: 1, lastError: "", - fullStatus: "Fertig (100 MB)", - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); new DownloadManager( { @@ -13644,12 +13721,12 @@ describe("download manager", () => { resumable: true, attempts: 1, lastError: "", - fullStatus: "Fertig", - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); new DownloadManager( { ...defaultSettings(), @@ -13723,12 +13800,12 @@ describe("download manager", () => { resumable: true, attempts: 1, lastError: "", - fullStatus: "Fertig", - createdAt, - updatedAt: createdAt - }; - - const mkvLibraryDir = path.join(root, "mkv-library"); + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); fs.mkdirSync(mkvLibraryDir, { recursive: true }); fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video")); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index bc2b065..47b201f 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -14,7 +14,7 @@ function hasJavaRuntime(): boolean { return result.status === 0; } -function hasJvmExtractorRuntime(): boolean { +function hasJvmExtractorRuntime(): boolean { const root = path.join(process.cwd(), "resources", "extractor-jvm"); const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"); const requiredLibs = [ @@ -23,7 +23,24 @@ function hasJvmExtractorRuntime(): boolean { path.join(root, "lib", "zip4j.jar") ]; return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); -} +} + +function corruptFirstZipPayload(zipPath: string): void { + const bytes = fs.readFileSync(zipPath); + const signature = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04])); + if (signature < 0) { + throw new Error("local ZIP header missing"); + } + const compressedSize = bytes.readUInt32LE(signature + 18); + const nameLength = bytes.readUInt16LE(signature + 26); + const extraLength = bytes.readUInt16LE(signature + 28); + const dataOffset = signature + 30 + nameLength + extraLength; + if (compressedSize < 2 || dataOffset + compressedSize > bytes.length) { + throw new Error("ZIP payload missing"); + } + bytes[dataOffset + Math.floor(compressedSize / 2)] ^= 0xff; + fs.writeFileSync(zipPath, bytes); +} afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -66,6 +83,14 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b 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: "opened", + disposition: "written" + }), expect.objectContaining({ version: 1, archivePath: path.resolve(zipPath), @@ -109,14 +134,141 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b ], { 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(" "); + const outputLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_OUTPUT ")); + expect(outputLines.map((line) => line.split(" ")[2])).toEqual(["opened", "complete"]); + const fields = outputLines[1].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.each(["7zjbinding", "zip4j"])("rejects %s output behind a junction before opening it", (backend) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-junction-${backend}-`)); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const realDir = path.join(targetDir, "real"); + const linkedDir = path.join(targetDir, "linked"); + fs.mkdirSync(realDir, { recursive: true }); + try { + fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + } catch { + return; + } + const protectedPath = path.join(realDir, "protected.txt"); + fs.writeFileSync(protectedPath, "foreign"); + const zipPath = path.join(root, "release.zip"); + const zip = new AdmZip(); + zip.addFile("linked/protected.txt", Buffer.from("package")); + 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).not.toBe(0); + expect(fs.readFileSync(protectedPath, "utf8")).toBe("foreign"); + }); + + it("turns JVM output callback failures into a controlled archive failure and keeps the next request usable", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-callback-")); + tempDirs.push(root); + const firstPackage = path.join(root, "first-pkg"); + const secondPackage = path.join(root, "second-pkg"); + fs.mkdirSync(firstPackage, { recursive: true }); + fs.mkdirSync(secondPackage, { recursive: true }); + const firstZip = new AdmZip(); + firstZip.addFile("first.txt", Buffer.from("first")); + firstZip.writeZip(path.join(firstPackage, "first.zip")); + const secondZip = new AdmZip(); + secondZip.addFile("second.txt", Buffer.from("second")); + secondZip.writeZip(path.join(secondPackage, "second.zip")); + + const first = await extractPackageArchives({ + packageDir: firstPackage, + targetDir: path.join(root, "first-out"), + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onOutput: () => { + throw new Error("jvm-output-callback-failed"); + } + }); + const second = await extractPackageArchives({ + packageDir: secondPackage, + targetDir: path.join(root, "second-out"), + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(first.extracted).toBe(0); + expect(first.failed).toBe(1); + expect(first.lastError).toContain("jvm-output-callback-failed"); + expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 })); + }, 10000); + + it.each(["7zjbinding", "zip4j"])("reports %s partial output before removing a failed file", (backend) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`)); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const zipPath = path.join(root, "corrupt.zip"); + const zip = new AdmZip(); + zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000))); + zip.writeZip(zipPath); + corruptFirstZipPayload(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" }); + const states = String(run.stdout) + .split(/\r?\n/) + .filter((line) => line.startsWith("RD_OUTPUT ")) + .map((line) => line.split(" ")[2]); + + expect(run.status).not.toBe(0); + expect(states[0]).toBe("opened"); + expect(states).toContain("partial"); + expect(states[states.length - 1]).toBe("removed"); + expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false); + }); 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 0ee428f..edcb9ef 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -1316,14 +1316,15 @@ describe("extractor", () => { }); expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent); - expect(events).toEqual([expect.objectContaining({ + expect(events.map((event) => event.state)).toEqual(disposition === "skipped" ? ["complete"] : ["opened", "complete"]); + expect(events[events.length - 1]).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]); }); @@ -1462,12 +1463,101 @@ describe("extractor", () => { } })).rejects.toThrow("aborted:extract"); - expect(events).toHaveLength(1); - expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") })); + expect(events.map((event) => event.state)).toEqual(["opened", "complete"]); + expect(events[1]).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); }); + it("emits opened before writing and complete only after the internal ZIP write", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-lifecycle-")); + 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("episode.mkv", Buffer.from("video")); + zip.writeZip(archivePath); + const states: string[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onOutput: (event) => states.push(event.state) + }); + + expect(result.failed).toBe(0); + expect(states).toEqual(["opened", "complete"]); + expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe("video"); + }); + + it("rejects an internal ZIP target behind a junction before changing it", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-junction-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const realDir = path.join(targetDir, "real"); + const linkedDir = path.join(targetDir, "linked"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(realDir, { recursive: true }); + try { + fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + } catch { + return; + } + const protectedPath = path.join(realDir, "protected.txt"); + fs.writeFileSync(protectedPath, "foreign"); + const zip = new AdmZip(); + zip.addFile("linked/protected.txt", Buffer.from("package")); + zip.writeZip(path.join(packageDir, "release.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); + expect(fs.readFileSync(protectedPath, "utf8")).toBe("foreign"); + }); + + it("aborts an internal ZIP entry callback failure before opening the target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-callback-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from("video")); + zip.writeZip(path.join(packageDir, "release.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onOutput: () => { + throw new Error("output-callback-failed"); + } + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(result.lastError).toContain("output-callback-failed"); + expect(fs.existsSync(path.join(targetDir, "episode.mkv"))).toBe(false); + }); + it("strictly parses native output paths and fails closed for ambiguous rename output", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-")); tempDirs.push(root); @@ -1495,5 +1585,18 @@ describe("extractor", () => { ]); }); + it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const outputPath = path.join(targetDir, "episode.mkv"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(outputPath, "video"); + + expect(parseNativeExtractOutput("UnRAR.exe", `${verb} ${outputPath} OK`, path.join(root, "archive.rar"), targetDir, "overwrite")).toEqual([ + expect.objectContaining({ outputPath, entryPath: "episode.mkv" }) + ]); + }); + }); }); diff --git a/tests/package-output-scope.test.ts b/tests/package-output-scope.test.ts index 8fa57d1..3800c68 100644 --- a/tests/package-output-scope.test.ts +++ b/tests/package-output-scope.test.ts @@ -156,4 +156,27 @@ describe("PackageOutputScope", () => { expect(scope.removePath(targetPath)).toBe(true); expect(scope.completeFiles()).toEqual([]); }); + + it("validates opened targets before creation and removes discarded partial ownership", () => { + const root = createRoot(); + const outputPath = path.join(root, "episode.mkv"); + const scope = new PackageOutputScope([root]); + const opened = { + version: 1 as const, + archivePath: path.join(root, "archive.rar"), + entryPath: "episode.mkv", + outputPath, + state: "opened" as const, + disposition: "written" as const + }; + + expect(scope.add(opened)).toBe(false); + expect(scope.records()).toEqual([]); + fs.writeFileSync(outputPath, "partial"); + scope.add({ ...opened, state: "partial" }); + expect(scope.partialFiles()).toEqual([outputPath]); + fs.rmSync(outputPath, { force: true }); + expect(scope.add({ ...opened, state: "removed" })).toBe(true); + expect(scope.records()).toEqual([]); + }); });