fix: reconcile native extraction outputs
Use the validated native archive plan to recognize exact files after successful WinRAR and 7-Zip runs even when progress output contains no parseable completion lines. Preserve extractor-specific rename targets across subst mappings, keep 7-Zip directory paths when RAR flat mode is active, and reject colliding RAR flat targets before extraction.
This commit is contained in:
+183
-14
@@ -2412,6 +2412,8 @@ export function parseNativeArchiveEntryList(command: string, output: string): st
|
||||
|
||||
type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
|
||||
|
||||
type NativeEntryPreflightResult = ExtractSpawnResult & { entries: NativeArchiveEntryCandidate[] };
|
||||
|
||||
function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] {
|
||||
const lines = String(output || "").split(/\r?\n/);
|
||||
if (isRarNativeCommand(command)) {
|
||||
@@ -2472,6 +2474,34 @@ function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCan
|
||||
}
|
||||
}
|
||||
|
||||
function nativeArchiveEntriesForOutputMode(
|
||||
command: string,
|
||||
entries: readonly NativeArchiveEntryCandidate[],
|
||||
flatMode: boolean
|
||||
): NativeArchiveEntryCandidate[] {
|
||||
if (!flatMode || !isRarNativeCommand(command)) {
|
||||
return [...entries];
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => !entry.isDirectory)
|
||||
.map((entry) => ({
|
||||
entryPath: path.posix.basename(entry.entryPath.replace(/\\/g, "/").replace(/\/$/, "")),
|
||||
isDirectory: false
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateNativeFlatArchiveEntryCandidates(
|
||||
command: string,
|
||||
entries: readonly string[],
|
||||
targetDir: string
|
||||
): void {
|
||||
const candidates = entries.map((entryPath) => ({
|
||||
entryPath,
|
||||
isDirectory: /[\\/]$/.test(entryPath)
|
||||
}));
|
||||
validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, candidates, true), targetDir);
|
||||
}
|
||||
|
||||
function dispatchJvmOutputEvent(
|
||||
state: JvmParseState,
|
||||
onOutput: ((event: ExtractOutputEvent) => void) | undefined,
|
||||
@@ -2528,8 +2558,9 @@ async function runNativeEntryPreflight(
|
||||
targetDir: string,
|
||||
password: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number
|
||||
): Promise<ExtractSpawnResult> {
|
||||
timeoutMs: number,
|
||||
flatMode: boolean
|
||||
): Promise<NativeEntryPreflightResult> {
|
||||
const chunks: string[] = [];
|
||||
const result = await runExtractCommand(
|
||||
command,
|
||||
@@ -2539,7 +2570,7 @@ async function runNativeEntryPreflight(
|
||||
timeoutMs
|
||||
);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
return { ...result, entries: [] };
|
||||
}
|
||||
try {
|
||||
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
|
||||
@@ -2547,18 +2578,126 @@ async function runNativeEntryPreflight(
|
||||
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
||||
}
|
||||
validateNativeArchiveTargetPlan(entries, targetDir);
|
||||
return result;
|
||||
if (flatMode && isRarNativeCommand(command)) {
|
||||
validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, entries, true), targetDir);
|
||||
}
|
||||
return { ...result, entries };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
missingCommand: false,
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
errorText: cleanErrorText(String(error))
|
||||
errorText: cleanErrorText(String(error)),
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function existingNativeOutputKeys(
|
||||
command: string,
|
||||
entries: readonly NativeArchiveEntryCandidate[],
|
||||
targetDir: string,
|
||||
conflictMode: ConflictMode,
|
||||
flatMode: boolean
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const extractsFlat = flatMode && isRarNativeCommand(command);
|
||||
for (const candidate of entries) {
|
||||
if (candidate.isDirectory) {
|
||||
continue;
|
||||
}
|
||||
const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath;
|
||||
const outputPath = path.resolve(targetDir, ...entryPath.split("/"));
|
||||
try {
|
||||
const stat = fs.lstatSync(outputPath);
|
||||
if (stat.isFile() && !stat.isSymbolicLink()) {
|
||||
keys.add(pathSetKey(outputPath));
|
||||
if (mode === "rename") {
|
||||
const pattern = nativeRenameOutputPattern(command, path.basename(outputPath));
|
||||
for (const entry of fs.readdirSync(path.dirname(outputPath), { withFileTypes: true })) {
|
||||
if (entry.isFile() && pattern.test(entry.name)) {
|
||||
keys.add(pathSetKey(path.join(path.dirname(outputPath), entry.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function nativeRenameOutputPattern(command: string, fileName: string): RegExp {
|
||||
const parsed = path.parse(fileName);
|
||||
const suffix = extractorCommandKind(command) === "seven_zip" ? "_\\d+" : "\\(\\d+\\)";
|
||||
return new RegExp(`^${escapeRegex(parsed.name)}${suffix}${escapeRegex(parsed.ext)}$`, "i");
|
||||
}
|
||||
|
||||
export function reconcileNativeExtractOutputs(
|
||||
command: string,
|
||||
entries: readonly { entryPath: string; isDirectory: boolean }[],
|
||||
archivePath: string,
|
||||
targetDir: string,
|
||||
conflictMode: ConflictMode,
|
||||
existingBefore: ReadonlySet<string>,
|
||||
flatMode = false
|
||||
): ExtractOutputEvent[] {
|
||||
validateNativeArchiveTargetPlan(entries, targetDir);
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const extractsFlat = flatMode && isRarNativeCommand(command);
|
||||
const scope = new PackageOutputScope([targetDir]);
|
||||
const events: ExtractOutputEvent[] = [];
|
||||
for (const candidate of entries) {
|
||||
if (candidate.isDirectory) {
|
||||
continue;
|
||||
}
|
||||
const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath;
|
||||
const baseOutputPath = path.resolve(targetDir, ...entryPath.split("/"));
|
||||
const existed = existingBefore.has(pathSetKey(baseOutputPath));
|
||||
let outputPath = baseOutputPath;
|
||||
if (mode === "rename" && existed) {
|
||||
const pattern = nativeRenameOutputPattern(command, path.basename(baseOutputPath));
|
||||
let renamedCandidates: string[] = [];
|
||||
try {
|
||||
renamedCandidates = fs.readdirSync(path.dirname(baseOutputPath), { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
||||
.map((entry) => path.join(path.dirname(baseOutputPath), entry.name))
|
||||
.filter((candidatePath) => !existingBefore.has(pathSetKey(candidatePath)));
|
||||
} catch {
|
||||
}
|
||||
if (renamedCandidates.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
[outputPath] = renamedCandidates;
|
||||
}
|
||||
const event: ExtractOutputEvent = {
|
||||
version: 1,
|
||||
archivePath: path.resolve(archivePath),
|
||||
entryPath,
|
||||
outputPath,
|
||||
state: "complete",
|
||||
disposition: mode === "rename" && existed
|
||||
? "renamed"
|
||||
: mode === "skip" && existed
|
||||
? "skipped"
|
||||
: mode === "overwrite" && existed
|
||||
? "overwritten"
|
||||
: "written"
|
||||
};
|
||||
try {
|
||||
if (event.disposition === "skipped" || scope.add(event)) {
|
||||
events.push(event);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function parseNativeExtractOutput(
|
||||
command: string,
|
||||
line: string,
|
||||
@@ -2614,6 +2753,24 @@ export function parseNativeExtractOutput(
|
||||
}
|
||||
}
|
||||
|
||||
export function remapNativeSubstOutput(
|
||||
event: ExtractOutputEvent,
|
||||
effectiveTargetDir: string,
|
||||
targetDir: string
|
||||
): ExtractOutputEvent {
|
||||
const relativePath = path.relative(path.resolve(effectiveTargetDir), path.resolve(event.outputPath));
|
||||
if (!relativePath
|
||||
|| relativePath === ".."
|
||||
|| relativePath.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relativePath)) {
|
||||
throw new Error(`Ungültiger subst-Ausgabepfad: ${event.outputPath}`);
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
outputPath: path.resolve(targetDir, relativePath)
|
||||
};
|
||||
}
|
||||
|
||||
function failDaemonOutputCallback(req: DaemonRequest): void {
|
||||
if (daemonCurrentRequest !== req || !req.parseState.outputError || req.terminationStarted) {
|
||||
return;
|
||||
@@ -2695,12 +2852,20 @@ async function runExternalExtractInner(
|
||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||||
let createErrorText = "";
|
||||
let createErrorPassword = "";
|
||||
const runNativeAttempt = async (args: string[], password: string): Promise<ExtractSpawnResult> => {
|
||||
const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs);
|
||||
const runNativeAttempt = async (args: string[], password: string, flatMode = false): Promise<ExtractSpawnResult> => {
|
||||
const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs, flatMode);
|
||||
if (!preflight.ok) {
|
||||
return preflight;
|
||||
}
|
||||
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput);
|
||||
const existingBefore = existingNativeOutputKeys(command, preflight.entries, targetDir, conflictMode, flatMode);
|
||||
const reportedOutputs = new Set<string>();
|
||||
const emitNativeOutput = (event: ExtractOutputEvent): void => {
|
||||
if (event.state === "complete" && event.disposition !== "skipped") {
|
||||
reportedOutputs.add(pathSetKey(event.outputPath));
|
||||
}
|
||||
onOutput?.(event);
|
||||
};
|
||||
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, emitNativeOutput);
|
||||
const result = await runExtractCommand(command, args, (chunk) => {
|
||||
outputs.push(chunk);
|
||||
const parsed = parseProgressPercent(chunk);
|
||||
@@ -2714,6 +2879,13 @@ async function runExternalExtractInner(
|
||||
}
|
||||
}, signal, timeoutMs);
|
||||
outputs.finish(result.ok ? "complete" : "partial");
|
||||
if (result.ok) {
|
||||
for (const event of reconcileNativeExtractOutputs(command, preflight.entries, archivePath, targetDir, conflictMode, existingBefore, flatMode)) {
|
||||
if (!reportedOutputs.has(pathSetKey(event.outputPath))) {
|
||||
onOutput?.(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2726,7 +2898,7 @@ async function runExternalExtractInner(
|
||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||
const result = await runNativeAttempt(args, password);
|
||||
const result = await runNativeAttempt(args, password, true);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||
@@ -2824,7 +2996,7 @@ async function runExternalExtractInner(
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||
const result = await runNativeAttempt(args, password);
|
||||
const result = await runNativeAttempt(args, password, true);
|
||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||
@@ -2935,10 +3107,7 @@ async function runExternalExtract(
|
||||
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||
}
|
||||
const legacyOnOutput = subst && onOutput
|
||||
? (event: ExtractOutputEvent): void => onOutput({
|
||||
...event,
|
||||
outputPath: path.resolve(targetDir, ...event.entryPath.split("/"))
|
||||
})
|
||||
? (event: ExtractOutputEvent): void => onOutput(remapNativeSubstOutput(event, effectiveTargetDir, targetDir))
|
||||
: onOutput;
|
||||
|
||||
const command = await resolveExtractorCommand(archivePath);
|
||||
|
||||
@@ -21,10 +21,13 @@ import {
|
||||
orderExtractorCandidatesForArchive,
|
||||
parseNativeExtractOutput,
|
||||
parseNativeArchiveEntryList,
|
||||
remapNativeSubstOutput,
|
||||
reconcileNativeExtractOutputs,
|
||||
resolveExtractorBackendModeForArchive,
|
||||
resolveExtractorBackendMode,
|
||||
shouldFallbackLegacyRarToJvm,
|
||||
validateNativeArchiveEntryCandidates,
|
||||
validateNativeFlatArchiveEntryCandidates,
|
||||
} from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -1731,6 +1734,56 @@ describe("extractor", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a native renamed filename when remapping a subst output", () => {
|
||||
const targetDir = "C:\\Downloads\\Extracted";
|
||||
const event = remapNativeSubstOutput({
|
||||
version: 1,
|
||||
archivePath: "C:\\Downloads\\release.rar",
|
||||
entryPath: "episode.mkv",
|
||||
outputPath: "Z:\\episode(2).mkv",
|
||||
state: "complete",
|
||||
disposition: "renamed"
|
||||
}, "Z:\\", targetDir);
|
||||
|
||||
expect(event.outputPath).toBe(path.resolve(targetDir, "episode(2).mkv"));
|
||||
expect(event.entryPath).toBe("episode.mkv");
|
||||
});
|
||||
|
||||
it("keeps native 7-Zip output directories when a previous RAR requested flat mode", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-seven-flat-"));
|
||||
tempDirs.push(root);
|
||||
const targetDir = path.join(root, "out");
|
||||
const outputPath = path.join(targetDir, "folder", "episode.mkv");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, "video");
|
||||
|
||||
const events = reconcileNativeExtractOutputs(
|
||||
"7z.exe",
|
||||
[{ entryPath: "folder/episode.mkv", isDirectory: false }],
|
||||
path.join(root, "release.7z"),
|
||||
targetDir,
|
||||
"overwrite",
|
||||
new Set(),
|
||||
true
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath })
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects colliding RAR flat-mode basenames before extraction", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-flat-collision-"));
|
||||
tempDirs.push(root);
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
expect(() => validateNativeFlatArchiveEntryCandidates("Rar.exe", [
|
||||
"folder-a/episode.mkv",
|
||||
"folder-b/episode.mkv"
|
||||
], targetDir)).toThrow(/Kollision/i);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1744,6 +1797,72 @@ describe("extractor", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reconciles verified native outputs when WinRAR emits no parseable completion line", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-reconcile-"));
|
||||
tempDirs.push(root);
|
||||
const targetDir = path.join(root, "out");
|
||||
const archivePath = path.join(root, "release.part1.rar");
|
||||
const outputPath = path.join(targetDir, "folder", "episode.mkv");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, "video");
|
||||
|
||||
const events = reconcileNativeExtractOutputs(
|
||||
"Rar.exe",
|
||||
[{ entryPath: "folder/", isDirectory: true }, { entryPath: "folder/episode.mkv", isDirectory: false }],
|
||||
archivePath,
|
||||
targetDir,
|
||||
"overwrite",
|
||||
new Set()
|
||||
);
|
||||
|
||||
expect(events).toEqual([{
|
||||
version: 1,
|
||||
archivePath: path.resolve(archivePath),
|
||||
entryPath: "folder/episode.mkv",
|
||||
outputPath,
|
||||
state: "complete",
|
||||
disposition: "written"
|
||||
}]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Rar.exe", "episode(1).mkv", "episode(2).mkv"],
|
||||
["7z.exe", "episode_1.mkv", "episode_2.mkv"]
|
||||
])("reconciles the newly renamed %s output without claiming an older collision", (command, firstRenamedName, newRenamedName) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rename-reconcile-"));
|
||||
tempDirs.push(root);
|
||||
const targetDir = path.join(root, "out");
|
||||
const archivePath = path.join(root, "release.rar");
|
||||
const basePath = path.join(targetDir, "episode.mkv");
|
||||
const firstRenamedPath = path.join(targetDir, firstRenamedName);
|
||||
const newRenamedPath = path.join(targetDir, newRenamedName);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(basePath, "foreign-base");
|
||||
fs.writeFileSync(firstRenamedPath, "foreign-renamed");
|
||||
const existingBefore = new Set([basePath, firstRenamedPath].map((filePath) => (
|
||||
process.platform === "win32" ? path.resolve(filePath).toLowerCase() : path.resolve(filePath)
|
||||
)));
|
||||
fs.writeFileSync(newRenamedPath, "owned");
|
||||
|
||||
const events = reconcileNativeExtractOutputs(
|
||||
command,
|
||||
[{ entryPath: "episode.mkv", isDirectory: false }],
|
||||
archivePath,
|
||||
targetDir,
|
||||
"rename",
|
||||
existingBefore
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
entryPath: "episode.mkv",
|
||||
outputPath: newRenamedPath,
|
||||
state: "complete",
|
||||
disposition: "renamed"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["file.mkv:stream", "file.mkv"],
|
||||
["name.", "name"],
|
||||
|
||||
Reference in New Issue
Block a user