fix: close Windows extraction namespace gaps

Reject alternate data streams, reserved Win32 device names, and trailing-dot or trailing-space aliases before internal ZIP, JVM, or native extraction writes. Preflight native archive entry lists, reconcile opened JVM outputs to partial or removed state on abnormal results, and replace hardlink-based owner markers with portable exclusive reservation plus atomic replacement while allowing direct scoped extraction to continue when marker persistence is unavailable.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:40:59 +02:00
parent 11d63dd2be
commit 6724b3605c
19 changed files with 566 additions and 54 deletions
@@ -668,20 +668,25 @@ public final class JBindExtractorMain {
return output; return output;
} }
private static String normalizeEntryName(String value, String fallback) { private static String normalizeEntryName(String value, String fallback) {
String entry = value == null ? "" : value.trim(); String entry = value == null ? "" : value;
if (entry.length() == 0) { if (entry.trim().length() == 0) {
return fallback; return fallback;
} }
entry = entry.replace('\\', '/'); entry = entry.replace('\\', '/');
while (entry.startsWith("./")) { while (entry.startsWith("./")) {
entry = entry.substring(2); entry = entry.substring(2);
} }
if (entry.length() == 0) { if (entry.length() == 0) {
return fallback; return fallback;
} }
String[] segments = entry.split("/", -1); while (entry.endsWith("/")) {
entry = entry.substring(0, entry.length() - 1);
}
validateWindowsEntryName(entry);
String[] segments = entry.split("/", -1);
StringBuilder sanitized = new StringBuilder(); StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < segments.length; i++) { for (int i = 0; i < segments.length; i++) {
if (i > 0) { if (i > 0) {
@@ -693,8 +698,33 @@ public final class JBindExtractorMain {
if (entry.length() == 0) { if (entry.length() == 0) {
return fallback; return fallback;
} }
return entry; return entry;
} }
private static void validateWindowsEntryName(String entry) {
if (entry.startsWith("/") || entry.matches("^[a-zA-Z]:.*")) {
throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry);
}
String[] segments = entry.split("/", -1);
for (String segment : segments) {
if (segment.length() == 0 || ".".equals(segment) || "..".equals(segment)
|| segment.endsWith(".") || segment.endsWith(" ")
|| WINDOWS_SPECIAL_CHARS_RE.matcher(segment).find()) {
throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry);
}
for (int i = 0; i < segment.length(); i++) {
if (segment.charAt(i) < 32) {
throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry);
}
}
int dot = segment.indexOf('.');
String base = (dot >= 0 ? segment.substring(0, dot) : segment).toUpperCase(Locale.ROOT);
if ("CON".equals(base) || "PRN".equals(base) || "AUX".equals(base) || "NUL".equals(base)
|| base.matches("COM[1-9¹²³]") || base.matches("LPT[1-9¹²³]")) {
throw new IllegalArgumentException("Reservierter Windows-Archivpfad: " + entry);
}
}
}
private static long safeSize(Long value) { private static long safeSize(Long value) {
if (value == null) { if (value == null) {
+23 -10
View File
@@ -4477,19 +4477,26 @@ export class DownloadManager extends EventEmitter {
private async writePackageOutputOwnerMarkerAtomic(pkg: PackageEntry, marker: PackageOutputOwnerMarker): Promise<void> { private async writePackageOutputOwnerMarkerAtomic(pkg: PackageEntry, marker: PackageOutputOwnerMarker): Promise<void> {
const markerPath = this.packageOutputOwnerMarkerPath(pkg); const markerPath = this.packageOutputOwnerMarkerPath(pkg);
const tempPath = path.join(pkg.extractDir, `.${PACKAGE_OUTPUT_OWNER_MARKER}.${uuidv4()}.tmp`); const tempPath = path.join(pkg.extractDir, `.${PACKAGE_OUTPUT_OWNER_MARKER}.${uuidv4()}.tmp`);
const handle = await fs.promises.open(tempPath, "wx"); const reservation = await fs.promises.open(markerPath, "wx");
try { try {
await handle.writeFile(JSON.stringify(marker), "utf8"); await reservation.writeFile("{}", "utf8");
await handle.sync(); await reservation.sync();
} finally { await reservation.close();
await handle.close(); const temp = await fs.promises.open(tempPath, "wx");
} try {
try { await temp.writeFile(JSON.stringify(marker), "utf8");
await fs.promises.link(tempPath, markerPath); await temp.sync();
await fs.promises.rm(tempPath, { force: true }); } finally {
await temp.close().catch(() => {});
}
await fs.promises.rename(tempPath, markerPath);
} catch (error) { } catch (error) {
await reservation.close().catch(() => {});
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); await fs.promises.rm(tempPath, { force: true }).catch(() => {});
await fs.promises.rm(markerPath, { force: true }).catch(() => {});
throw error; throw error;
} finally {
await reservation.close().catch(() => {});
} }
} }
@@ -4638,7 +4645,13 @@ export class DownloadManager extends EventEmitter {
const scope = this.getPackageOutputScope(pkg); const scope = this.getPackageOutputScope(pkg);
try { try {
await fs.promises.mkdir(pkg.extractDir, { recursive: true }); await fs.promises.mkdir(pkg.extractDir, { recursive: true });
await this.ensurePackageOutputOwnerMarker(pkg); try {
await this.ensurePackageOutputOwnerMarker(pkg);
} catch (error) {
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
logger.warn(`Output-Owner-Marker nicht verfügbar: pkg=${pkg.id}, reason=${compactErrorText(error)}`);
}
return await operation(pkg.extractDir, scope); return await operation(pkg.extractDir, scope);
} finally { } finally {
if (!packageWasInSession || this.session.packages[pkg.id] === pkg) { if (!packageWasInSession || this.session.packages[pkg.id] === pkg) {
+190 -26
View File
@@ -214,6 +214,7 @@ interface DaemonRequest {
startedAt: number; startedAt: number;
passwordCount: number; passwordCount: number;
onOutput?: (event: ExtractOutputEvent) => void; onOutput?: (event: ExtractOutputEvent) => void;
targetDir: string;
} }
const activeSubstDrives = new Set<string>(); const activeSubstDrives = new Set<string>();
@@ -1629,17 +1630,10 @@ function parseJvmLine(
state.openedOutputs ||= new Map<string, ExtractOutputEvent>(); state.openedOutputs ||= new Map<string, ExtractOutputEvent>();
if (event.state === "opened") { if (event.state === "opened") {
state.openedOutputs.set(outputKey, event); state.openedOutputs.set(outputKey, event);
} else if (event.state === "complete" || event.state === "removed") { } else if (event.state === "complete" || event.state === "partial" || event.state === "removed") {
state.openedOutputs.delete(outputKey); state.openedOutputs.delete(outputKey);
} }
if (!state.outputError) { dispatchJvmOutputEvent(state, onOutput, event);
try {
onOutput?.(event);
} catch (error) {
state.outputError = error instanceof Error ? error : new Error(String(error));
state.reportedError = state.outputError.message;
}
}
return; return;
} }
@@ -1676,10 +1670,27 @@ export function shutdownDaemon(): void {
daemonLayout = null; daemonLayout = null;
} }
function finishDaemonRequest(result: JvmExtractResult): void { function finishDaemonRequest(result: JvmExtractResult): void {
const req = daemonCurrentRequest; const req = daemonCurrentRequest;
if (!req) return; if (!req) return;
daemonCurrentRequest = null; const openedCount = reconcileJvmOpenedOutputs(req.parseState, req.onOutput, req.targetDir);
let finalResult = result;
if (req.parseState.outputError) {
finalResult = {
...result,
ok: false,
aborted: false,
timedOut: false,
errorText: cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError))
};
} else if (result.ok && openedCount > 0) {
finalResult = {
...result,
ok: false,
errorText: "JVM-Output blieb ohne Abschlussstatus"
};
}
daemonCurrentRequest = null;
daemonBusy = false; daemonBusy = false;
daemonStdoutBuffer = ""; daemonStdoutBuffer = "";
daemonStderrBuffer = ""; daemonStderrBuffer = "";
@@ -1689,7 +1700,7 @@ function finishDaemonRequest(result: JvmExtractResult): void {
req.signal.removeEventListener("abort", daemonAbortHandler); req.signal.removeEventListener("abort", daemonAbortHandler);
daemonAbortHandler = null; daemonAbortHandler = null;
} }
req.resolve(result); req.resolve(finalResult);
} }
function flushDaemonParseBuffers(req: DaemonRequest | null): void { function flushDaemonParseBuffers(req: DaemonRequest | null): void {
@@ -1908,7 +1919,8 @@ function sendDaemonRequest(
archiveName, archiveName,
startedAt: Date.now(), startedAt: Date.now(),
passwordCount: passwordCandidates.length, passwordCount: passwordCandidates.length,
onOutput onOutput,
targetDir
}; };
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`); logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
@@ -2072,11 +2084,28 @@ async function runJvmExtractCommand(
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
}; };
const finish = (result: JvmExtractResult): void => { const finish = (result: JvmExtractResult): void => {
if (settled) { if (settled) {
return; return;
} }
settled = true; const openedCount = reconcileJvmOpenedOutputs(parseState, onOutput, targetDir);
let finalResult = result;
if (parseState.outputError) {
finalResult = {
...result,
ok: false,
aborted: false,
timedOut: false,
errorText: cleanErrorText(parseState.outputError.message || String(parseState.outputError))
};
} else if (result.ok && openedCount > 0) {
finalResult = {
...result,
ok: false,
errorText: "JVM-Output blieb ohne Abschlussstatus"
};
}
settled = true;
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null; timeoutId = null;
@@ -2085,7 +2114,7 @@ async function runJvmExtractCommand(
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
} }
cleanupTmpDir(); cleanupTmpDir();
resolve(result); resolve(finalResult);
}; };
if (timeoutMs && timeoutMs > 0) { if (timeoutMs && timeoutMs > 0) {
@@ -2216,6 +2245,137 @@ 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[] {
if (isRarNativeCommand(command)) {
const pass = password ? `-p${password}` : "-p-";
return ["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[] {
const lines = String(output || "").split(/\r?\n/);
if (isRarNativeCommand(command)) {
return lines.map((line) => line.trim()).filter(Boolean);
}
const entries: string[] = [];
let inEntries = false;
for (const line of lines) {
if (/^-{8,}\s*$/.test(line.trim())) {
inEntries = true;
continue;
}
if (!inEntries) {
continue;
}
const match = line.match(/^Path = (.*)$/);
if (match?.[1]) {
entries.push(match[1]);
}
}
return entries;
}
export function validateNativeArchiveEntryCandidates(entries: readonly string[], targetDir: string): void {
const scope = new PackageOutputScope([targetDir]);
for (const rawEntry of entries) {
const entryPath = String(rawEntry || "").replace(/\\/g, "/").replace(/\/$/, "");
if (!entryPath) {
continue;
}
const outputPath = path.resolve(targetDir, ...entryPath.split("/"));
scope.validateTarget(entryPath, outputPath);
}
}
function dispatchJvmOutputEvent(
state: JvmParseState,
onOutput: ((event: ExtractOutputEvent) => void) | undefined,
event: ExtractOutputEvent
): void {
if (state.outputError) {
return;
}
try {
onOutput?.(event);
} catch (error) {
state.outputError = error instanceof Error ? error : new Error(String(error));
state.reportedError = state.outputError.message;
}
}
function reconcileJvmOpenedOutputs(
state: JvmParseState,
onOutput: ((event: ExtractOutputEvent) => void) | undefined,
targetDir: string
): number {
const opened = [...(state.openedOutputs?.values() || [])];
state.openedOutputs?.clear();
if (state.outputError || opened.length === 0) {
return opened.length;
}
const validator = new PackageOutputScope([targetDir]);
for (const event of opened) {
let next: ExtractOutputEvent = { ...event, state: "removed" };
try {
const stat = fs.lstatSync(event.outputPath);
if (stat.isFile() && !stat.isSymbolicLink()) {
next = { ...event, state: "partial" };
}
validator.add(next);
} catch {
next = { ...event, state: "removed" };
try {
validator.add(next);
} catch (error) {
state.outputError = error instanceof Error ? error : new Error(String(error));
state.reportedError = state.outputError.message;
return opened.length;
}
}
dispatchJvmOutputEvent(state, onOutput, next);
}
return opened.length;
}
async function runNativeEntryPreflight(
command: string,
archivePath: string,
targetDir: string,
password: string,
signal: AbortSignal | undefined,
timeoutMs: number
): Promise<ExtractSpawnResult> {
const chunks: string[] = [];
const result = await runExtractCommand(
command,
buildExternalListArgs(command, archivePath, password),
(chunk) => chunks.push(chunk),
signal,
timeoutMs
);
if (!result.ok) {
return result;
}
try {
const entries = parseNativeArchiveEntryList(command, chunks.join(""));
if (entries.length === 0) {
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
}
validateNativeArchiveEntryCandidates(entries, targetDir);
return result;
} catch (error) {
return {
ok: false,
missingCommand: false,
aborted: false,
timedOut: false,
errorText: cleanErrorText(String(error))
};
}
}
export function parseNativeExtractOutput( export function parseNativeExtractOutput(
command: string, command: string,
line: string, line: string,
@@ -2359,7 +2519,11 @@ async function runExternalExtractInner(
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText); const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
let createErrorText = ""; let createErrorText = "";
let createErrorPassword = ""; let createErrorPassword = "";
const runNativeAttempt = async (args: string[]): Promise<ExtractSpawnResult> => { const runNativeAttempt = async (args: string[], password: string): Promise<ExtractSpawnResult> => {
const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs);
if (!preflight.ok) {
return preflight;
}
const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput); const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput);
const result = await runExtractCommand(command, args, (chunk) => { const result = await runExtractCommand(command, args, (chunk) => {
outputs.push(chunk); outputs.push(chunk);
@@ -2386,7 +2550,7 @@ async function runExternalExtractInner(
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`); 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>)`); 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 args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
const result = await runNativeAttempt(args); const result = await runNativeAttempt(args, password);
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); 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}`); 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; } if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
@@ -2413,7 +2577,7 @@ async function runExternalExtractInner(
onPasswordAttempt?.(passwordAttempt, passwords.length); onPasswordAttempt?.(passwordAttempt, passwords.length);
} }
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
let result = await runNativeAttempt(args); let result = await runNativeAttempt(args, password);
if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) {
usePerformanceFlags = false; usePerformanceFlags = false;
@@ -2421,7 +2585,7 @@ async function runExternalExtractInner(
onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode);
result = await runNativeAttempt(args); result = await runNativeAttempt(args, password);
} }
logger.info( logger.info(
@@ -2484,7 +2648,7 @@ async function runExternalExtractInner(
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`); 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>`); 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 args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
const result = await runNativeAttempt(args); const result = await runNativeAttempt(args, password);
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); 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}`); 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; } if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
+16 -1
View File
@@ -37,8 +37,20 @@ export class PackageOutputScope {
return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US"); return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US");
} }
private validateWindowsSegments(segments: readonly string[], sourcePath: string): void {
for (const segment of segments) {
const reservedBase = segment.split(".", 1)[0];
if (segment.includes(":")
|| /[<>"|?*\u0000-\u001f]/.test(segment)
|| /[. ]$/.test(segment)
|| /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i.test(reservedBase)) {
throw new Error(`Ungültiger Win32-Entry-Ausgabepfad: ${sourcePath}`);
}
}
}
private validateEntryPath(entryPath: string): string { private validateEntryPath(entryPath: string): string {
const normalized = String(entryPath || "").trim().replace(/\\/g, "/"); const normalized = String(entryPath || "").replace(/\\/g, "/");
const segments = normalized.split("/"); const segments = normalized.split("/");
if (!normalized if (!normalized
|| normalized.startsWith("/") || normalized.startsWith("/")
@@ -47,6 +59,7 @@ export class PackageOutputScope {
|| segments.some((segment) => segment === ".." || segment === "")) { || segments.some((segment) => segment === ".." || segment === "")) {
throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`); throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`);
} }
this.validateWindowsSegments(segments, entryPath);
return segments.filter((segment) => segment !== ".").join("/"); return segments.filter((segment) => segment !== ".").join("/");
} }
@@ -131,6 +144,8 @@ export class PackageOutputScope {
} }
const normalizedOutputPath = path.resolve(outputPath); const normalizedOutputPath = path.resolve(outputPath);
const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath); const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath);
const relativeOutputPath = path.relative(authorizedRoot, normalizedOutputPath).replace(/\\/g, "/");
this.validateWindowsSegments(relativeOutputPath.split("/"), outputPath);
this.rejectLinkedPath(normalizedOutputPath, authorizedRoot); this.rejectLinkedPath(normalizedOutputPath, authorizedRoot);
return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath }; return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath };
} }
+130
View File
@@ -13451,6 +13451,136 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false);
}); });
it("creates a package owner marker without hardlink support", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-no-link-"));
tempDirs.push(root);
const packageName = "no-link-package";
const session = emptySession();
const packageId = "no-link-package-id";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir: path.join(root, "downloads", packageName),
extractDir: path.join(root, "extract", packageName),
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const linkSpy = vi.spyOn(fs.promises, "link").mockRejectedValue(Object.assign(new Error("link unsupported"), { code: "ENOTSUP" }));
try {
await expect((manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).resolves.toBe(true);
expect(fs.existsSync(path.join(session.packages[packageId].extractDir, ".rd-package-output-owner-v1.json"))).toBe(true);
} finally {
linkSpy.mockRestore();
}
});
it("continues direct scoped output when owner marker creation is denied", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-denied-"));
tempDirs.push(root);
const packageName = "denied-marker-package";
const session = emptySession();
const packageId = "denied-marker-package-id";
const pkg: PackageEntry = {
id: packageId,
name: packageName,
outputDir: path.join(root, "downloads", packageName),
extractDir: path.join(root, "extract", packageName),
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
session.packageOrder = [packageId];
session.packages[packageId] = pkg;
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const originalOpen = fs.promises.open.bind(fs.promises);
const openSpy = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath: any, ...args: any[]) => {
if (String(filePath).includes("rd-package-output-owner-v1")) {
throw Object.assign(new Error("marker denied"), { code: "EPERM" });
}
return originalOpen(filePath, ...(args as [any, any]));
});
try {
await expect((manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => {
const outputPath = path.join(targetDir, "owned.mkv");
fs.writeFileSync(outputPath, "owned");
scope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: "owned.mkv",
outputPath,
state: "complete",
disposition: "written"
});
})).resolves.toBeUndefined();
} finally {
openSpy.mockRestore();
}
expect(fs.readFileSync(path.join(pkg.extractDir, "owned.mkv"), "utf8")).toBe("owned");
expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]);
expect(fs.existsSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"))).toBe(false);
});
it("rejects a replayed owner marker from an older package generation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-replay-"));
tempDirs.push(root);
const packageName = "replay-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 ownerId = crypto.randomUUID().toLowerCase();
fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({
version: 1,
packageId: "replay-package-id",
generation: 1,
ownerId
}));
const session = emptySession();
const packageId = "replay-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: [],
outputOwnerId: ownerId,
outputOwnerGeneration: 1,
resultGeneration: 2,
createdAt: 1_000,
updatedAt: 1_000
};
const manager = new DownloadManager(
{ ...defaultSettings(), autoExtract: 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([]);
});
it("does NOT move bonus files from Extras subdirectory to flat library", async () => { it("does NOT move bonus files from Extras subdirectory to flat library", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
+91
View File
@@ -269,6 +269,97 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(states[states.length - 1]).toBe("removed"); expect(states[states.length - 1]).toBe("removed");
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false); expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
}); });
it.each([
["7zjbinding", "file.mkv:stream", "file.mkv"],
["7zjbinding", "name.", "name"],
["7zjbinding", "name ", "name"],
["7zjbinding", "CON", "safe-base.txt"],
["7zjbinding", "aux.txt", "safe-base.txt"],
["7zjbinding", "folder/LPT1.mkv", "safe-base.txt"],
["zip4j", "file.mkv:stream", "file.mkv"],
["zip4j", "name.", "name"],
["zip4j", "name ", "name"],
["zip4j", "CON", "safe-base.txt"],
["zip4j", "aux.txt", "safe-base.txt"],
["zip4j", "folder/LPT1.mkv", "safe-base.txt"]
] as const)("rejects %s Win32-unsafe entry %s before changing its alias", (backend, entryName, baseName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-win32-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const basePath = path.join(targetDir, baseName);
fs.writeFileSync(basePath, "foreign");
const zipPath = path.join(root, "unsafe.zip");
const zip = new AdmZip();
zip.addFile(entryName, 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(basePath, "utf8")).toBe("foreign");
});
it("reconciles a real aborted JVM opened output to partial or removed", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-abort-output-"));
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.bin", Buffer.alloc(8 * 1024 * 1024, 7));
zip.writeZip(path.join(packageDir, "large.zip"));
const controller = new AbortController();
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const extraction = extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
signal: controller.signal,
onOutput: (event) => {
events.push(event);
if (event.state === "opened") {
controller.abort();
}
}
});
await expect(extraction).rejects.toThrow("aborted:extract");
expect(events[0]?.state).toBe("opened");
expect(["partial", "removed"]).toContain(events[events.length - 1]?.state);
const outputPath = path.join(targetDir, "episode.bin");
if (events[events.length - 1]?.state === "partial") {
expect(fs.statSync(outputPath).isFile()).toBe(true);
} else {
expect(fs.existsSync(outputPath)).toBe(false);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}, 10000);
it("emits progress callbacks with archiveName and percent", async () => { it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm"; process.env.RD_EXTRACT_BACKEND = "jvm";
+66 -3
View File
@@ -4,7 +4,8 @@ import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
buildExternalExtractArgs, buildExternalExtractArgs,
buildExternalListArgs,
cleanErrorText, cleanErrorText,
collectArchiveCleanupTargets, collectArchiveCleanupTargets,
extractPackageArchives, extractPackageArchives,
@@ -18,9 +19,11 @@ import {
findArchiveCandidates, findArchiveCandidates,
orderExtractorCandidatesForArchive, orderExtractorCandidatesForArchive,
parseNativeExtractOutput, parseNativeExtractOutput,
parseNativeArchiveEntryList,
resolveExtractorBackendModeForArchive, resolveExtractorBackendModeForArchive,
resolveExtractorBackendMode, resolveExtractorBackendMode,
shouldFallbackLegacyRarToJvm, shouldFallbackLegacyRarToJvm,
validateNativeArchiveEntryCandidates,
} from "../src/main/extractor"; } from "../src/main/extractor";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -1598,5 +1601,65 @@ describe("extractor", () => {
]); ]);
}); });
it.each([
["file.mkv:stream", "file.mkv"],
["name.", "name"],
["name ", "name"],
["CON", "safe-base.txt"],
["aux.txt", "safe-base.txt"],
["folder/LPT1.mkv", "safe-base.txt"]
] as const)("rejects Win32-unsafe internal ZIP entry %s before changing its alias", async (entryName, baseName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-win32-entry-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const basePath = path.join(targetDir, baseName);
fs.writeFileSync(basePath, "foreign");
const zip = new AdmZip();
zip.addFile(entryName, 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(basePath, "utf8")).toBe("foreign");
});
it.each(["file.mkv:stream", "name.", "name ", "CON", "aux.txt", "folder/LPT1.mkv"])("rejects native preflight entry %s before extraction", (entryName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-preflight-"));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
expect(validateNativeArchiveEntryCandidates).toBeTypeOf("function");
expect(() => validateNativeArchiveEntryCandidates([entryName], targetDir)).toThrow(/Ausgabepfad|entry/i);
});
it("uses deterministic native list commands and parses entry-only output", () => {
expect(buildExternalListArgs("7z.exe", "archive.7z")).toEqual(["l", "-slt", "-sccUTF-8", "-p", "archive.7z"]);
expect(buildExternalListArgs("UnRAR.exe", "archive.rar")).toEqual(["lb", "-p-", "-y", "archive.rar"]);
expect(parseNativeArchiveEntryList("7z.exe", [
"Path = archive.7z",
"Type = 7z",
"----------",
"Path = folder/episode.mkv",
"Size = 10"
].join("\n"))).toEqual(["folder/episode.mkv"]);
expect(parseNativeArchiveEntryList("UnRAR.exe", "folder/episode.mkv\r\nsubtitle.srt\r\n")).toEqual([
"folder/episode.mkv",
"subtitle.srt"
]);
});
}); });
}); });
+7 -1
View File
@@ -52,7 +52,13 @@ describe("PackageOutputScope", () => {
"../foreign.mkv", "../foreign.mkv",
"folder/../../foreign.mkv", "folder/../../foreign.mkv",
"/absolute.mkv", "/absolute.mkv",
"C:\\absolute.mkv" "C:\\absolute.mkv",
"file.mkv:stream",
"CON",
"aux.txt",
"folder/LPT1.mkv",
"name.",
"name "
])("rejects unsafe archive entry path %s", (entryPath) => { ])("rejects unsafe archive entry path %s", (entryPath) => {
const root = createRoot(); const root = createRoot();
const outputPath = path.join(root, "safe.mkv"); const outputPath = path.join(root, "safe.mkv");