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.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:24:19 +02:00
parent 58237bed0c
commit 11d63dd2be
21 changed files with 1010 additions and 378 deletions
@@ -27,8 +27,11 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.RandomAccessFile; import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; 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.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
@@ -266,10 +269,11 @@ public final class JBindExtractorMain {
} }
String entryName = normalizeEntryName(header.getFileName(), "file"); String entryName = normalizeEntryName(header.getFileName(), "file");
if (header.isDirectory()) { if (header.isDirectory()) {
File dir = resolveDirectory(request.targetDir, entryName); File dir = resolveDirectory(request.targetDir, entryName);
ensureDirectory(dir); ensureDirectory(dir);
reserved.add(pathKey(dir)); rejectLinkedPath(request.targetDir, dir);
reserved.add(pathKey(dir));
continue; continue;
} }
@@ -282,8 +286,10 @@ public final class JBindExtractorMain {
continue; continue;
} }
ensureDirectory(output.getParentFile()); rejectLinkedPath(request.targetDir, output);
rejectSymlink(output); emitOutput(request.archiveFile, entryName, output, "opened", outputTarget.disposition);
ensureDirectory(output.getParentFile());
rejectLinkedPath(request.targetDir, output);
long[] remaining = new long[] { itemUnits }; long[] remaining = new long[] { itemUnits };
boolean extractionSuccess = false; boolean extractionSuccess = false;
try { try {
@@ -335,12 +341,9 @@ public final class JBindExtractorMain {
if (!extractionSuccess && output.exists()) { if (!extractionSuccess && output.exists()) {
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition); emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
} }
if (!extractionSuccess && output.exists()) { if (!extractionSuccess && output.exists() && output.delete()) {
try { emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
output.delete(); }
} catch (Throwable ignored) {
}
}
} }
} }
@@ -386,9 +389,10 @@ public final class JBindExtractorMain {
String entryPath = (String) archive.getProperty(i, PropID.PATH); String entryPath = (String) archive.getProperty(i, PropID.PATH);
String entryName = normalizeEntryName(entryPath, "item-" + i); String entryName = normalizeEntryName(entryPath, "item-" + i);
if (Boolean.TRUE.equals(isFolder)) { if (Boolean.TRUE.equals(isFolder)) {
File dir = resolveDirectory(request.targetDir, entryName); File dir = resolveDirectory(request.targetDir, entryName);
ensureDirectory(dir); ensureDirectory(dir);
rejectLinkedPath(request.targetDir, dir);
reserved.add(pathKey(dir)); reserved.add(pathKey(dir));
continue; continue;
} }
@@ -446,18 +450,21 @@ public final class JBindExtractorMain {
final Throwable[] firstError = new Throwable[1]; final Throwable[] firstError = new Throwable[1];
final int[] currentPos = new int[] { -1 }; final int[] currentPos = new int[] { -1 };
try { BulkExtractCallback extractCallback = new BulkExtractCallback(
archive.extract(indices, false, new BulkExtractCallback( archive, request.archiveFile, request.targetDir, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions,
archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, progress, encryptedFinal, effectivePassword, currentOutput,
progress, encryptedFinal, effectivePassword, currentOutput, currentStream, currentSuccess, currentRemaining, currentPos, firstError
currentStream, currentSuccess, currentRemaining, currentPos, firstError );
)); try {
} catch (SevenZipException error) { archive.extract(indices, false, extractCallback);
if (looksLikeWrongPassword(error, encryptedFinal)) { } catch (SevenZipException error) {
throw new WrongPasswordException(error); if (looksLikeWrongPassword(error, encryptedFinal)) {
} throw new WrongPasswordException(error);
throw error; }
} throw error;
} finally {
extractCallback.finishCurrentOutput();
}
if (firstError[0] != null) { if (firstError[0] != null) {
if (firstError[0] instanceof WrongPasswordException) { if (firstError[0] instanceof WrongPasswordException) {
@@ -575,13 +582,15 @@ public final class JBindExtractorMain {
return false; return false;
} }
private static File resolveDirectory(File targetDir, String entryName) throws IOException { private static File resolveDirectory(File targetDir, String entryName) throws IOException {
File directory = secureResolve(targetDir, entryName); File directory = secureResolve(targetDir, entryName);
return directory; rejectLinkedPath(targetDir, directory);
} return directory;
}
private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set<String> reserved) throws IOException { private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set<String> reserved) throws IOException {
File base = secureResolve(targetDir, entryName); File base = secureResolve(targetDir, entryName);
rejectLinkedPath(targetDir, base);
String key = pathKey(base); String key = pathKey(base);
boolean exists = base.exists() || reserved.contains(key); boolean exists = base.exists() || reserved.contains(key);
@@ -594,9 +603,11 @@ public final class JBindExtractorMain {
return new OutputTarget(null, base, "skipped"); return new OutputTarget(null, base, "skipped");
} }
if (conflictMode == ConflictMode.OVERWRITE) { if (conflictMode == ConflictMode.OVERWRITE) {
if (base.exists()) { if (base.exists()) {
deleteRecursively(base); if (!base.isFile() || !base.delete()) {
throw new IOException("Konnte Datei nicht uberschreiben: " + base.getAbsolutePath());
}
} }
reserved.add(key); reserved.add(key);
return new OutputTarget(base, base, "overwritten"); return new OutputTarget(base, base, "overwritten");
@@ -610,8 +621,9 @@ public final class JBindExtractorMain {
int counter = 1; int counter = 1;
while (counter <= 10000) { while (counter <= 10000) {
String candidateName = stem + " (" + counter + ")" + ext; String candidateName = stem + " (" + counter + ")" + ext;
File candidate = new File(parent, candidateName); File candidate = new File(parent, candidateName);
rejectLinkedPath(targetDir, candidate);
String candidateKey = pathKey(candidate); String candidateKey = pathKey(candidate);
if (!candidate.exists() && !reserved.contains(candidateKey)) { if (!candidate.exists() && !reserved.contains(candidateKey)) {
reserved.add(candidateKey); reserved.add(candidateKey);
@@ -623,23 +635,6 @@ public final class JBindExtractorMain {
throw new IOException("Rename-Limit erreicht fur " + entryName); 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 { private static File secureResolve(File targetDir, String entryName) throws IOException {
String normalized = normalizeEntryName(entryName, "file"); String normalized = normalizeEntryName(entryName, "file");
while (normalized.startsWith("/")) { while (normalized.startsWith("/")) {
@@ -657,18 +652,20 @@ public final class JBindExtractorMain {
normalized = normalized.substring(1); normalized = normalized.substring(1);
} }
} }
File targetCanonical = targetDir.getCanonicalFile(); File targetCanonical = targetDir.getCanonicalFile();
File output = new File(targetCanonical, normalized); Path targetPathValue = targetCanonical.toPath().toAbsolutePath().normalize();
File outputCanonical = output.getCanonicalFile(); Path outputPathValue = targetPathValue.resolve(normalized).normalize();
String targetPath = targetCanonical.getPath(); String targetPath = targetPathValue.toString();
String outputPath = outputCanonical.getPath(); String outputPath = outputPathValue.toString();
String targetPathNorm = isWindows() ? targetPath.toLowerCase(Locale.ROOT) : targetPath; String targetPathNorm = isWindows() ? targetPath.toLowerCase(Locale.ROOT) : targetPath;
String outputPathNorm = isWindows() ? outputPath.toLowerCase(Locale.ROOT) : outputPath; String outputPathNorm = isWindows() ? outputPath.toLowerCase(Locale.ROOT) : outputPath;
String targetPrefix = targetPathNorm.endsWith(File.separator) ? targetPathNorm : targetPathNorm + File.separator; String targetPrefix = targetPathNorm.endsWith(File.separator) ? targetPathNorm : targetPathNorm + File.separator;
if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPrefix)) { if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPrefix)) {
throw new IOException("Path Traversal blockiert: " + entryName); 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) { private static String normalizeEntryName(String value, String fallback) {
@@ -710,22 +707,31 @@ public final class JBindExtractorMain {
return size; return size;
} }
private static void rejectSymlink(File file) throws IOException { private static void rejectLinkedPath(File targetDir, File file) throws IOException {
if (file == null) { if (targetDir == null || file == null) {
return; return;
} }
if (Files.isSymbolicLink(file.toPath())) { Path root = targetDir.getCanonicalFile().toPath().toAbsolutePath().normalize();
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath()); Path current = file.toPath().toAbsolutePath().normalize();
} String rootValue = isWindows() ? root.toString().toLowerCase(Locale.ROOT) : root.toString();
while (current != null) {
File parent = file.getParentFile(); String currentValue = isWindows() ? current.toString().toLowerCase(Locale.ROOT) : current.toString();
while (parent != null) { String prefix = rootValue.endsWith(File.separator) ? rootValue : rootValue + File.separator;
if (Files.isSymbolicLink(parent.toPath())) { if (!currentValue.equals(rootValue) && !currentValue.startsWith(prefix)) {
throw new IOException("Elternverzeichnis ist ein Symlink, Schreiben verweigert: " + parent.getAbsolutePath()); throw new IOException("Path Traversal blockiert: " + file.getAbsolutePath());
} }
parent = parent.getParentFile(); 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 { private static void ensureDirectory(File dir) throws IOException {
if (dir == null) { if (dir == null) {
@@ -917,6 +923,7 @@ public final class JBindExtractorMain {
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive; private final IInArchive archive;
private final File archiveFile; private final File archiveFile;
private final File targetDir;
private final Map<Integer, Integer> indexToPos; private final Map<Integer, Integer> indexToPos;
private final List<Integer> fileIndices; private final List<Integer> fileIndices;
private final List<File> outputFiles; private final List<File> outputFiles;
@@ -933,7 +940,7 @@ public final class JBindExtractorMain {
private final int[] currentPos; private final int[] currentPos;
private final Throwable[] firstError; private final Throwable[] firstError;
BulkExtractCallback(IInArchive archive, File archiveFile, Map<Integer, Integer> indexToPos, BulkExtractCallback(IInArchive archive, File archiveFile, File targetDir, Map<Integer, Integer> indexToPos,
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes, List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
List<String> entryNames, List<String> dispositions, List<String> entryNames, List<String> dispositions,
ProgressTracker progress, boolean encrypted, String password, ProgressTracker progress, boolean encrypted, String password,
@@ -942,6 +949,7 @@ public final class JBindExtractorMain {
Throwable[] firstError) { Throwable[] firstError) {
this.archive = archive; this.archive = archive;
this.archiveFile = archiveFile; this.archiveFile = archiveFile;
this.targetDir = targetDir;
this.indexToPos = indexToPos; this.indexToPos = indexToPos;
this.fileIndices = fileIndices; this.fileIndices = fileIndices;
this.outputFiles = outputFiles; this.outputFiles = outputFiles;
@@ -974,9 +982,9 @@ public final class JBindExtractorMain {
} }
@Override @Override
public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException { public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
closeCurrentStream(); discardCurrentOutput();
Integer pos = indexToPos.get(index); Integer pos = indexToPos.get(index);
if (pos == null) { if (pos == null) {
@@ -995,12 +1003,14 @@ public final class JBindExtractorMain {
if (currentOutput[0] == null) { if (currentOutput[0] == null) {
progress.advance(currentRemaining[0]); progress.advance(currentRemaining[0]);
return null; return null;
} }
try { try {
ensureDirectory(currentOutput[0].getParentFile()); rejectLinkedPath(targetDir, currentOutput[0]);
rejectSymlink(currentOutput[0]); emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "opened", dispositions.get(currentPos[0]));
currentStream[0] = new FileOutputStream(currentOutput[0]); ensureDirectory(currentOutput[0].getParentFile());
rejectLinkedPath(targetDir, currentOutput[0]);
currentStream[0] = new FileOutputStream(currentOutput[0]);
} catch (IOException error) { } catch (IOException error) {
throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error); throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error);
} }
@@ -1036,9 +1046,9 @@ public final class JBindExtractorMain {
currentRemaining[0] = 0; currentRemaining[0] = 0;
} }
if (result == ExtractOperationResult.OK) { if (result == ExtractOperationResult.OK) {
currentSuccess[0] = true; currentSuccess[0] = true;
closeCurrentStream(); closeCurrentStreamOnly();
if (currentPos[0] >= 0 && currentOutput[0] != null) { if (currentPos[0] >= 0 && currentOutput[0] != null) {
try { try {
int archiveIndex = fileIndices.get(currentPos[0]); 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])); emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "complete", dispositions.get(currentPos[0]));
} }
} else { } else {
closeCurrentStream(); discardCurrentOutput();
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) {
}
}
if (firstError[0] == null) { if (firstError[0] == null) {
if (isPasswordFailure(result, encrypted)) { if (isPasswordFailure(result, encrypted)) {
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort")); firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
@@ -1070,20 +1073,31 @@ public final class JBindExtractorMain {
} }
} }
private void closeCurrentStream() { void finishCurrentOutput() {
if (currentStream[0] != null) { discardCurrentOutput();
}
private void closeCurrentStreamOnly() {
if (currentStream[0] != null) {
try { try {
currentStream[0].close(); currentStream[0].close();
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
currentStream[0] = null; currentStream[0] = null;
} }
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) { }
try {
currentOutput[0].delete(); private void discardCurrentOutput() {
} catch (Throwable ignored) { 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));
}
}
} }
} }
+154 -7
View File
@@ -480,6 +480,15 @@ type DownloadManagerOptions = {
protectEmptyClobber?: boolean; 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 = { type RunLifecycleContext = {
id: string; id: string;
startedAt: number; startedAt: number;
@@ -4429,19 +4438,139 @@ export class DownloadManager extends EventEmitter {
return scope; return scope;
} }
private packageOutputOwnerMarkerPath(pkg: PackageEntry): string {
return path.join(pkg.extractDir, PACKAGE_OUTPUT_OWNER_MARKER);
}
private async readPackageOutputOwnerMarker(pkg: PackageEntry, requireSessionMatch: boolean): Promise<PackageOutputOwnerMarker | null> {
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<PackageOutputOwnerMarker>;
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<void> {
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<boolean> {
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<boolean> {
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<void> { private async adoptLegacyPackageOutputsIfExclusive(pkg: PackageEntry, scope: PackageOutputScope): Promise<void> {
if (pkg.outputScopeAdopted || scope.records().length > 0) { if (scope.records().length > 0) {
pkg.outputScopeAdopted = true; pkg.outputScopeAdopted = true;
return; return;
} }
if (pkg.outputScopeAdopted) {
return;
}
pkg.outputScopeAdopted = true; pkg.outputScopeAdopted = true;
if (pkg.outputProvenanceVersion !== undefined if (pkg.outputProvenanceVersion !== undefined
&& pkg.outputProvenanceVersion !== PACKAGE_OUTPUT_PROVENANCE_VERSION) { && pkg.outputProvenanceVersion !== PACKAGE_OUTPUT_PROVENANCE_VERSION) {
return; return;
} }
const packageExclusive = (this.settings.createExtractSubfolder || this.isPackageSpecificExtractDir(pkg)) if (!await this.readPackageOutputOwnerMarker(pkg, true)) {
&& !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir);
if (!packageExclusive || !await this.existsAsync(pkg.extractDir)) {
return; return;
} }
const candidates: string[] = []; const candidates: string[] = [];
@@ -4473,6 +4602,8 @@ export class DownloadManager extends EventEmitter {
} else if (entry.isFile() } else if (entry.isFile()
&& !/^\.rd-(?:output|replace)-/i.test(entry.name) && !/^\.rd-(?:output|replace)-/i.test(entry.name)
&& !/^\.rd_extract_progress(?:_[^.]+)?\.json$/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)) { && !isIgnorableEmptyDirFileName(entry.name)) {
candidates.push(fullPath); candidates.push(fullPath);
} }
@@ -4507,10 +4638,19 @@ export class DownloadManager extends EventEmitter {
const scope = this.getPackageOutputScope(pkg); const scope = this.getPackageOutputScope(pkg);
try { try {
await fs.promises.mkdir(pkg.extractDir, { recursive: true }); await fs.promises.mkdir(pkg.extractDir, { recursive: true });
await this.ensurePackageOutputOwnerMarker(pkg);
return await operation(pkg.extractDir, scope); return await operation(pkg.extractDir, scope);
} finally { } finally {
if (!packageWasInSession || this.session.packages[pkg.id] === pkg) { if (!packageWasInSession || this.session.packages[pkg.id] === pkg) {
this.syncPackageOutputScope(pkg, scope); 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)) { if ((sourceArtifactsChanged || sourceCleanupRelevant) && cleanupDir && await this.existsAsync(cleanupDir)) {
const removedResidual = await this.cleanupNonMkvResidualFiles(scope, targetDir, touchedParents); const removedResidual = await this.cleanupNonMkvResidualFiles(scope, targetDir, touchedParents);
if (removedResidual > 0) { if (removedResidual > 0) {
logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`); 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); const removedDirs = await this.removeEmptyScopedParentChains(cleanupDir, touchedParents);
if (removedDirs > 0) { if (removedDirs > 0) {
logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedDirs}`); 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.outputProvenance = [];
pkg.outputRecords = []; pkg.outputRecords = [];
pkg.outputScopeAdopted = false; pkg.outputScopeAdopted = false;
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
} }
for (const itemId of itemIds) { for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
@@ -12411,6 +12556,8 @@ export class DownloadManager extends EventEmitter {
pkg.outputProvenance = []; pkg.outputProvenance = [];
pkg.outputRecords = []; pkg.outputRecords = [];
pkg.outputScopeAdopted = false; pkg.outputScopeAdopted = false;
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
this.packageOutputScopes.delete(packageId); this.packageOutputScopes.delete(packageId);
pkg.cleanupErrorCategory = ""; pkg.cleanupErrorCategory = "";
} }
+152 -51
View File
@@ -119,6 +119,13 @@ export class ExtractionError extends Error {
this.name = "ExtractionError"; 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 & { type ExtractionErrorWithHints = Error & {
suggestRedownload?: boolean; suggestRedownload?: boolean;
@@ -146,6 +153,15 @@ type JvmExtractResult = {
backend: string; backend: string;
}; };
type JvmParseState = {
bestPercent: number;
usedPassword: string;
backend: string;
reportedError: string;
outputError?: Error;
openedOutputs?: Map<string, ExtractOutputEvent>;
};
export interface ExtractResult { export interface ExtractResult {
extracted: number; extracted: number;
failed: number; failed: number;
@@ -193,7 +209,7 @@ interface DaemonRequest {
onArchiveProgress?: (percent: number) => void; onArchiveProgress?: (percent: number) => void;
signal?: AbortSignal; signal?: AbortSignal;
timeoutMs?: number; timeoutMs?: number;
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; parseState: JvmParseState;
archiveName: string; archiveName: string;
startedAt: number; startedAt: number;
passwordCount: number; passwordCount: number;
@@ -1556,7 +1572,7 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
function parseJvmLine( function parseJvmLine(
line: string, line: string,
onArchiveProgress: ((percent: number) => void) | undefined, onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }, state: JvmParseState,
onOutput?: (event: ExtractOutputEvent) => void onOutput?: (event: ExtractOutputEvent) => void
): void { ): void {
const trimmed = String(line || "").trim(); const trimmed = String(line || "").trim();
@@ -1597,18 +1613,33 @@ function parseJvmLine(
const disposition = fields[3]; const disposition = fields[3];
if (fields.length !== 7 if (fields.length !== 7
|| fields[1] !== "1" || 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"])) { || !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) {
return; return;
} }
onOutput?.({ const event: ExtractOutputEvent = {
version: 1, version: 1,
archivePath: Buffer.from(fields[4], "base64").toString("utf8"), archivePath: Buffer.from(fields[4], "base64").toString("utf8"),
entryPath: Buffer.from(fields[5], "base64").toString("utf8"), entryPath: Buffer.from(fields[5], "base64").toString("utf8"),
outputPath: Buffer.from(fields[6], "base64").toString("utf8"), outputPath: Buffer.from(fields[6], "base64").toString("utf8"),
state: stateValue, state: stateValue as ExtractOutputEvent["state"],
disposition: disposition as ExtractOutputEvent["disposition"] disposition: disposition as ExtractOutputEvent["disposition"]
}); };
const outputKey = pathSetKey(path.resolve(event.outputPath));
state.openedOutputs ||= new Map<string, ExtractOutputEvent>();
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; return;
} }
@@ -1661,7 +1692,7 @@ function finishDaemonRequest(result: JvmExtractResult): void {
req.resolve(result); req.resolve(result);
} }
function flushDaemonParseBuffers(req: DaemonRequest | null): void { function flushDaemonParseBuffers(req: DaemonRequest | null): void {
if (!req) { if (!req) {
return; return;
} }
@@ -1693,7 +1724,11 @@ function handleDaemonLine(line: string): void {
if (daemonCurrentRequest !== req) { if (daemonCurrentRequest !== req) {
return; return;
} }
flushDaemonParseBuffers(req); flushDaemonParseBuffers(req);
if (req.parseState.outputError) {
failDaemonOutputCallback(req);
return;
}
const elapsedMs = Date.now() - req.startedAt; const elapsedMs = Date.now() - req.startedAt;
logger.info( logger.info(
`JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` + `JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` +
@@ -1727,9 +1762,11 @@ function handleDaemonLine(line: string): void {
return; return;
} }
if (daemonCurrentRequest) { if (daemonCurrentRequest) {
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); const req = daemonCurrentRequest;
} parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
} }
function startDaemon(layout: JvmExtractorLayout): boolean { function startDaemon(layout: JvmExtractorLayout): boolean {
@@ -1780,9 +1817,11 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
const lines = daemonStderrBuffer.split(/\r?\n/); const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || ""; daemonStderrBuffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
if (daemonCurrentRequest) { if (daemonCurrentRequest) {
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); const req = daemonCurrentRequest;
} parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
} }
}); });
@@ -1999,9 +2038,10 @@ async function runJvmExtractCommand(
let timedOutByWatchdog = false; let timedOutByWatchdog = false;
let abortedBySignal = false; let abortedBySignal = false;
let onAbort: (() => void) | null = null; let onAbort: (() => void) | null = null;
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" }; const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
let stdoutBuffer = ""; let stdoutBuffer = "";
let stderrBuffer = ""; let stderrBuffer = "";
let outputCallbackKillStarted = false;
const child = spawn(layout.javaCommand, args, { windowsHide: true }); const child = spawn(layout.javaCommand, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority); lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
@@ -2014,9 +2054,13 @@ async function runJvmExtractCommand(
const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`; const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`;
const lines = nextBuffer.split(/\r?\n/); const lines = nextBuffer.split(/\r?\n/);
const keep = lines.pop() || ""; const keep = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
parseJvmLine(line, onArchiveProgress, parseState, onOutput); parseJvmLine(line, onArchiveProgress, parseState, onOutput);
} }
if (parseState.outputError && !outputCallbackKillStarted) {
outputCallbackKillStarted = true;
killProcessTree(child);
}
if (fromStdErr) { if (fromStdErr) {
stderrBuffer = keep; stderrBuffer = keep;
} else { } else {
@@ -2101,17 +2145,31 @@ async function runJvmExtractCommand(
}); });
return; return;
} }
if (timedOutByWatchdog) { if (timedOutByWatchdog) {
finish({ finish({
ok: false, missingCommand: false, missingRuntime: false, ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: true, aborted: false, timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`, errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`,
usedPassword: parseState.usedPassword, backend: parseState.backend usedPassword: parseState.usedPassword, backend: parseState.backend
}); });
return; return;
} }
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`; 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) { if (code === 0) {
onArchiveProgress?.(100); onArchiveProgress?.(100);
finish({ finish({
@@ -2171,8 +2229,9 @@ export function parseNativeExtractOutput(
const match = trimmed.match(/^[-+]\s+(.+)$/); const match = trimmed.match(/^[-+]\s+(.+)$/);
reportedPath = match?.[1]?.trim() || ""; reportedPath = match?.[1]?.trim() || "";
} else if (isRarNativeCommand(command)) { } else if (isRarNativeCommand(command)) {
const match = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i); const localizedMatch = trimmed.match(/^.+?\s{2,}(.+?)\s{2,}OK$/);
reportedPath = match?.[1]?.trim() || ""; const legacyMatch = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
reportedPath = localizedMatch?.[1]?.trim() || legacyMatch?.[1]?.trim() || "";
} }
if (!reportedPath) { if (!reportedPath) {
return []; 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( function createNativeOutputCollector(
command: string, command: string,
archivePath: string, archivePath: string,
@@ -2224,7 +2301,7 @@ function createNativeOutputCollector(
const collectLine = (value: string): void => { const collectLine = (value: string): void => {
const trimmed = value.trim(); const trimmed = value.trim();
if ((extractorCommandKind(command) === "seven_zip" && /^[-+]\s+/.test(trimmed)) 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); lines.add(trimmed);
} }
}; };
@@ -2697,9 +2774,12 @@ async function runExternalExtract(
function isZipSafetyGuardError(error: unknown): boolean { function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase(); const text = String(error || "").toLowerCase();
return text.includes("path traversal") return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß") || text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross"); || 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 { function isZipInternalLimitError(error: unknown): boolean {
@@ -2736,7 +2816,8 @@ async function extractZipArchive(
targetDir: string, targetDir: string,
conflictMode: ConflictMode, conflictMode: ConflictMode,
signal?: AbortSignal, signal?: AbortSignal,
onOutput?: (event: ExtractOutputEvent) => void onOutput?: (event: ExtractOutputEvent) => void,
validateTarget?: (entryPath: string, outputPath: string) => void
): Promise<void> { ): Promise<void> {
const mode = effectiveConflictMode(conflictMode); const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes(); const memoryLimitBytes = zipEntryMemoryLimitBytes();
@@ -2755,9 +2836,11 @@ async function extractZipArchive(
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue; continue;
} }
if (entry.isDirectory) { if (entry.isDirectory) {
await fs.promises.mkdir(baseOutputPath, { recursive: true }); validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
continue; await fs.promises.mkdir(baseOutputPath, { recursive: true });
validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
continue;
} }
const header = (entry as unknown as { const header = (entry as unknown as {
@@ -2797,7 +2880,6 @@ async function extractZipArchive(
let outputKey = pathSetKey(outputPath); let outputKey = pathSetKey(outputPath);
let disposition: ExtractOutputEvent["disposition"] = "written"; 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); const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) { if (outputExists) {
if (mode === "skip") { if (mode === "skip") {
@@ -2840,10 +2922,22 @@ async function extractZipArchive(
} }
} }
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
const data = entry.getData(); 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) { if (data.length > memoryLimitBytes) {
const entryMb = Math.ceil(data.length / (1024 * 1024)); const entryMb = Math.ceil(data.length / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
@@ -2856,14 +2950,6 @@ async function extractZipArchive(
try { try {
await fs.promises.writeFile(outputPath, data); await fs.promises.writeFile(outputPath, data);
usedOutputs.add(outputKey); usedOutputs.add(outputKey);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath,
state: "complete",
disposition
});
} catch (error) { } catch (error) {
if (await fs.promises.access(outputPath).then(() => true, () => false)) { if (await fs.promises.access(outputPath).then(() => true, () => false)) {
onOutput?.({ onOutput?.({
@@ -2877,6 +2963,14 @@ async function extractZipArchive(
} }
throw error; 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<E
const outputScope = new PackageOutputScope([options.targetDir]); const outputScope = new PackageOutputScope([options.targetDir]);
const emitOutput = (event: ExtractOutputEvent): void => { const emitOutput = (event: ExtractOutputEvent): void => {
outputScope.add(event); 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" }); options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
const allCandidates = await findArchiveCandidates(options.packageDir); const allCandidates = await findArchiveCandidates(options.packageDir);
@@ -3428,14 +3529,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
rememberLearnedPassword(usedPassword); rememberLearnedPassword(usedPassword);
} catch (error) { } catch (error) {
if (isNoExtractorError(String(error))) { if (isNoExtractorError(String(error))) {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
} else { } else {
throw error; throw error;
} }
} }
} else { } else {
try { try {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
archivePercent = 100; archivePercent = 100;
} catch (error) { } catch (error) {
if (!shouldFallbackToExternalZip(error)) { if (!shouldFallbackToExternalZip(error)) {
@@ -3696,7 +3797,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const ext = path.extname(nestedArchive).toLowerCase(); const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) { if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try { try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput); await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
nestedPercent = 100; nestedPercent = 100;
} catch (zipErr) { } catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr; if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
+30 -19
View File
@@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
export type ExtractOutputState = "complete" | "partial"; export type ExtractOutputState = "opened" | "complete" | "partial" | "removed";
export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped"; export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped";
export interface ExtractOutputEvent { export interface ExtractOutputEvent {
@@ -13,10 +13,12 @@ export interface ExtractOutputEvent {
disposition: ExtractOutputDisposition; disposition: ExtractOutputDisposition;
} }
export type OwnedExtractOutputEvent = ExtractOutputEvent & { state: "complete" | "partial" };
export class PackageOutputScope { export class PackageOutputScope {
private readonly authorizedRoots: string[]; private readonly authorizedRoots: string[];
private readonly outputRecords = new Map<string, ExtractOutputEvent>(); private readonly outputRecords = new Map<string, OwnedExtractOutputEvent>();
public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) { public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) {
this.authorizedRoots = [...new Map( this.authorizedRoots = [...new Map(
@@ -94,20 +96,14 @@ export class PackageOutputScope {
if (!path.isAbsolute(String(event.archivePath || ""))) { if (!path.isAbsolute(String(event.archivePath || ""))) {
throw new Error(`Archivpfad muss absolut sein: ${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)}`); throw new Error(`Ungültiger Extract-Output-Status: ${String(event.state)}`);
} }
if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) { if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) {
throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`); throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`);
} }
const entryPath = this.validateEntryPath(event.entryPath); const { entryPath, outputPath } = this.validateTarget(event.entryPath, event.outputPath);
if (!path.isAbsolute(String(event.outputPath || ""))) { if (event.disposition !== "skipped" && event.state !== "opened" && event.state !== "removed") {
throw new Error(`Finaler Ausgabepfad muss absolut sein: ${event.outputPath}`);
}
const outputPath = path.resolve(event.outputPath);
const authorizedRoot = this.findAuthorizedRoot(outputPath);
this.rejectLinkedPath(outputPath, authorizedRoot);
if (event.disposition !== "skipped") {
let stat: fs.Stats; let stat: fs.Stats;
try { try {
stat = fs.lstatSync(outputPath); 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 { public add(event: ExtractOutputEvent): boolean {
const normalized = this.normalizeEvent(event); 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; return false;
} }
const key = this.pathKey(normalized.outputPath); const owned = normalized as OwnedExtractOutputEvent;
const current = this.outputRecords.get(key); const current = this.outputRecords.get(key);
if (current) { if (current) {
if (current.state === "partial" && normalized.state === "complete") { if (current.state === "partial" && owned.state === "complete") {
this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath }); this.outputRecords.set(key, { ...owned, outputPath: current.outputPath });
} }
return false; return false;
} }
this.outputRecords.set(key, normalized); this.outputRecords.set(key, owned);
return true; return true;
} }
@@ -155,7 +166,7 @@ export class PackageOutputScope {
return added; return added;
} }
public records(): ExtractOutputEvent[] { public records(): OwnedExtractOutputEvent[] {
return [...this.outputRecords.values()]; 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)); 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 sourceKey = this.pathKey(sourcePath);
const current = this.outputRecords.get(sourceKey); const current = this.outputRecords.get(sourceKey);
if (!current) { if (!current) {
@@ -187,7 +198,7 @@ export class PackageOutputScope {
entryPath: path.basename(targetPath), entryPath: path.basename(targetPath),
state: state || current.state, state: state || current.state,
disposition: targetPath === current.outputPath ? current.disposition : "renamed" disposition: targetPath === current.outputPath ? current.disposition : "renamed"
}); }) as OwnedExtractOutputEvent;
this.outputRecords.delete(sourceKey); this.outputRecords.delete(sourceKey);
this.outputRecords.set(this.pathKey(next.outputPath), next); this.outputRecords.set(this.pathKey(next.outputPath), next);
return true; return true;
+2
View File
@@ -1063,6 +1063,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
outputProvenance, outputProvenance,
outputRecords, outputRecords,
outputScopeAdopted: Boolean(pkg.outputScopeAdopted), 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), cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
+2
View File
@@ -602,6 +602,8 @@ export interface PackageEntry {
outputProvenance?: string[]; outputProvenance?: string[];
outputRecords?: PackageOutputRecord[]; outputRecords?: PackageOutputRecord[];
outputScopeAdopted?: boolean; outputScopeAdopted?: boolean;
outputOwnerId?: string;
outputOwnerGeneration?: number;
cleanupErrorCategory?: string; cleanupErrorCategory?: string;
resultGeneration?: number; resultGeneration?: number;
createdAt: number; createdAt: number;
+260 -183
View File
@@ -25,9 +25,23 @@ import { resetVideoToolingCache } from "../src/main/video-processor";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor"; import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const originalFetch = globalThis.fetch; 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", () => { describe("runWithLimitedConcurrency", () => {
it("processes the full batch without exceeding the configured worker count", async () => { it("processes the full batch without exceeding the configured worker count", async () => {
let active = 0; let active = 0;
@@ -6760,7 +6774,6 @@ describe("download manager", () => {
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -11596,17 +11609,18 @@ describe("download manager", () => {
downloadedBytes: 123, downloadedBytes: 123,
totalBytes: 123, totalBytes: 123,
progressPercent: 100, progressPercent: 100,
fileName: "missing-source-ok.part01.rar", fileName: "missing-source-ok.part01.rar",
targetPath: path.join(outputDir, "missing-source-ok.part01.rar"), targetPath: path.join(outputDir, "missing-source-ok.part01.rar"),
resumable: true, resumable: true,
attempts: 1, attempts: 1,
lastError: "", lastError: "",
fullStatus: "Fertig (123 B)", fullStatus: "Fertig (123 B)",
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const manager = new DownloadManager(
const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
token: "rd-token", 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", "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", "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", "sample.mkv"), "sample-video", "utf8");
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8"); 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 mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -11764,9 +11779,10 @@ describe("download manager", () => {
} = createCompletedArchiveSession(root, packageName, sourceFileName); } = createCompletedArchiveSession(root, packageName, sourceFileName);
session.packages[packageId].status = "completed"; session.packages[packageId].status = "completed";
session.items[itemId].fullStatus = "Entpackt - Done (<1s)"; session.items[itemId].fullStatus = "Entpackt - Done (<1s)";
fs.mkdirSync(extractDir, { recursive: true }); fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(originalExtractedPath, "video", "utf8"); fs.writeFileSync(originalExtractedPath, "video", "utf8");
writePackageOutputOwnerMarker(session.packages[packageId]);
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
@@ -12664,20 +12680,21 @@ describe("download manager", () => {
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 20_000; const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "downloading", status: "downloading",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -12718,28 +12735,29 @@ describe("download manager", () => {
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV"; const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
const epDir = path.join(extractDir, episodeFolder); const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const rawPath = path.join(epDir, rawName); const rawPath = path.join(epDir, rawName);
fs.writeFileSync(rawPath, Buffer.alloc(4096, 7)); fs.writeFileSync(rawPath, Buffer.alloc(4096, 7));
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000; const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "completed", status: "completed",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -12831,30 +12849,31 @@ describe("download manager", () => {
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC"; const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const epDir = path.join(extractDir, episodeFolder); const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
const epName = `${episodeFolder}.mkv`; const epName = `${episodeFolder}.mkv`;
fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9)); fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9));
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000; const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "completed", status: "completed",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -12887,29 +12906,30 @@ describe("download manager", () => {
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(extractDir, { recursive: true }); fs.mkdirSync(extractDir, { recursive: true });
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv"; const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
const bonusName = "Some.Show.Making.Of.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, epName), Buffer.alloc(4096, 1));
fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2)); fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2));
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000; const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "completed", status: "completed",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -12947,29 +12967,30 @@ describe("download manager", () => {
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8)); fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8));
}; };
const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; 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"; const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
mk(folderA, "safari-fm-s04e08a.avi"); mk(folderA, "safari-fm-s04e08a.avi");
mk(folderB, "safari-fm-s04e08b.avi"); mk(folderB, "safari-fm-s04e08b.avi");
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000; const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "completed", status: "completed",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -13003,30 +13024,31 @@ describe("download manager", () => {
const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG"; const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; 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 cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
const epDir = path.join(extractDir, epFolder); const epDir = path.join(extractDir, epFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5)); fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000; const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId]; session.packageOrder = [packageId];
session.packages[packageId] = { session.packages[packageId] = {
id: packageId, id: packageId,
name: packageName, name: packageName,
outputDir, outputDir,
extractDir, extractDir,
status: "completed", status: "completed",
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -13274,13 +13296,10 @@ describe("download manager", () => {
} }
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo")); fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
const s01Mkvs = [ const s01Mkvs = [
"Ugly.Americans.S01E01.German.mkv", "Ugly.Americans.S01E01.German.mkv",
"Ugly.Americans.S01E02.German.mkv" "Ugly.Americans.S01E02.German.mkv"
]; ];
for (const mkv of s01Mkvs) {
fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9));
}
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
@@ -13311,13 +13330,25 @@ describe("download manager", () => {
collectMkvToLibrary: true, collectMkvToLibrary: true,
mkvLibraryDir, mkvLibraryDir,
enableIntegrityCheck: false, enableIntegrityCheck: false,
cleanupMode: "delete" cleanupMode: "delete"
}, },
session, session,
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); 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) { for (const mkv of s01Mkvs) {
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true); 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(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false);
expect(session.packages["package-a"].outputRecords).toEqual([]); 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
@@ -13425,12 +13502,12 @@ describe("download manager", () => {
resumable: true, resumable: true,
attempts: 1, attempts: 1,
lastError: "", lastError: "",
fullStatus: "Fertig (100 MB)", fullStatus: "Fertig (100 MB)",
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
const mkvLibraryDir = path.join(root, "mkv-library"); const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager( new DownloadManager(
{ {
@@ -13516,12 +13593,12 @@ describe("download manager", () => {
resumable: true, resumable: true,
attempts: 1, attempts: 1,
lastError: "", lastError: "",
fullStatus: "Fertig (100 MB)", fullStatus: "Fertig (100 MB)",
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
const mkvLibraryDir = path.join(root, "mkv-library"); const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager( new DownloadManager(
{ {
@@ -13644,12 +13721,12 @@ describe("download manager", () => {
resumable: true, resumable: true,
attempts: 1, attempts: 1,
lastError: "", lastError: "",
fullStatus: "Fertig", fullStatus: "Fertig",
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
const mkvLibraryDir = path.join(root, "mkv-library"); const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager( new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -13723,12 +13800,12 @@ describe("download manager", () => {
resumable: true, resumable: true,
attempts: 1, attempts: 1,
lastError: "", lastError: "",
fullStatus: "Fertig", fullStatus: "Fertig",
createdAt, createdAt,
updatedAt: createdAt updatedAt: createdAt
}; };
const mkvLibraryDir = path.join(root, "mkv-library"); const mkvLibraryDir = path.join(root, "mkv-library");
fs.mkdirSync(mkvLibraryDir, { recursive: true }); fs.mkdirSync(mkvLibraryDir, { recursive: true });
fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video")); fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video"));
+157 -5
View File
@@ -14,7 +14,7 @@ function hasJavaRuntime(): boolean {
return result.status === 0; return result.status === 0;
} }
function hasJvmExtractorRuntime(): boolean { function hasJvmExtractorRuntime(): boolean {
const root = path.join(process.cwd(), "resources", "extractor-jvm"); const root = path.join(process.cwd(), "resources", "extractor-jvm");
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class"); const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
const requiredLibs = [ const requiredLibs = [
@@ -23,7 +23,24 @@ function hasJvmExtractorRuntime(): boolean {
path.join(root, "lib", "zip4j.jar") path.join(root, "lib", "zip4j.jar")
]; ];
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath)); return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
} }
function 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(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
@@ -66,6 +83,14 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
expect(events).toEqual([ 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({ expect.objectContaining({
version: 1, version: 1,
archivePath: path.resolve(zipPath), archivePath: path.resolve(zipPath),
@@ -109,14 +134,141 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
], { encoding: "utf8" }); ], { encoding: "utf8" });
expect(run.status).toBe(0); expect(run.status).toBe(0);
const outputLine = String(run.stdout).split(/\r?\n/).find((line) => line.startsWith("RD_OUTPUT ")); const outputLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_OUTPUT "));
expect(outputLine).toBeTruthy(); expect(outputLines.map((line) => line.split(" ")[2])).toEqual(["opened", "complete"]);
const fields = String(outputLine).split(" "); const fields = outputLines[1].split(" ");
expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]); 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[4], "base64").toString("utf8")).toBe(path.resolve(zipPath));
expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt"); 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")); 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 () => { it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm"; process.env.RD_EXTRACT_BACKEND = "jvm";
+107 -4
View File
@@ -1316,14 +1316,15 @@ describe("extractor", () => {
}); });
expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent); 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, version: 1,
archivePath: path.resolve(archivePath), archivePath: path.resolve(archivePath),
entryPath: "episode.mkv", entryPath: "episode.mkv",
outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"), outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"),
state: "complete", state: "complete",
disposition disposition
})]); }));
expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]); expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]);
}); });
@@ -1462,12 +1463,101 @@ describe("extractor", () => {
} }
})).rejects.toThrow("aborted:extract"); })).rejects.toThrow("aborted:extract");
expect(events).toHaveLength(1); expect(events.map((event) => event.state)).toEqual(["opened", "complete"]);
expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") })); 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, "first.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false); 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", () => { it("strictly parses native output paths and fails closed for ambiguous rename output", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-"));
tempDirs.push(root); 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" })
]);
});
}); });
}); });
+23
View File
@@ -156,4 +156,27 @@ describe("PackageOutputScope", () => {
expect(scope.removePath(targetPath)).toBe(true); expect(scope.removePath(targetPath)).toBe(true);
expect(scope.completeFiles()).toEqual([]); 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([]);
});
}); });