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 e8fcab3..d2f137d 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class index 45b8522..7d81699 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class index 9b4c635..fa5cecb 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class differ 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 771c8fe..aed1e17 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ 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 7a3b3e4..c8a5780 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class index e1b2fc9..6314745 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class differ 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 cfe8652..8edca16 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ 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 e85dfd4..b51750e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ 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 c3d2f41..6c93e30 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ 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 c2bc826..2aee3ae 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 894704b..265e1cc 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ 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([]); + }); });