fix: validate extraction ownership before writes

Reject symlink and reparse boundaries before internal ZIP or JVM outputs are opened, track opened, committed, partial, removed output lifecycle events, and convert output callback failures into controlled extractor failures without poisoning the JVM daemon. Gate legacy recovery behind an atomically created package-generation owner marker and keep unmarked reused or shared directories fail closed. Parse native RAR output from strictly verified locale-independent candidates while retaining ambiguous rename rejection.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:24:19 +02:00
parent 58237bed0c
commit 11d63dd2be
21 changed files with 1010 additions and 378 deletions
+154 -7
View File
@@ -480,6 +480,15 @@ type DownloadManagerOptions = {
protectEmptyClobber?: boolean;
};
type PackageOutputOwnerMarker = {
version: 1;
packageId: string;
generation: number;
ownerId: string;
};
const PACKAGE_OUTPUT_OWNER_MARKER = ".rd-package-output-owner-v1.json";
type RunLifecycleContext = {
id: string;
startedAt: number;
@@ -4429,19 +4438,139 @@ export class DownloadManager extends EventEmitter {
return scope;
}
private packageOutputOwnerMarkerPath(pkg: PackageEntry): string {
return path.join(pkg.extractDir, PACKAGE_OUTPUT_OWNER_MARKER);
}
private async readPackageOutputOwnerMarker(pkg: PackageEntry, requireSessionMatch: boolean): Promise<PackageOutputOwnerMarker | null> {
const markerPath = this.packageOutputOwnerMarkerPath(pkg);
try {
const stat = await fs.promises.lstat(markerPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
return null;
}
const raw = JSON.parse(await fs.promises.readFile(markerPath, "utf8")) as Partial<PackageOutputOwnerMarker>;
const marker: PackageOutputOwnerMarker = {
version: 1,
packageId: String(raw.packageId || ""),
generation: Math.max(0, Math.floor(Number(raw.generation) || 0)),
ownerId: String(raw.ownerId || "").toLowerCase()
};
if (raw.version !== 1
|| marker.packageId !== pkg.id
|| marker.generation < 1
|| !/^[a-f0-9-]{36}$/.test(marker.ownerId)) {
return null;
}
if (requireSessionMatch
&& (marker.ownerId !== String(pkg.outputOwnerId || "").toLowerCase()
|| marker.generation !== Number(pkg.outputOwnerGeneration || 0)
|| marker.generation !== this.getPackageResultGeneration(pkg.id))) {
return null;
}
return marker;
} catch {
return null;
}
}
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");
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 });
} catch (error) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw error;
}
}
private async ensurePackageOutputOwnerMarker(pkg: PackageEntry): Promise<boolean> {
if (!this.isPackageSpecificExtractDir(pkg)
|| this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir)) {
return false;
}
await fs.promises.mkdir(pkg.extractDir, { recursive: true });
new PackageOutputScope([pkg.extractDir]).validateTarget(PACKAGE_OUTPUT_OWNER_MARKER, this.packageOutputOwnerMarkerPath(pkg));
const current = await this.readPackageOutputOwnerMarker(pkg, true);
if (current) {
return true;
}
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(pkg.extractDir, { withFileTypes: true });
} catch {
return false;
}
const rawExistingMarker = await this.readPackageOutputOwnerMarker(pkg, false);
const nonMarkerEntries = entries.filter((entry) => entry.name !== PACKAGE_OUTPUT_OWNER_MARKER);
if (nonMarkerEntries.length > 0) {
return false;
}
if (entries.some((entry) => entry.name === PACKAGE_OUTPUT_OWNER_MARKER)) {
if (!rawExistingMarker || rawExistingMarker.packageId !== pkg.id) {
return false;
}
await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true });
}
const marker: PackageOutputOwnerMarker = {
version: 1,
packageId: pkg.id,
generation: this.getPackageResultGeneration(pkg.id),
ownerId: uuidv4().toLowerCase()
};
await this.writePackageOutputOwnerMarkerAtomic(pkg, marker);
pkg.outputOwnerId = marker.ownerId;
pkg.outputOwnerGeneration = marker.generation;
if (this.session.packages[pkg.id] === pkg) {
try {
await saveSessionAsync(this.storagePaths, this.session);
} catch (error) {
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true }).catch(() => {});
throw error;
}
}
return true;
}
private async removePackageOutputOwnerMarker(pkg: PackageEntry): Promise<boolean> {
if (!await this.readPackageOutputOwnerMarker(pkg, true)) {
return false;
}
try {
await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true });
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
return true;
} catch {
return false;
}
}
private async adoptLegacyPackageOutputsIfExclusive(pkg: PackageEntry, scope: PackageOutputScope): Promise<void> {
if (pkg.outputScopeAdopted || scope.records().length > 0) {
if (scope.records().length > 0) {
pkg.outputScopeAdopted = true;
return;
}
if (pkg.outputScopeAdopted) {
return;
}
pkg.outputScopeAdopted = true;
if (pkg.outputProvenanceVersion !== undefined
&& pkg.outputProvenanceVersion !== PACKAGE_OUTPUT_PROVENANCE_VERSION) {
return;
}
const packageExclusive = (this.settings.createExtractSubfolder || this.isPackageSpecificExtractDir(pkg))
&& !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir);
if (!packageExclusive || !await this.existsAsync(pkg.extractDir)) {
if (!await this.readPackageOutputOwnerMarker(pkg, true)) {
return;
}
const candidates: string[] = [];
@@ -4473,6 +4602,8 @@ export class DownloadManager extends EventEmitter {
} else if (entry.isFile()
&& !/^\.rd-(?:output|replace)-/i.test(entry.name)
&& !/^\.rd_extract_progress(?:_[^.]+)?\.json$/i.test(entry.name)
&& entry.name !== PACKAGE_OUTPUT_OWNER_MARKER
&& !entry.name.startsWith(`.${PACKAGE_OUTPUT_OWNER_MARKER}.`)
&& !isIgnorableEmptyDirFileName(entry.name)) {
candidates.push(fullPath);
}
@@ -4507,10 +4638,19 @@ export class DownloadManager extends EventEmitter {
const scope = this.getPackageOutputScope(pkg);
try {
await fs.promises.mkdir(pkg.extractDir, { recursive: true });
await this.ensurePackageOutputOwnerMarker(pkg);
return await operation(pkg.extractDir, scope);
} finally {
if (!packageWasInSession || this.session.packages[pkg.id] === pkg) {
this.syncPackageOutputScope(pkg, scope);
if (scope.records().length === 0 && await this.removePackageOutputOwnerMarker(pkg)) {
try {
if ((await fs.promises.readdir(pkg.extractDir)).length === 0) {
await fs.promises.rmdir(pkg.extractDir);
}
} catch {
}
}
}
}
}
@@ -6138,9 +6278,12 @@ export class DownloadManager extends EventEmitter {
if ((sourceArtifactsChanged || sourceCleanupRelevant) && cleanupDir && await this.existsAsync(cleanupDir)) {
const removedResidual = await this.cleanupNonMkvResidualFiles(scope, targetDir, touchedParents);
if (removedResidual > 0) {
logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`);
}
if (removedResidual > 0) {
logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedResidual}`);
}
if (!scope.files().some((filePath) => isPathInsideDir(filePath, cleanupDir))) {
await this.removePackageOutputOwnerMarker(pkg);
}
const removedDirs = await this.removeEmptyScopedParentChains(cleanupDir, touchedParents);
if (removedDirs > 0) {
logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, dir=${cleanupDir}, entfernt=${removedDirs}`);
@@ -9096,6 +9239,8 @@ export class DownloadManager extends EventEmitter {
pkg.outputProvenance = [];
pkg.outputRecords = [];
pkg.outputScopeAdopted = false;
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
}
for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
@@ -12411,6 +12556,8 @@ export class DownloadManager extends EventEmitter {
pkg.outputProvenance = [];
pkg.outputRecords = [];
pkg.outputScopeAdopted = false;
pkg.outputOwnerId = "";
pkg.outputOwnerGeneration = 0;
this.packageOutputScopes.delete(packageId);
pkg.cleanupErrorCategory = "";
}
+152 -51
View File
@@ -119,6 +119,13 @@ export class ExtractionError extends Error {
this.name = "ExtractionError";
}
}
class ExtractionOutputCallbackError extends Error {
public constructor(error: unknown) {
super(`extract_output_callback_failed: ${cleanErrorText(String(error))}`);
this.name = "ExtractionOutputCallbackError";
}
}
type ExtractionErrorWithHints = Error & {
suggestRedownload?: boolean;
@@ -146,6 +153,15 @@ type JvmExtractResult = {
backend: string;
};
type JvmParseState = {
bestPercent: number;
usedPassword: string;
backend: string;
reportedError: string;
outputError?: Error;
openedOutputs?: Map<string, ExtractOutputEvent>;
};
export interface ExtractResult {
extracted: number;
failed: number;
@@ -193,7 +209,7 @@ interface DaemonRequest {
onArchiveProgress?: (percent: number) => void;
signal?: AbortSignal;
timeoutMs?: number;
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
parseState: JvmParseState;
archiveName: string;
startedAt: number;
passwordCount: number;
@@ -1556,7 +1572,7 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
function parseJvmLine(
line: string,
onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string },
state: JvmParseState,
onOutput?: (event: ExtractOutputEvent) => void
): void {
const trimmed = String(line || "").trim();
@@ -1597,18 +1613,33 @@ function parseJvmLine(
const disposition = fields[3];
if (fields.length !== 7
|| fields[1] !== "1"
|| (stateValue !== "complete" && stateValue !== "partial")
|| !(["opened", "complete", "partial", "removed"] as const).includes(stateValue as ExtractOutputEvent["state"])
|| !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) {
return;
}
onOutput?.({
const event: ExtractOutputEvent = {
version: 1,
archivePath: Buffer.from(fields[4], "base64").toString("utf8"),
entryPath: Buffer.from(fields[5], "base64").toString("utf8"),
outputPath: Buffer.from(fields[6], "base64").toString("utf8"),
state: stateValue,
state: stateValue as ExtractOutputEvent["state"],
disposition: disposition as ExtractOutputEvent["disposition"]
});
};
const outputKey = pathSetKey(path.resolve(event.outputPath));
state.openedOutputs ||= new Map<string, ExtractOutputEvent>();
if (event.state === "opened") {
state.openedOutputs.set(outputKey, event);
} else if (event.state === "complete" || 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;
}
}
return;
}
@@ -1661,7 +1692,7 @@ function finishDaemonRequest(result: JvmExtractResult): void {
req.resolve(result);
}
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
if (!req) {
return;
}
@@ -1693,7 +1724,11 @@ function handleDaemonLine(line: string): void {
if (daemonCurrentRequest !== req) {
return;
}
flushDaemonParseBuffers(req);
flushDaemonParseBuffers(req);
if (req.parseState.outputError) {
failDaemonOutputCallback(req);
return;
}
const elapsedMs = Date.now() - req.startedAt;
logger.info(
`JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` +
@@ -1727,9 +1762,11 @@ function handleDaemonLine(line: string): void {
return;
}
if (daemonCurrentRequest) {
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
}
function startDaemon(layout: JvmExtractorLayout): boolean {
@@ -1780,9 +1817,11 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
if (daemonCurrentRequest) {
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
}
});
@@ -1999,9 +2038,10 @@ async function runJvmExtractCommand(
let timedOutByWatchdog = false;
let abortedBySignal = false;
let onAbort: (() => void) | null = null;
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
let stdoutBuffer = "";
let stderrBuffer = "";
const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
let stdoutBuffer = "";
let stderrBuffer = "";
let outputCallbackKillStarted = false;
const child = spawn(layout.javaCommand, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
@@ -2014,9 +2054,13 @@ async function runJvmExtractCommand(
const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`;
const lines = nextBuffer.split(/\r?\n/);
const keep = lines.pop() || "";
for (const line of lines) {
for (const line of lines) {
parseJvmLine(line, onArchiveProgress, parseState, onOutput);
}
}
if (parseState.outputError && !outputCallbackKillStarted) {
outputCallbackKillStarted = true;
killProcessTree(child);
}
if (fromStdErr) {
stderrBuffer = keep;
} else {
@@ -2101,17 +2145,31 @@ async function runJvmExtractCommand(
});
return;
}
if (timedOutByWatchdog) {
if (timedOutByWatchdog) {
finish({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`,
usedPassword: parseState.usedPassword, backend: parseState.backend
});
return;
}
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`;
return;
}
if (parseState.outputError) {
finish({
ok: false,
missingCommand: false,
missingRuntime: false,
aborted: false,
timedOut: false,
errorText: cleanErrorText(parseState.outputError.message || String(parseState.outputError)),
usedPassword: parseState.usedPassword,
backend: parseState.backend
});
return;
}
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`;
if (code === 0) {
onArchiveProgress?.(100);
finish({
@@ -2171,8 +2229,9 @@ export function parseNativeExtractOutput(
const match = trimmed.match(/^[-+]\s+(.+)$/);
reportedPath = match?.[1]?.trim() || "";
} else if (isRarNativeCommand(command)) {
const match = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
reportedPath = match?.[1]?.trim() || "";
const localizedMatch = trimmed.match(/^.+?\s{2,}(.+?)\s{2,}OK$/);
const legacyMatch = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
reportedPath = localizedMatch?.[1]?.trim() || legacyMatch?.[1]?.trim() || "";
}
if (!reportedPath) {
return [];
@@ -2212,6 +2271,24 @@ export function parseNativeExtractOutput(
}
}
function failDaemonOutputCallback(req: DaemonRequest): void {
if (daemonCurrentRequest !== req || !req.parseState.outputError) {
return;
}
const message = cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError));
finishDaemonRequest({
ok: false,
missingCommand: false,
missingRuntime: false,
aborted: false,
timedOut: false,
errorText: message,
usedPassword: req.parseState.usedPassword,
backend: req.parseState.backend
});
shutdownDaemon();
}
function createNativeOutputCollector(
command: string,
archivePath: string,
@@ -2224,7 +2301,7 @@ function createNativeOutputCollector(
const collectLine = (value: string): void => {
const trimmed = value.trim();
if ((extractorCommandKind(command) === "seven_zip" && /^[-+]\s+/.test(trimmed))
|| (isRarNativeCommand(command) && /^Extracting\s+/i.test(trimmed))) {
|| (isRarNativeCommand(command) && (/^.+?\s{2,}.+?\s{2,}OK$/.test(trimmed) || /^Extracting\s+/i.test(trimmed)))) {
lines.add(trimmed);
}
};
@@ -2697,9 +2774,12 @@ async function runExternalExtract(
function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross");
return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross")
|| text.includes("symbolischer link")
|| text.includes("reparse point")
|| text.includes("extract_output_callback_failed");
}
function isZipInternalLimitError(error: unknown): boolean {
@@ -2736,7 +2816,8 @@ async function extractZipArchive(
targetDir: string,
conflictMode: ConflictMode,
signal?: AbortSignal,
onOutput?: (event: ExtractOutputEvent) => void
onOutput?: (event: ExtractOutputEvent) => void,
validateTarget?: (entryPath: string, outputPath: string) => void
): Promise<void> {
const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
@@ -2755,9 +2836,11 @@ async function extractZipArchive(
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue;
}
if (entry.isDirectory) {
await fs.promises.mkdir(baseOutputPath, { recursive: true });
continue;
if (entry.isDirectory) {
validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
await fs.promises.mkdir(baseOutputPath, { recursive: true });
validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
continue;
}
const header = (entry as unknown as {
@@ -2797,7 +2880,6 @@ async function extractZipArchive(
let outputKey = pathSetKey(outputPath);
let disposition: ExtractOutputEvent["disposition"] = "written";
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) {
if (mode === "skip") {
@@ -2840,10 +2922,22 @@ async function extractZipArchive(
}
}
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const data = entry.getData();
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const normalizedEntryPath = entry.entryName.replace(/\\/g, "/");
validateTarget?.(normalizedEntryPath, outputPath);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: normalizedEntryPath,
outputPath,
state: "opened",
disposition
});
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
validateTarget?.(normalizedEntryPath, outputPath);
const data = entry.getData();
if (data.length > memoryLimitBytes) {
const entryMb = Math.ceil(data.length / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
@@ -2856,14 +2950,6 @@ async function extractZipArchive(
try {
await fs.promises.writeFile(outputPath, data);
usedOutputs.add(outputKey);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath,
state: "complete",
disposition
});
} catch (error) {
if (await fs.promises.access(outputPath).then(() => true, () => false)) {
onOutput?.({
@@ -2877,6 +2963,14 @@ async function extractZipArchive(
}
throw error;
}
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: normalizedEntryPath,
outputPath,
state: "complete",
disposition
});
}
}
@@ -3149,7 +3243,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const outputScope = new PackageOutputScope([options.targetDir]);
const emitOutput = (event: ExtractOutputEvent): void => {
outputScope.add(event);
options.onOutput?.(event);
try {
options.onOutput?.(event);
} catch (error) {
throw new ExtractionOutputCallbackError(error);
}
};
const validateOutputTarget = (entryPath: string, outputPath: string): void => {
outputScope.validateTarget(entryPath, outputPath);
};
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
const allCandidates = await findArchiveCandidates(options.packageDir);
@@ -3428,14 +3529,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
rememberLearnedPassword(usedPassword);
} catch (error) {
if (isNoExtractorError(String(error))) {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
} else {
throw error;
}
}
} else {
try {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
archivePercent = 100;
} catch (error) {
if (!shouldFallbackToExternalZip(error)) {
@@ -3696,7 +3797,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
nestedPercent = 100;
} catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
+30 -19
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
export type ExtractOutputState = "complete" | "partial";
export type ExtractOutputState = "opened" | "complete" | "partial" | "removed";
export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped";
export interface ExtractOutputEvent {
@@ -13,10 +13,12 @@ export interface ExtractOutputEvent {
disposition: ExtractOutputDisposition;
}
export type OwnedExtractOutputEvent = ExtractOutputEvent & { state: "complete" | "partial" };
export class PackageOutputScope {
private readonly authorizedRoots: string[];
private readonly outputRecords = new Map<string, ExtractOutputEvent>();
private readonly outputRecords = new Map<string, OwnedExtractOutputEvent>();
public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) {
this.authorizedRoots = [...new Map(
@@ -94,20 +96,14 @@ export class PackageOutputScope {
if (!path.isAbsolute(String(event.archivePath || ""))) {
throw new Error(`Archivpfad muss absolut sein: ${event.archivePath}`);
}
if (event.state !== "complete" && event.state !== "partial") {
if (!(["opened", "complete", "partial", "removed"] as const).includes(event.state)) {
throw new Error(`Ungültiger Extract-Output-Status: ${String(event.state)}`);
}
if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) {
throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`);
}
const entryPath = this.validateEntryPath(event.entryPath);
if (!path.isAbsolute(String(event.outputPath || ""))) {
throw new Error(`Finaler Ausgabepfad muss absolut sein: ${event.outputPath}`);
}
const outputPath = path.resolve(event.outputPath);
const authorizedRoot = this.findAuthorizedRoot(outputPath);
this.rejectLinkedPath(outputPath, authorizedRoot);
if (event.disposition !== "skipped") {
const { entryPath, outputPath } = this.validateTarget(event.entryPath, event.outputPath);
if (event.disposition !== "skipped" && event.state !== "opened" && event.state !== "removed") {
let stat: fs.Stats;
try {
stat = fs.lstatSync(outputPath);
@@ -128,20 +124,35 @@ export class PackageOutputScope {
};
}
public validateTarget(entryPath: string, outputPath: string): { entryPath: string; outputPath: string } {
const normalizedEntryPath = this.validateEntryPath(entryPath);
if (!path.isAbsolute(String(outputPath || ""))) {
throw new Error(`Finaler Ausgabepfad muss absolut sein: ${outputPath}`);
}
const normalizedOutputPath = path.resolve(outputPath);
const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath);
this.rejectLinkedPath(normalizedOutputPath, authorizedRoot);
return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath };
}
public add(event: ExtractOutputEvent): boolean {
const normalized = this.normalizeEvent(event);
if (normalized.disposition === "skipped") {
const key = this.pathKey(normalized.outputPath);
if (normalized.state === "removed") {
return this.outputRecords.delete(key);
}
if (normalized.disposition === "skipped" || normalized.state === "opened") {
return false;
}
const key = this.pathKey(normalized.outputPath);
const owned = normalized as OwnedExtractOutputEvent;
const current = this.outputRecords.get(key);
if (current) {
if (current.state === "partial" && normalized.state === "complete") {
this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath });
if (current.state === "partial" && owned.state === "complete") {
this.outputRecords.set(key, { ...owned, outputPath: current.outputPath });
}
return false;
}
this.outputRecords.set(key, normalized);
this.outputRecords.set(key, owned);
return true;
}
@@ -155,7 +166,7 @@ export class PackageOutputScope {
return added;
}
public records(): ExtractOutputEvent[] {
public records(): OwnedExtractOutputEvent[] {
return [...this.outputRecords.values()];
}
@@ -175,7 +186,7 @@ export class PackageOutputScope {
return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|tgz|tbz2|txz|001)$/i.test(filePath));
}
public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean {
public replacePath(sourcePath: string, targetPath: string, state?: OwnedExtractOutputEvent["state"]): boolean {
const sourceKey = this.pathKey(sourcePath);
const current = this.outputRecords.get(sourceKey);
if (!current) {
@@ -187,7 +198,7 @@ export class PackageOutputScope {
entryPath: path.basename(targetPath),
state: state || current.state,
disposition: targetPath === current.outputPath ? current.disposition : "renamed"
});
}) as OwnedExtractOutputEvent;
this.outputRecords.delete(sourceKey);
this.outputRecords.set(this.pathKey(next.outputPath), next);
return true;
+2
View File
@@ -1063,6 +1063,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
outputProvenance,
outputRecords,
outputScopeAdopted: Boolean(pkg.outputScopeAdopted),
outputOwnerId: /^[a-f0-9-]{36}$/i.test(asText(pkg.outputOwnerId)) ? asText(pkg.outputOwnerId).toLowerCase() : "",
outputOwnerGeneration: clampNumber(pkg.outputOwnerGeneration, 0, 0, Number.MAX_SAFE_INTEGER),
cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
+2
View File
@@ -602,6 +602,8 @@ export interface PackageEntry {
outputProvenance?: string[];
outputRecords?: PackageOutputRecord[];
outputScopeAdopted?: boolean;
outputOwnerId?: string;
outputOwnerGeneration?: number;
cleanupErrorCategory?: string;
resultGeneration?: number;
createdAt: number;