fix(extraction): classify RAR directories without losing collision safety

Use WinRAR's directory-attribute listing as a counted type source so bare folder entries remain valid while file-directory aliases are still rejected.

Emit only removed for successfully deleted JVM partial outputs so asynchronous consumers preserve the original archive error instead of reporting a stale missing file.
This commit is contained in:
Sucukdeluxe
2026-08-23 00:22:03 +02:00
parent e5fa355286
commit 4202640904
20 changed files with 113 additions and 24 deletions
@@ -375,10 +375,11 @@ public final class JBindExtractorMain {
throw error;
} finally {
if (!extractionSuccess && output.exists()) {
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
}
if (!extractionSuccess && output.exists() && output.delete()) {
emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
if (output.delete()) {
emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
} else {
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
}
}
}
}
@@ -1294,13 +1295,18 @@ public final class JBindExtractorMain {
closeCurrentStreamOnly();
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
int pos = currentPos[0];
if (pos >= 0) {
if (currentOutput[0].delete()) {
if (pos >= 0) {
emitOutput(archiveFile, entryNames.get(pos), currentOutput[0], "removed", dispositions.get(pos));
}
} else 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));
}
}
currentOutput[0] = null;
currentPos[0] = -1;
currentSuccess[0] = false;
currentRemaining[0] = 0;
}
}
+35 -10
View File
@@ -2396,17 +2396,17 @@ export function buildExternalExtractArgs(
return ["x", "-y", "-bb1", "-sccUTF-8", overwrite, pass, archivePath, `-o${targetDir}`];
}
export function buildExternalListArgs(command: string, archivePath: string, password = ""): string[] {
export function buildExternalListArgs(command: string, archivePath: string, password = "", directoriesOnly = false): string[] {
if (isRarNativeCommand(command)) {
const pass = password ? `-p${password}` : "-p-";
return ["lb", pass, "-y", archivePath];
return directoriesOnly ? ["lb", "-e+d", pass, "-y", archivePath] : ["lb", pass, "-y", archivePath];
}
const pass = password ? `-p${password}` : "-p";
return ["l", "-slt", "-sccUTF-8", pass, archivePath];
}
export function parseNativeArchiveEntryList(command: string, output: string): string[] {
return parseNativeArchiveEntryCandidates(command, output).map((entry) => (
export function parseNativeArchiveEntryList(command: string, output: string, directoryOutput = ""): string[] {
return parseNativeArchiveEntryCandidates(command, output, directoryOutput).map((entry) => (
entry.isDirectory && !/[\\/]$/.test(entry.entryPath) ? `${entry.entryPath}/` : entry.entryPath
));
}
@@ -2415,13 +2415,25 @@ type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
type NativeEntryPreflightResult = ExtractSpawnResult & { entries: NativeArchiveEntryCandidate[] };
function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] {
function parseNativeArchiveEntryCandidates(command: string, output: string, directoryOutput = ""): NativeArchiveEntryCandidate[] {
const lines = String(output || "").split(/\r?\n/);
if (isRarNativeCommand(command)) {
return lines.filter((line) => line.length > 0).map((entryPath) => ({
entryPath,
isDirectory: /[\\/]$/.test(entryPath)
}));
const directoryCounts = new Map<string, number>();
for (const entryPath of String(directoryOutput || "").split(/\r?\n/).filter((line) => line.length > 0)) {
const key = entryPath.replace(/\\/g, "/").replace(/\/+$/, "").toLocaleLowerCase("en-US");
directoryCounts.set(key, (directoryCounts.get(key) || 0) + 1);
}
return lines.filter((line) => line.length > 0).map((entryPath) => {
const key = entryPath.replace(/\\/g, "/").replace(/\/+$/, "").toLocaleLowerCase("en-US");
const directoryCount = directoryCounts.get(key) || 0;
if (directoryCount > 0) {
directoryCounts.set(key, directoryCount - 1);
}
return {
entryPath,
isDirectory: /[\\/]$/.test(entryPath) || directoryCount > 0
};
});
}
const entries: NativeArchiveEntryCandidate[] = [];
let inEntries = false;
@@ -2573,8 +2585,21 @@ async function runNativeEntryPreflight(
if (!result.ok) {
return { ...result, entries: [] };
}
const directoryChunks: string[] = [];
if (isRarNativeCommand(command)) {
const directoryResult = await runExtractCommand(
command,
buildExternalListArgs(command, archivePath, password, true),
(chunk) => directoryChunks.push(chunk),
signal,
timeoutMs
);
if (!directoryResult.ok) {
return { ...directoryResult, entries: [] };
}
}
try {
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""), directoryChunks.join(""));
if (entries.length === 0) {
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
}
+30 -4
View File
@@ -297,7 +297,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
}, 10000);
it.each(["7zjbinding", "zip4j"])("reports %s partial output before removing a failed file", (backend) => {
it.each(["7zjbinding", "zip4j"])("reports only removed after deleting a failed %s output", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
@@ -333,9 +333,35 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
.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(states).toEqual(["opened", "removed"]);
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
it("preserves the real JVM archive error when a failed output is removed before Node consumes the event", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-removed-output-error-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
const zipPath = path.join(packageDir, "corrupt.zip");
fs.mkdirSync(packageDir, { recursive: true });
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
zip.writeZip(zipPath);
corruptFirstZipPayload(zipPath);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
expect(result.lastError).not.toContain("Gemeldete Extract-Ausgabe existiert nicht");
expect(result.lastError).toMatch(/crc|data|checksum|zip|archive/i);
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
+32
View File
@@ -1930,6 +1930,38 @@ describe("extractor", () => {
].join("\n"))).toEqual(["folder/", "folder/episode.mkv"]);
});
it("classifies bare RAR directory names through the attribute-filtered directory listing", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-directory-list-"));
tempDirs.push(root);
const allEntries = [
"Doona.S01E02.German.DL.720p.WEB.x264-WvF\\doona.s01e02.german.dl.720p.web.x264-wvf.mkv",
"Doona.S01E02.German.DL.720p.WEB.x264-WvF"
].join("\r\n");
const directoryEntries = "Doona.S01E02.German.DL.720p.WEB.x264-WvF\r\n";
expect(buildExternalListArgs("Rar.exe", "archive.rar", "", true)).toEqual([
"lb", "-e+d", "-p-", "-y", "archive.rar"
]);
const parsed = parseNativeArchiveEntryList("Rar.exe", allEntries, directoryEntries);
expect(parsed).toEqual([
"Doona.S01E02.German.DL.720p.WEB.x264-WvF\\doona.s01e02.german.dl.720p.web.x264-wvf.mkv",
"Doona.S01E02.German.DL.720p.WEB.x264-WvF/"
]);
expect(() => validateNativeArchiveEntryCandidates(parsed, root)).not.toThrow();
expect(() => validateNativeArchiveEntryCandidates(
parseNativeArchiveEntryList("Rar.exe", allEntries),
root
)).toThrow(/Datei ist Vorfahr/i);
expect(() => validateNativeArchiveEntryCandidates(
parseNativeArchiveEntryList("Rar.exe", "same\r\nsame\r\n", "same\r\n"),
root
)).toThrow(/mehrfaches|typwidriges/i);
expect(() => validateNativeArchiveEntryCandidates(
parseNativeArchiveEntryList("Rar.exe", "same\r\nsame\r\n", "same\r\nsame\r\n"),
root
)).not.toThrow();
});
it("preflights every internal ZIP entry before overwriting an earlier safe target", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-full-preflight-"));
tempDirs.push(root);