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:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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<String> 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<Integer, Integer> indexToPos;
|
||||
private final List<Integer> fileIndices;
|
||||
private final List<File> outputFiles;
|
||||
@@ -933,7 +940,7 @@ public final class JBindExtractorMain {
|
||||
private final int[] currentPos;
|
||||
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<String> entryNames, List<String> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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> {
|
||||
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 = "";
|
||||
}
|
||||
|
||||
+152
-51
@@ -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<string, ExtractOutputEvent>;
|
||||
};
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<E
|
||||
const outputScope = new PackageOutputScope([options.targetDir]);
|
||||
const emitOutput = (event: ExtractOutputEvent): void => {
|
||||
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<E
|
||||
rememberLearnedPassword(usedPassword);
|
||||
} catch (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 {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
|
||||
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
|
||||
archivePercent = 100;
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToExternalZip(error)) {
|
||||
@@ -3696,7 +3797,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
|
||||
const ext = path.extname(nestedArchive).toLowerCase();
|
||||
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
|
||||
try {
|
||||
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput);
|
||||
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
|
||||
nestedPercent = 100;
|
||||
} catch (zipErr) {
|
||||
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
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 interface ExtractOutputEvent {
|
||||
@@ -13,10 +13,12 @@ export interface ExtractOutputEvent {
|
||||
disposition: ExtractOutputDisposition;
|
||||
}
|
||||
|
||||
export type OwnedExtractOutputEvent = ExtractOutputEvent & { state: "complete" | "partial" };
|
||||
|
||||
export class PackageOutputScope {
|
||||
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[] = []) {
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -602,6 +602,8 @@ export interface PackageEntry {
|
||||
outputProvenance?: string[];
|
||||
outputRecords?: PackageOutputRecord[];
|
||||
outputScopeAdopted?: boolean;
|
||||
outputOwnerId?: string;
|
||||
outputOwnerGeneration?: number;
|
||||
cleanupErrorCategory?: string;
|
||||
resultGeneration?: number;
|
||||
createdAt: number;
|
||||
|
||||
+260
-183
@@ -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"));
|
||||
|
||||
|
||||
+157
-5
@@ -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";
|
||||
|
||||
+107
-4
@@ -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" })
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user