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:
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.
@@ -669,8 +669,8 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
|
||||
private static String normalizeEntryName(String value, String fallback) {
|
||||
String entry = value == null ? "" : value.trim();
|
||||
if (entry.length() == 0) {
|
||||
String entry = value == null ? "" : value;
|
||||
if (entry.trim().length() == 0) {
|
||||
return fallback;
|
||||
}
|
||||
entry = entry.replace('\\', '/');
|
||||
@@ -681,6 +681,11 @@ public final class JBindExtractorMain {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
while (entry.endsWith("/")) {
|
||||
entry = entry.substring(0, entry.length() - 1);
|
||||
}
|
||||
validateWindowsEntryName(entry);
|
||||
|
||||
String[] segments = entry.split("/", -1);
|
||||
StringBuilder sanitized = new StringBuilder();
|
||||
for (int i = 0; i < segments.length; i++) {
|
||||
@@ -696,6 +701,31 @@ public final class JBindExtractorMain {
|
||||
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) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
|
||||
@@ -4477,19 +4477,26 @@ export class DownloadManager extends EventEmitter {
|
||||
private async writePackageOutputOwnerMarkerAtomic(pkg: PackageEntry, marker: PackageOutputOwnerMarker): Promise<void> {
|
||||
const markerPath = this.packageOutputOwnerMarkerPath(pkg);
|
||||
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 {
|
||||
await handle.writeFile(JSON.stringify(marker), "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
try {
|
||||
await fs.promises.link(tempPath, markerPath);
|
||||
await fs.promises.rm(tempPath, { force: true });
|
||||
await reservation.writeFile("{}", "utf8");
|
||||
await reservation.sync();
|
||||
await reservation.close();
|
||||
const temp = await fs.promises.open(tempPath, "wx");
|
||||
try {
|
||||
await temp.writeFile(JSON.stringify(marker), "utf8");
|
||||
await temp.sync();
|
||||
} finally {
|
||||
await temp.close().catch(() => {});
|
||||
}
|
||||
await fs.promises.rename(tempPath, markerPath);
|
||||
} catch (error) {
|
||||
await reservation.close().catch(() => {});
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
await fs.promises.rm(markerPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await reservation.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4638,7 +4645,13 @@ export class DownloadManager extends EventEmitter {
|
||||
const scope = this.getPackageOutputScope(pkg);
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
if (!packageWasInSession || this.session.packages[pkg.id] === pkg) {
|
||||
|
||||
+181
-17
@@ -214,6 +214,7 @@ interface DaemonRequest {
|
||||
startedAt: number;
|
||||
passwordCount: number;
|
||||
onOutput?: (event: ExtractOutputEvent) => void;
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
const activeSubstDrives = new Set<string>();
|
||||
@@ -1629,17 +1630,10 @@ function parseJvmLine(
|
||||
state.openedOutputs ||= new Map<string, ExtractOutputEvent>();
|
||||
if (event.state === "opened") {
|
||||
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);
|
||||
}
|
||||
if (!state.outputError) {
|
||||
try {
|
||||
onOutput?.(event);
|
||||
} catch (error) {
|
||||
state.outputError = error instanceof Error ? error : new Error(String(error));
|
||||
state.reportedError = state.outputError.message;
|
||||
}
|
||||
}
|
||||
dispatchJvmOutputEvent(state, onOutput, event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1679,6 +1673,23 @@ export function shutdownDaemon(): void {
|
||||
function finishDaemonRequest(result: JvmExtractResult): void {
|
||||
const req = daemonCurrentRequest;
|
||||
if (!req) return;
|
||||
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;
|
||||
daemonStdoutBuffer = "";
|
||||
@@ -1689,7 +1700,7 @@ function finishDaemonRequest(result: JvmExtractResult): void {
|
||||
req.signal.removeEventListener("abort", daemonAbortHandler);
|
||||
daemonAbortHandler = null;
|
||||
}
|
||||
req.resolve(result);
|
||||
req.resolve(finalResult);
|
||||
}
|
||||
|
||||
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
|
||||
@@ -1908,7 +1919,8 @@ function sendDaemonRequest(
|
||||
archiveName,
|
||||
startedAt: Date.now(),
|
||||
passwordCount: passwordCandidates.length,
|
||||
onOutput
|
||||
onOutput,
|
||||
targetDir
|
||||
};
|
||||
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
|
||||
|
||||
@@ -2076,6 +2088,23 @@ async function runJvmExtractCommand(
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -2085,7 +2114,7 @@ async function runJvmExtractCommand(
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
cleanupTmpDir();
|
||||
resolve(result);
|
||||
resolve(finalResult);
|
||||
};
|
||||
|
||||
if (timeoutMs && timeoutMs > 0) {
|
||||
@@ -2216,6 +2245,137 @@ export function buildExternalExtractArgs(
|
||||
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(
|
||||
command: string,
|
||||
line: string,
|
||||
@@ -2359,7 +2519,11 @@ async function runExternalExtractInner(
|
||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||||
let createErrorText = "";
|
||||
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 result = await runExtractCommand(command, args, (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>`);
|
||||
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);
|
||||
const result = await runNativeAttempt(args, password);
|
||||
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; }
|
||||
@@ -2413,7 +2577,7 @@ async function runExternalExtractInner(
|
||||
onPasswordAttempt?.(passwordAttempt, passwords.length);
|
||||
}
|
||||
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)) {
|
||||
usePerformanceFlags = false;
|
||||
@@ -2421,7 +2585,7 @@ async function runExternalExtractInner(
|
||||
onLog?.("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);
|
||||
result = await runNativeAttempt(args);
|
||||
result = await runNativeAttempt(args, password);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -2484,7 +2648,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);
|
||||
const result = await runNativeAttempt(args, password);
|
||||
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; }
|
||||
|
||||
@@ -37,8 +37,20 @@ export class PackageOutputScope {
|
||||
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 {
|
||||
const normalized = String(entryPath || "").trim().replace(/\\/g, "/");
|
||||
const normalized = String(entryPath || "").replace(/\\/g, "/");
|
||||
const segments = normalized.split("/");
|
||||
if (!normalized
|
||||
|| normalized.startsWith("/")
|
||||
@@ -47,6 +59,7 @@ export class PackageOutputScope {
|
||||
|| segments.some((segment) => segment === ".." || segment === "")) {
|
||||
throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`);
|
||||
}
|
||||
this.validateWindowsSegments(segments, entryPath);
|
||||
return segments.filter((segment) => segment !== ".").join("/");
|
||||
}
|
||||
|
||||
@@ -131,6 +144,8 @@ export class PackageOutputScope {
|
||||
}
|
||||
const normalizedOutputPath = path.resolve(outputPath);
|
||||
const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath);
|
||||
const relativeOutputPath = path.relative(authorizedRoot, normalizedOutputPath).replace(/\\/g, "/");
|
||||
this.validateWindowsSegments(relativeOutputPath.split("/"), outputPath);
|
||||
this.rejectLinkedPath(normalizedOutputPath, authorizedRoot);
|
||||
return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath };
|
||||
}
|
||||
|
||||
@@ -13451,6 +13451,136 @@ describe("download manager", () => {
|
||||
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 () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -270,6 +270,97 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
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 () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import AdmZip from "adm-zip";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExternalExtractArgs,
|
||||
buildExternalListArgs,
|
||||
cleanErrorText,
|
||||
collectArchiveCleanupTargets,
|
||||
extractPackageArchives,
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
findArchiveCandidates,
|
||||
orderExtractorCandidatesForArchive,
|
||||
parseNativeExtractOutput,
|
||||
parseNativeArchiveEntryList,
|
||||
resolveExtractorBackendModeForArchive,
|
||||
resolveExtractorBackendMode,
|
||||
shouldFallbackLegacyRarToJvm,
|
||||
validateNativeArchiveEntryCandidates,
|
||||
} from "../src/main/extractor";
|
||||
|
||||
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"
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,13 @@ describe("PackageOutputScope", () => {
|
||||
"../foreign.mkv",
|
||||
"folder/../../foreign.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) => {
|
||||
const root = createRoot();
|
||||
const outputPath = path.join(root, "safe.mkv");
|
||||
|
||||
Reference in New Issue
Block a user