fix: reject colliding archive targets before extraction

Build a typed normalized target plan for internal ZIP, native list preflight, Zip4j, and SevenZipJBinding before any output directory or file is mutated. Reject file-directory aliases, file ancestors, case-insensitive aliases, and duplicate file targets while allowing identical directory declarations. Preserve linear archive planning and ship the rebuilt Java runtime classes.
This commit is contained in:
Sucukdeluxe
2026-08-22 16:07:35 +02:00
parent 1859aaf539
commit b5f57e7a29
17 changed files with 376 additions and 23 deletions
+87 -10
View File
@@ -291,9 +291,47 @@ export async function detectArchiveSignature(filePath: string): Promise<ArchiveS
}
}
export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
type TargetPlanKind = "file" | "directory";
type TargetPlanNode = {
kind?: TargetPlanKind;
children: Map<string, TargetPlanNode>;
};
class TargetPlanInvariant {
private readonly root: TargetPlanNode = { children: new Map() };
public add(outputPath: string, kind: TargetPlanKind): void {
const key = pathSetKey(path.resolve(outputPath)).replace(/\\/g, "/");
const segments = key.split("/").filter(Boolean);
let node = this.root;
for (const segment of segments) {
if (node.kind === "file") {
throw new Error(`Target-Plan-Kollision: Datei ist Vorfahr von ${outputPath}`);
}
let child = node.children.get(segment);
if (!child) {
child = { children: new Map() };
node.children.set(segment, child);
}
node = child;
}
if (node.kind) {
if (node.kind === "directory" && kind === "directory") {
return;
}
throw new Error(`Target-Plan-Kollision: mehrfaches oder typwidriges Ziel ${outputPath}`);
}
if (kind === "file" && node.children.size > 0) {
throw new Error(`Target-Plan-Kollision: Datei ist Vorfahr eines anderen Ziels ${outputPath}`);
}
node.kind = kind;
}
}
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
@@ -2255,12 +2293,30 @@ export function buildExternalListArgs(command: string, archivePath: string, pass
}
export function parseNativeArchiveEntryList(command: string, output: string): string[] {
return parseNativeArchiveEntryCandidates(command, output).map((entry) => (
entry.isDirectory && !/[\\/]$/.test(entry.entryPath) ? `${entry.entryPath}/` : entry.entryPath
));
}
type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean };
function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] {
const lines = String(output || "").split(/\r?\n/);
if (isRarNativeCommand(command)) {
return lines.filter((line) => line.length > 0);
return lines.filter((line) => line.length > 0).map((entryPath) => ({
entryPath,
isDirectory: /[\\/]$/.test(entryPath)
}));
}
const entries: string[] = [];
const entries: NativeArchiveEntryCandidate[] = [];
let inEntries = false;
let current: NativeArchiveEntryCandidate | null = null;
const commitCurrent = (): void => {
if (current) {
entries.push(current);
current = null;
}
};
for (const line of lines) {
if (/^-{8,}\s*$/.test(line.trim())) {
inEntries = true;
@@ -2271,21 +2327,36 @@ export function parseNativeArchiveEntryList(command: string, output: string): st
}
const match = line.match(/^Path = (.*)$/);
if (match?.[1]) {
entries.push(match[1]);
commitCurrent();
current = { entryPath: match[1], isDirectory: /[\\/]$/.test(match[1]) };
continue;
}
if (current && /^Folder = \+\s*$/.test(line)) {
current.isDirectory = true;
}
}
commitCurrent();
return entries;
}
export function validateNativeArchiveEntryCandidates(entries: readonly string[], targetDir: string): void {
validateNativeArchiveTargetPlan(entries.map((entryPath) => ({
entryPath,
isDirectory: /[\\/]$/.test(entryPath)
})), targetDir);
}
function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCandidate[], targetDir: string): void {
const scope = new PackageOutputScope([targetDir]);
for (const rawEntry of entries) {
const entryPath = String(rawEntry || "").replace(/\\/g, "/").replace(/\/$/, "");
const targetPlan = new TargetPlanInvariant();
for (const candidate of entries) {
const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, "");
if (!entryPath) {
continue;
}
const outputPath = path.resolve(targetDir, ...entryPath.split("/"));
scope.validateTarget(entryPath, outputPath);
targetPlan.add(outputPath, candidate.isDirectory ? "directory" : "file");
}
}
@@ -2359,11 +2430,11 @@ async function runNativeEntryPreflight(
return result;
}
try {
const entries = parseNativeArchiveEntryList(command, chunks.join(""));
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
if (entries.length === 0) {
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
}
validateNativeArchiveEntryCandidates(entries, targetDir);
validateNativeArchiveTargetPlan(entries, targetDir);
return result;
} catch (error) {
return {
@@ -2943,6 +3014,7 @@ function isZipSafetyGuardError(error: unknown): boolean {
|| text.includes("zip-eintrag verdaechtig gross")
|| text.includes("symbolischer link")
|| text.includes("reparse point")
|| text.includes("target-plan-kollision")
|| text.includes("extract_output_callback_failed");
}
@@ -2989,6 +3061,7 @@ async function extractZipArchive(
const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir);
const plannedOutputs = new Set<string>();
const targetPlan = new TargetPlanInvariant();
const renameCounters = new Map<string, number>();
const directoryPlans: Array<{ entryPath: string; outputPath: string }> = [];
const filePlans: Array<{
@@ -3012,6 +3085,8 @@ async function extractZipArchive(
if (entry.isDirectory) {
const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory";
validateTarget?.(entryPath, baseOutputPath);
targetPlan.add(baseOutputPath, "directory");
plannedOutputs.add(pathSetKey(baseOutputPath));
directoryPlans.push({ entryPath, outputPath: baseOutputPath });
continue;
}
@@ -3058,6 +3133,7 @@ async function extractZipArchive(
if (mode === "skip") {
const entryPath = entry.entryName.replace(/\\/g, "/");
validateTarget?.(entryPath, baseOutputPath);
targetPlan.add(baseOutputPath, "file");
filePlans.push({
entry,
entryPath,
@@ -3100,6 +3176,7 @@ async function extractZipArchive(
const normalizedEntryPath = entry.entryName.replace(/\\/g, "/");
validateTarget?.(normalizedEntryPath, outputPath);
targetPlan.add(outputPath, "file");
plannedOutputs.add(outputKey);
filePlans.push({
entry,