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:
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.
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.
@@ -375,10 +375,11 @@ public final class JBindExtractorMain {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (!extractionSuccess && output.exists()) {
|
if (!extractionSuccess && output.exists()) {
|
||||||
|
if (output.delete()) {
|
||||||
|
emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
|
||||||
|
} else {
|
||||||
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
|
emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition);
|
||||||
}
|
}
|
||||||
if (!extractionSuccess && output.exists() && output.delete()) {
|
|
||||||
emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1294,14 +1295,19 @@ public final class JBindExtractorMain {
|
|||||||
closeCurrentStreamOnly();
|
closeCurrentStreamOnly();
|
||||||
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
|
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
|
||||||
int pos = currentPos[0];
|
int pos = currentPos[0];
|
||||||
|
if (currentOutput[0].delete()) {
|
||||||
if (pos >= 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));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
currentOutput[0] = null;
|
||||||
|
currentPos[0] = -1;
|
||||||
|
currentSuccess[0] = false;
|
||||||
|
currentRemaining[0] = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class WrongPasswordException extends Exception {
|
private static final class WrongPasswordException extends Exception {
|
||||||
|
|||||||
+34
-9
@@ -2396,17 +2396,17 @@ export function buildExternalExtractArgs(
|
|||||||
return ["x", "-y", "-bb1", "-sccUTF-8", overwrite, pass, archivePath, `-o${targetDir}`];
|
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)) {
|
if (isRarNativeCommand(command)) {
|
||||||
const pass = password ? `-p${password}` : "-p-";
|
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";
|
const pass = password ? `-p${password}` : "-p";
|
||||||
return ["l", "-slt", "-sccUTF-8", pass, archivePath];
|
return ["l", "-slt", "-sccUTF-8", pass, archivePath];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNativeArchiveEntryList(command: string, output: string): string[] {
|
export function parseNativeArchiveEntryList(command: string, output: string, directoryOutput = ""): string[] {
|
||||||
return parseNativeArchiveEntryCandidates(command, output).map((entry) => (
|
return parseNativeArchiveEntryCandidates(command, output, directoryOutput).map((entry) => (
|
||||||
entry.isDirectory && !/[\\/]$/.test(entry.entryPath) ? `${entry.entryPath}/` : entry.entryPath
|
entry.isDirectory && !/[\\/]$/.test(entry.entryPath) ? `${entry.entryPath}/` : entry.entryPath
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -2415,13 +2415,25 @@ type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
|
|||||||
|
|
||||||
type NativeEntryPreflightResult = ExtractSpawnResult & { entries: NativeArchiveEntryCandidate[] };
|
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/);
|
const lines = String(output || "").split(/\r?\n/);
|
||||||
if (isRarNativeCommand(command)) {
|
if (isRarNativeCommand(command)) {
|
||||||
return lines.filter((line) => line.length > 0).map((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,
|
entryPath,
|
||||||
isDirectory: /[\\/]$/.test(entryPath)
|
isDirectory: /[\\/]$/.test(entryPath) || directoryCount > 0
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const entries: NativeArchiveEntryCandidate[] = [];
|
const entries: NativeArchiveEntryCandidate[] = [];
|
||||||
let inEntries = false;
|
let inEntries = false;
|
||||||
@@ -2573,8 +2585,21 @@ async function runNativeEntryPreflight(
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return { ...result, entries: [] };
|
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 {
|
try {
|
||||||
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
|
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""), directoryChunks.join(""));
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
||||||
}, 10000);
|
}, 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}-`));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
@@ -333,9 +333,35 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
.map((line) => line.split(" ")[2]);
|
.map((line) => line.split(" ")[2]);
|
||||||
|
|
||||||
expect(run.status).not.toBe(0);
|
expect(run.status).not.toBe(0);
|
||||||
expect(states[0]).toBe("opened");
|
expect(states).toEqual(["opened", "removed"]);
|
||||||
expect(states).toContain("partial");
|
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
|
||||||
expect(states[states.length - 1]).toBe("removed");
|
});
|
||||||
|
|
||||||
|
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);
|
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1930,6 +1930,38 @@ describe("extractor", () => {
|
|||||||
].join("\n"))).toEqual(["folder/", "folder/episode.mkv"]);
|
].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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-full-preflight-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user