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:
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user