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;
|
||||
} finally {
|
||||
if (!extractionSuccess && output.exists()) {
|
||||
if (output.delete()) {
|
||||
emitOutput(request.archiveFile, entryName, output, "removed", outputTarget.disposition);
|
||||
} else {
|
||||
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();
|
||||
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
|
||||
int pos = currentPos[0];
|
||||
if (currentOutput[0].delete()) {
|
||||
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));
|
||||
}
|
||||
} 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 {
|
||||
|
||||
+34
-9
@@ -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) => ({
|
||||
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)
|
||||
}));
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user