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
+23 -10
View File
@@ -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) {
+190 -26
View File
@@ -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;
}
@@ -1676,10 +1670,27 @@ export function shutdownDaemon(): void {
daemonLayout = null;
}
function finishDaemonRequest(result: JvmExtractResult): void {
const req = daemonCurrentRequest;
if (!req) return;
daemonCurrentRequest = null;
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 = "";
daemonStderrBuffer = "";
@@ -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}`);
@@ -2072,11 +2084,28 @@ async function runJvmExtractCommand(
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
};
const finish = (result: JvmExtractResult): void => {
if (settled) {
return;
}
settled = true;
const finish = (result: JvmExtractResult): void => {
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);
timeoutId = null;
@@ -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; }
+16 -1
View File
@@ -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 };
}