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
@@ -263,6 +263,7 @@ public final class JBindExtractorMain {
progress.emitStart();
Set<String> preflightReserved = new HashSet<String>();
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
for (FileHeader header : fileHeaders) {
if (header == null) {
continue;
@@ -270,9 +271,11 @@ public final class JBindExtractorMain {
String entryName = normalizeEntryName(header.getFileName(), "file");
if (header.isDirectory()) {
File dir = resolveDirectory(request.targetDir, entryName);
targetPlan.add(dir, true);
preflightReserved.add(pathKey(dir));
} else {
resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved);
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved);
targetPlan.add(outputTarget.reportedFile, false);
}
}
@@ -398,6 +401,7 @@ public final class JBindExtractorMain {
List<String> dispositions = new ArrayList<String>();
List<File> outputDirectories = new ArrayList<File>();
Set<String> reserved = new HashSet<String>();
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
for (int i = 0; i < itemCount; i++) {
Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER);
@@ -406,6 +410,7 @@ public final class JBindExtractorMain {
if (Boolean.TRUE.equals(isFolder)) {
File dir = resolveDirectory(request.targetDir, entryName);
targetPlan.add(dir, true);
outputDirectories.add(dir);
reserved.add(pathKey(dir));
continue;
@@ -423,6 +428,7 @@ public final class JBindExtractorMain {
totalUnits += itemSize;
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
targetPlan.add(outputTarget.reportedFile, false);
File output = outputTarget.file;
if (output == null) {
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
@@ -798,13 +804,54 @@ public final class JBindExtractorMain {
}
private static String pathKey(File file) {
String value = file.getAbsolutePath();
String value = file.toPath().toAbsolutePath().normalize().toString();
if (isWindows()) {
value = value.toLowerCase(Locale.ROOT);
}
return value;
}
private static final class TargetPlanInvariant {
private final TargetPlanNode root = new TargetPlanNode();
void add(File file, boolean directory) throws IOException {
String key = pathKey(file).replace('\\', '/');
String[] segments = key.split("/");
TargetPlanNode node = root;
for (String segment : segments) {
if (segment.length() == 0) {
continue;
}
if (node.file) {
throw new IOException("Target-Plan-Kollision: Datei ist Vorfahr von " + file.getAbsolutePath());
}
TargetPlanNode child = node.children.get(segment);
if (child == null) {
child = new TargetPlanNode();
node.children.put(segment, child);
}
node = child;
}
if (node.file || node.directory) {
if (directory && node.directory && !node.file) {
return;
}
throw new IOException("Target-Plan-Kollision: mehrfaches oder typwidriges Ziel " + file.getAbsolutePath());
}
if (!directory && !node.children.isEmpty()) {
throw new IOException("Target-Plan-Kollision: Datei ist Vorfahr eines anderen Ziels " + file.getAbsolutePath());
}
node.directory = directory;
node.file = !directory;
}
}
private static final class TargetPlanNode {
private final Map<String, TargetPlanNode> children = new HashMap<String, TargetPlanNode>();
private boolean file;
private boolean directory;
}
private static boolean isWindows() {
String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
return osName.contains("win");
+84 -7
View File
@@ -295,6 +295,44 @@ 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,
+117
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
@@ -8,6 +9,47 @@ import { extractPackageArchives } from "../src/main/extractor";
const tempDirs: string[] = [];
const originalBackend = process.env.RD_EXTRACT_BACKEND;
const require = createRequire(import.meta.url);
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void {
const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => {
setEntry: (entry: unknown) => void;
compressToBuffer: () => Buffer;
};
const ZipEntry = require("adm-zip/zipEntry") as new (options: Record<string, unknown>) => {
entryName: string;
setData: (data: Buffer) => void;
};
const utils = require("adm-zip/util") as {
Constants: { NONE: number };
decoder: unknown;
};
const options = {
noSort: true,
readEntries: false,
method: utils.Constants.NONE,
decoder: utils.decoder
};
const zip = new ZipFile(null, options);
for (const fixture of entries) {
const entry = new ZipEntry(options);
entry.entryName = fixture.directory && !fixture.name.endsWith("/") ? `${fixture.name}/` : fixture.name;
entry.setData(Buffer.from(fixture.content || ""));
zip.setEntry(entry);
}
fs.writeFileSync(filePath, zip.compressToBuffer());
}
const jvmTargetCollisionCases = [
["directory then same-name file", [{ name: "same", directory: true }, { name: "same", content: "file" }]],
["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]],
["parent file then child file", [{ name: "same", content: "parent" }, { name: "same/child", content: "child" }]],
["child file then parent file", [{ name: "same/child", content: "child" }, { name: "same", content: "parent" }]],
["case-insensitive file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
["duplicate file targets", [{ name: "same", content: "first" }, { name: "same", content: "second" }]]
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
function hasJavaRuntime(): boolean {
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
@@ -402,6 +444,81 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
});
it.each(["7zjbinding", "zip4j"].flatMap((backend) => jvmTargetCollisionCases.map(([label, entries]) => [backend, label, entries] as const)))(
"rejects %s %s before any target mutation",
(backend, _label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-target-plan-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const sentinelPath = path.join(targetDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "foreign");
const zipPath = path.join(root, "collision.zip");
writeZipFixture(zipPath, entries);
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.readdirSync(targetDir)).toEqual(["sentinel.txt"]);
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign");
}
);
it.each(["7zjbinding", "zip4j"])("allows safely identical duplicate directory targets in %s", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-identical-directory-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "directories.zip");
writeZipFixture(zipPath, [
{ name: "same", directory: true },
{ name: "same", directory: true }
]);
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).toBe(0);
expect(fs.readdirSync(targetDir)).toEqual(["same"]);
expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true);
});
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
+112
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
@@ -29,6 +30,47 @@ import {
const tempDirs: string[] = [];
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
const originalStatfs = fs.promises.statfs;
const require = createRequire(import.meta.url);
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void {
const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => {
setEntry: (entry: unknown) => void;
compressToBuffer: () => Buffer;
};
const ZipEntry = require("adm-zip/zipEntry") as new (options: Record<string, unknown>) => {
entryName: string;
setData: (data: Buffer) => void;
};
const utils = require("adm-zip/util") as {
Constants: { NONE: number };
decoder: unknown;
};
const options = {
noSort: true,
readEntries: false,
method: utils.Constants.NONE,
decoder: utils.decoder
};
const zip = new ZipFile(null, options);
for (const fixture of entries) {
const entry = new ZipEntry(options);
entry.entryName = fixture.directory && !fixture.name.endsWith("/") ? `${fixture.name}/` : fixture.name;
entry.setData(Buffer.from(fixture.content || ""));
zip.setEntry(entry);
}
fs.writeFileSync(filePath, zip.compressToBuffer());
}
const archiveTargetCollisionCases = [
["directory then same-name file", [{ name: "same", directory: true }, { name: "same", content: "file" }]],
["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]],
["parent file then child file", [{ name: "same", content: "parent" }, { name: "same/child", content: "child" }]],
["child file then parent file", [{ name: "same/child", content: "child" }, { name: "same", content: "parent" }]],
["case-insensitive file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
["duplicate file targets", [{ name: "same", content: "first" }, { name: "same", content: "second" }]]
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
beforeEach(() => {
process.env.RD_EXTRACT_BACKEND = "legacy";
@@ -1659,6 +1701,13 @@ describe("extractor", () => {
"folder/episode.mkv",
"subtitle.srt"
]);
expect(parseNativeArchiveEntryList("7z.exe", [
"----------",
"Path = folder",
"Folder = +",
"Path = folder/episode.mkv",
"Folder = -"
].join("\n"))).toEqual(["folder/", "folder/episode.mkv"]);
});
it("preflights every internal ZIP entry before overwriting an earlier safe target", async () => {
@@ -1692,6 +1741,69 @@ describe("extractor", () => {
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
});
it.each(archiveTargetCollisionCases)("rejects internal ZIP %s before any target mutation", async (_label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-target-plan-"));
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 sentinelPath = path.join(targetDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "foreign");
writeZipFixture(path.join(packageDir, "collision.zip"), entries);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]);
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign");
});
it.each(archiveTargetCollisionCases)("rejects native preflight %s", (_label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-target-plan-"));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name);
expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i);
expect(fs.readdirSync(targetDir)).toEqual([]);
});
it("allows safely identical duplicate directory targets", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-identical-directory-plan-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
writeZipFixture(path.join(packageDir, "directories.zip"), [
{ name: "same", directory: true },
{ name: "same", directory: true },
{ name: "same/child.txt", content: "child" }
]);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true);
expect(fs.readFileSync(path.join(targetDir, "same", "child.txt"), "utf8")).toBe("child");
expect(() => validateNativeArchiveEntryCandidates(["same/", "same/"], targetDir)).not.toThrow();
});
it("preserves raw RAR list trailing whitespace and dots for validation", () => {
const entries = parseNativeArchiveEntryList("UnRAR.exe", "safe.mkv\r\nname \r\nname.\r\n");
expect(entries).toEqual(["safe.mkv", "name ", "name."]);