fix: validate raw archive targets before conflicts

This commit is contained in:
Sucukdeluxe
2026-08-22 16:22:43 +02:00
parent b5f57e7a29
commit 87bfa6576c
20 changed files with 338 additions and 66 deletions
@@ -36,6 +36,7 @@ import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -250,6 +251,20 @@ public final class JBindExtractorMain {
fileHeaders = new ArrayList<FileHeader>(); fileHeaders = new ArrayList<FileHeader>();
} }
RawArchivePlanInvariant rawPlan = new RawArchivePlanInvariant();
for (FileHeader header : fileHeaders) {
if (header == null) {
continue;
}
String entryName = normalizeEntryName(header.getFileName(), "file");
if (header.isDirectory()) {
resolveDirectory(request.targetDir, entryName);
} else {
secureResolve(request.targetDir, entryName);
}
rawPlan.add(entryName, header.isDirectory());
}
long totalUnits = 0; long totalUnits = 0;
boolean encrypted = false; boolean encrypted = false;
for (FileHeader header : fileHeaders) { for (FileHeader header : fileHeaders) {
@@ -259,43 +274,47 @@ public final class JBindExtractorMain {
encrypted = encrypted || header.isEncrypted(); encrypted = encrypted || header.isEncrypted();
totalUnits += safeSize(header.getUncompressedSize()); totalUnits += safeSize(header.getUncompressedSize());
} }
ProgressTracker progress = new ProgressTracker(totalUnits); Set<String> reserved = new HashSet<String>();
progress.emitStart();
Set<String> preflightReserved = new HashSet<String>();
TargetPlanInvariant targetPlan = new TargetPlanInvariant(); TargetPlanInvariant targetPlan = new TargetPlanInvariant();
Map<FileHeader, String> plannedEntryNames = new IdentityHashMap<FileHeader, String>();
Map<FileHeader, File> plannedDirectories = new IdentityHashMap<FileHeader, File>();
Map<FileHeader, OutputTarget> plannedOutputs = new IdentityHashMap<FileHeader, OutputTarget>();
for (FileHeader header : fileHeaders) { for (FileHeader header : fileHeaders) {
if (header == null) { if (header == null) {
continue; continue;
} }
String entryName = normalizeEntryName(header.getFileName(), "file"); String entryName = normalizeEntryName(header.getFileName(), "file");
plannedEntryNames.put(header, entryName);
if (header.isDirectory()) { if (header.isDirectory()) {
File dir = resolveDirectory(request.targetDir, entryName); File dir = resolveDirectory(request.targetDir, entryName);
targetPlan.add(dir, true); targetPlan.add(dir, true);
preflightReserved.add(pathKey(dir)); reserved.add(pathKey(dir));
plannedDirectories.put(header, dir);
} else { } else {
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved); OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
targetPlan.add(outputTarget.reportedFile, false); targetPlan.add(outputTarget.reportedFile, false);
plannedOutputs.put(header, outputTarget);
} }
} }
Set<String> reserved = new HashSet<String>(); ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart();
for (FileHeader header : fileHeaders) { for (FileHeader header : fileHeaders) {
if (header == null) { if (header == null) {
continue; continue;
} }
String entryName = normalizeEntryName(header.getFileName(), "file"); String entryName = plannedEntryNames.get(header);
if (header.isDirectory()) { if (header.isDirectory()) {
File dir = resolveDirectory(request.targetDir, entryName); File dir = plannedDirectories.get(header);
ensureDirectory(dir); ensureDirectory(dir);
rejectLinkedPath(request.targetDir, dir); rejectLinkedPath(request.targetDir, dir);
reserved.add(pathKey(dir));
continue; continue;
} }
long itemUnits = safeSize(header.getUncompressedSize()); long itemUnits = safeSize(header.getUncompressedSize());
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); OutputTarget outputTarget = plannedOutputs.get(header);
File output = outputTarget.file; File output = outputTarget.file;
if (output == null) { if (output == null) {
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
@@ -392,10 +411,28 @@ public final class JBindExtractorMain {
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
} }
List<String> rawEntryNames = new ArrayList<String>();
List<Boolean> rawEntryDirectories = new ArrayList<Boolean>();
RawArchivePlanInvariant rawPlan = new RawArchivePlanInvariant();
for (int i = 0; i < itemCount; i++) {
Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER);
String entryPath = (String) archive.getProperty(i, PropID.PATH);
String entryName = normalizeEntryName(entryPath, "item-" + i);
if (Boolean.TRUE.equals(isFolder)) {
resolveDirectory(request.targetDir, entryName);
} else {
secureResolve(request.targetDir, entryName);
}
rawPlan.add(entryName, Boolean.TRUE.equals(isFolder));
rawEntryNames.add(entryName);
rawEntryDirectories.add(Boolean.valueOf(Boolean.TRUE.equals(isFolder)));
}
long totalUnits = 0; long totalUnits = 0;
boolean encrypted = false; boolean encrypted = false;
List<Integer> fileIndices = new ArrayList<Integer>(); List<Integer> fileIndices = new ArrayList<Integer>();
List<File> outputFiles = new ArrayList<File>(); List<File> outputFiles = new ArrayList<File>();
List<File> reportedFiles = new ArrayList<File>();
List<Long> fileSizes = new ArrayList<Long>(); List<Long> fileSizes = new ArrayList<Long>();
List<String> entryNames = new ArrayList<String>(); List<String> entryNames = new ArrayList<String>();
List<String> dispositions = new ArrayList<String>(); List<String> dispositions = new ArrayList<String>();
@@ -404,9 +441,8 @@ public final class JBindExtractorMain {
TargetPlanInvariant targetPlan = new TargetPlanInvariant(); TargetPlanInvariant targetPlan = new TargetPlanInvariant();
for (int i = 0; i < itemCount; i++) { for (int i = 0; i < itemCount; i++) {
Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER); Boolean isFolder = rawEntryDirectories.get(i);
String entryPath = (String) archive.getProperty(i, PropID.PATH); String entryName = rawEntryNames.get(i);
String entryName = normalizeEntryName(entryPath, "item-" + i);
if (Boolean.TRUE.equals(isFolder)) { if (Boolean.TRUE.equals(isFolder)) {
File dir = resolveDirectory(request.targetDir, entryName); File dir = resolveDirectory(request.targetDir, entryName);
@@ -430,16 +466,20 @@ public final class JBindExtractorMain {
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
targetPlan.add(outputTarget.reportedFile, false); targetPlan.add(outputTarget.reportedFile, false);
File output = outputTarget.file; File output = outputTarget.file;
if (output == null) {
emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition);
}
fileIndices.add(i); fileIndices.add(i);
outputFiles.add(output); outputFiles.add(output);
reportedFiles.add(outputTarget.reportedFile);
fileSizes.add(itemSize); fileSizes.add(itemSize);
entryNames.add(entryName); entryNames.add(entryName);
dispositions.add(outputTarget.disposition); dispositions.add(outputTarget.disposition);
} }
for (int i = 0; i < outputFiles.size(); i++) {
if (outputFiles.get(i) == null) {
emitOutput(request.archiveFile, entryNames.get(i), reportedFiles.get(i), "complete", dispositions.get(i));
}
}
for (File directory : outputDirectories) { for (File directory : outputDirectories) {
ensureDirectory(directory); ensureDirectory(directory);
rejectLinkedPath(request.targetDir, directory); rejectLinkedPath(request.targetDir, directory);
@@ -811,6 +851,73 @@ public final class JBindExtractorMain {
return value; return value;
} }
private static final class RawArchivePlanInvariant {
private final RawArchivePlanNode root = new RawArchivePlanNode("");
void add(String relativeTarget, boolean directory) throws IOException {
String normalizedSpelling = relativeTarget == null ? "" : relativeTarget.replace('\\', '/');
while (normalizedSpelling.endsWith("/")) {
normalizedSpelling = normalizedSpelling.substring(0, normalizedSpelling.length() - 1);
}
if (normalizedSpelling.length() == 0) {
throw new IOException("Raw-Archivplan-Kollision: leeres Ziel");
}
String[] segments = normalizedSpelling.split("/", -1);
StringBuilder canonicalKey = new StringBuilder();
RawArchivePlanNode node = root;
for (String segment : segments) {
if (node.entry != null && !node.entry.directory) {
throw new IOException("Raw-Archivplan-Kollision: Datei ist Vorfahr von " + normalizedSpelling);
}
String canonicalSegment = segment.toLowerCase(Locale.ROOT);
if (canonicalKey.length() > 0) {
canonicalKey.append('/');
}
canonicalKey.append(canonicalSegment);
RawArchivePlanNode child = node.children.get(canonicalSegment);
if (child == null) {
child = new RawArchivePlanNode(segment);
node.children.put(canonicalSegment, child);
} else if (!child.segmentSpelling.equals(segment)) {
throw new IOException("Raw-Archivplan-Kollision: Windows-Case-Alias " + normalizedSpelling);
}
node = child;
}
if (node.entry != null) {
if (node.entry.directory && directory && node.entry.normalizedSpelling.equals(normalizedSpelling)) {
return;
}
throw new IOException("Raw-Archivplan-Kollision: mehrfaches oder typwidriges Ziel " + node.entry.canonicalWindowsKey);
}
if (!directory && !node.children.isEmpty()) {
throw new IOException("Raw-Archivplan-Kollision: Datei ist Vorfahr eines anderen Ziels " + normalizedSpelling);
}
node.entry = new RawArchivePlanEntry(canonicalKey.toString(), normalizedSpelling, directory);
}
}
private static final class RawArchivePlanNode {
private final String segmentSpelling;
private final Map<String, RawArchivePlanNode> children = new HashMap<String, RawArchivePlanNode>();
private RawArchivePlanEntry entry;
private RawArchivePlanNode(String segmentSpelling) {
this.segmentSpelling = segmentSpelling;
}
}
private static final class RawArchivePlanEntry {
private final String canonicalWindowsKey;
private final String normalizedSpelling;
private final boolean directory;
private RawArchivePlanEntry(String canonicalWindowsKey, String normalizedSpelling, boolean directory) {
this.canonicalWindowsKey = canonicalWindowsKey;
this.normalizedSpelling = normalizedSpelling;
this.directory = directory;
}
}
private static final class TargetPlanInvariant { private static final class TargetPlanInvariant {
private final TargetPlanNode root = new TargetPlanNode(); private final TargetPlanNode root = new TargetPlanNode();
+69 -2
View File
@@ -297,6 +297,58 @@ export function pathSetKey(filePath: string): string {
type TargetPlanKind = "file" | "directory"; type TargetPlanKind = "file" | "directory";
type RawArchivePlanEntry = {
canonicalWindowsKey: string;
normalizedSpelling: string;
kind: TargetPlanKind;
};
type RawArchivePlanNode = {
segmentSpelling: string;
entry?: RawArchivePlanEntry;
children: Map<string, RawArchivePlanNode>;
};
class RawArchivePlanInvariant {
private readonly root: RawArchivePlanNode = { segmentSpelling: "", children: new Map() };
public add(relativeTarget: string, kind: TargetPlanKind): void {
const normalizedSpelling = String(relativeTarget || "").replace(/\\/g, "/").replace(/\/+$/, "");
const segments = normalizedSpelling.split("/").filter(Boolean);
if (segments.length === 0) {
throw new Error("Raw-Archivplan-Kollision: leeres Ziel");
}
let node = this.root;
const canonicalSegments: string[] = [];
for (const segment of segments) {
if (node.entry?.kind === "file") {
throw new Error(`Raw-Archivplan-Kollision: Datei ist Vorfahr von ${normalizedSpelling}`);
}
const canonicalSegment = segment.toLowerCase();
canonicalSegments.push(canonicalSegment);
let child = node.children.get(canonicalSegment);
if (!child) {
child = { segmentSpelling: segment, children: new Map() };
node.children.set(canonicalSegment, child);
} else if (child.segmentSpelling !== segment) {
throw new Error(`Raw-Archivplan-Kollision: Windows-Case-Alias ${normalizedSpelling}`);
}
node = child;
}
const canonicalWindowsKey = canonicalSegments.join("/");
if (node.entry) {
if (node.entry.kind === "directory" && kind === "directory" && node.entry.normalizedSpelling === normalizedSpelling) {
return;
}
throw new Error(`Raw-Archivplan-Kollision: mehrfaches oder typwidriges Ziel ${node.entry.canonicalWindowsKey}`);
}
if (kind === "file" && node.children.size > 0) {
throw new Error(`Raw-Archivplan-Kollision: Datei ist Vorfahr eines anderen Ziels ${normalizedSpelling}`);
}
node.entry = { canonicalWindowsKey, normalizedSpelling, kind };
}
}
type TargetPlanNode = { type TargetPlanNode = {
kind?: TargetPlanKind; kind?: TargetPlanKind;
children: Map<string, TargetPlanNode>; children: Map<string, TargetPlanNode>;
@@ -2348,7 +2400,7 @@ export function validateNativeArchiveEntryCandidates(entries: readonly string[],
function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCandidate[], targetDir: string): void { function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCandidate[], targetDir: string): void {
const scope = new PackageOutputScope([targetDir]); const scope = new PackageOutputScope([targetDir]);
const targetPlan = new TargetPlanInvariant(); const rawPlan = new RawArchivePlanInvariant();
for (const candidate of entries) { for (const candidate of entries) {
const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, ""); const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, "");
if (!entryPath) { if (!entryPath) {
@@ -2356,7 +2408,7 @@ function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCan
} }
const outputPath = path.resolve(targetDir, ...entryPath.split("/")); const outputPath = path.resolve(targetDir, ...entryPath.split("/"));
scope.validateTarget(entryPath, outputPath); scope.validateTarget(entryPath, outputPath);
targetPlan.add(outputPath, candidate.isDirectory ? "directory" : "file"); rawPlan.add(entryPath, candidate.isDirectory ? "directory" : "file");
} }
} }
@@ -3060,6 +3112,21 @@ async function extractZipArchive(
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries(); const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir); const resolvedTarget = path.resolve(targetDir);
const rawPlan = new RawArchivePlanInvariant();
for (const entry of entries) {
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const baseOutputPath = path.resolve(targetDir, entry.entryName);
if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) {
throw new Error(`ZIP-Eintrag Path Traversal blockiert: ${entry.entryName}`);
}
const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "");
validateTarget?.(entryPath || "directory", baseOutputPath);
rawPlan.add(entryPath, entry.isDirectory ? "directory" : "file");
}
const plannedOutputs = new Set<string>(); const plannedOutputs = new Set<string>();
const targetPlan = new TargetPlanInvariant(); const targetPlan = new TargetPlanInvariant();
const renameCounters = new Map<string, number>(); const renameCounters = new Map<string, number>();
+79 -10
View File
@@ -13,6 +13,27 @@ const require = createRequire(import.meta.url);
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string }; type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
type TargetTreeEntry = { path: string; type: "directory" | "file"; bytes?: string };
function readTargetTree(root: string): TargetTreeEntry[] {
const entries: TargetTreeEntry[] = [];
const visit = (directory: string): void => {
for (const name of fs.readdirSync(directory).sort((left, right) => left.localeCompare(right))) {
const absolutePath = path.join(directory, name);
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
const stat = fs.lstatSync(absolutePath);
if (stat.isDirectory()) {
entries.push({ path: relativePath, type: "directory" });
visit(absolutePath);
} else {
entries.push({ path: relativePath, type: "file", bytes: fs.readFileSync(absolutePath).toString("base64") });
}
}
};
visit(root);
return entries;
}
function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void { function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void {
const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => { const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => {
setEntry: (entry: unknown) => void; setEntry: (entry: unknown) => void;
@@ -47,10 +68,16 @@ const jvmTargetCollisionCases = [
["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]], ["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" }]], ["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" }]], ["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" }]], ["upper-case then lower-case file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
["duplicate file targets", [{ name: "same", content: "first" }, { name: "same", content: "second" }]] ["lower-case then upper-case file aliases", [{ name: "name", content: "first" }, { name: "Name", content: "second" }]],
["upper-case then lower-case directory aliases", [{ name: "Folder", directory: true }, { name: "folder", directory: true }]],
["lower-case then upper-case directory aliases", [{ name: "folder", directory: true }, { name: "Folder", directory: true }]],
["duplicate file targets in forward order", [{ name: "same", content: "first" }, { name: "same", content: "second" }]],
["duplicate file targets in reverse order", [{ name: "same", content: "second" }, { name: "same", content: "first" }]]
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>; ] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
const rawPlanConflictModes = ["rename", "skip", "overwrite"] as const;
function hasJavaRuntime(): boolean { function hasJavaRuntime(): boolean {
const result = spawnSync("java", ["-version"], { stdio: "ignore" }); const result = spawnSync("java", ["-version"], { stdio: "ignore" });
return result.status === 0; return result.status === 0;
@@ -444,15 +471,16 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
}); });
it.each(["7zjbinding", "zip4j"].flatMap((backend) => jvmTargetCollisionCases.map(([label, entries]) => [backend, label, entries] as const)))( it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.flatMap((conflictMode) => jvmTargetCollisionCases.map(([label, entries]) => [backend, conflictMode, label, entries] as const))))(
"rejects %s %s before any target mutation", "rejects %s %s %s before any target mutation",
(backend, _label, entries) => { (backend, conflictMode, _label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-target-plan-${backend}-`)); const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-target-plan-${backend}-`));
tempDirs.push(root); tempDirs.push(root);
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
const sentinelPath = path.join(targetDir, "sentinel.txt"); const sentinelPath = path.join(targetDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "foreign"); fs.writeFileSync(sentinelPath, "foreign");
const before = readTargetTree(targetDir);
const zipPath = path.join(root, "collision.zip"); const zipPath = path.join(root, "collision.zip");
writeZipFixture(zipPath, entries); writeZipFixture(zipPath, entries);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm"); const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
@@ -472,18 +500,17 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
"--target", "--target",
targetDir, targetDir,
"--conflict", "--conflict",
"overwrite", conflictMode,
"--backend", "--backend",
backend backend
], { encoding: "utf8" }); ], { encoding: "utf8" });
expect(run.status).not.toBe(0); expect(run.status).not.toBe(0);
expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]); expect(readTargetTree(targetDir)).toEqual(before);
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign");
} }
); );
it.each(["7zjbinding", "zip4j"])("allows safely identical duplicate directory targets in %s", (backend) => { it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))("allows safely identical duplicate directory targets in %s with %s", (backend, conflictMode) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-identical-directory-${backend}-`)); const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-identical-directory-${backend}-`));
tempDirs.push(root); tempDirs.push(root);
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
@@ -509,7 +536,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
"--target", "--target",
targetDir, targetDir,
"--conflict", "--conflict",
"overwrite", conflictMode,
"--backend", "--backend",
backend backend
], { encoding: "utf8" }); ], { encoding: "utf8" });
@@ -519,6 +546,48 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true); expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true);
}); });
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))(
"preserves existing-target %s behavior for %s",
(backend, conflictMode) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-existing-target-${backend}-${conflictMode}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "same.txt"), "old");
const zipPath = path.join(root, "existing-target.zip");
writeZipFixture(zipPath, [{ name: "same.txt", content: "new" }]);
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",
conflictMode,
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).toBe(0);
expect(fs.readFileSync(path.join(targetDir, "same.txt"), "utf8")).toBe(conflictMode === "overwrite" ? "new" : "old");
if (conflictMode === "rename") {
expect(fs.readFileSync(path.join(targetDir, "same (1).txt"), "utf8")).toBe("new");
} else {
expect(fs.existsSync(path.join(targetDir, "same (1).txt"))).toBe(false);
}
}
);
it("emits progress callbacks with archiveName and percent", async () => { it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm"; process.env.RD_EXTRACT_BACKEND = "jvm";
+39 -10
View File
@@ -34,6 +34,27 @@ const require = createRequire(import.meta.url);
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string }; type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
type TargetTreeEntry = { path: string; type: "directory" | "file"; bytes?: string };
function readTargetTree(root: string): TargetTreeEntry[] {
const entries: TargetTreeEntry[] = [];
const visit = (directory: string): void => {
for (const name of fs.readdirSync(directory).sort((left, right) => left.localeCompare(right))) {
const absolutePath = path.join(directory, name);
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
const stat = fs.lstatSync(absolutePath);
if (stat.isDirectory()) {
entries.push({ path: relativePath, type: "directory" });
visit(absolutePath);
} else {
entries.push({ path: relativePath, type: "file", bytes: fs.readFileSync(absolutePath).toString("base64") });
}
}
};
visit(root);
return entries;
}
function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void { function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void {
const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => { const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => {
setEntry: (entry: unknown) => void; setEntry: (entry: unknown) => void;
@@ -68,10 +89,16 @@ const archiveTargetCollisionCases = [
["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]], ["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" }]], ["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" }]], ["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" }]], ["upper-case then lower-case file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
["duplicate file targets", [{ name: "same", content: "first" }, { name: "same", content: "second" }]] ["lower-case then upper-case file aliases", [{ name: "name", content: "first" }, { name: "Name", content: "second" }]],
["upper-case then lower-case directory aliases", [{ name: "Folder", directory: true }, { name: "folder", directory: true }]],
["lower-case then upper-case directory aliases", [{ name: "folder", directory: true }, { name: "Folder", directory: true }]],
["duplicate file targets in forward order", [{ name: "same", content: "first" }, { name: "same", content: "second" }]],
["duplicate file targets in reverse order", [{ name: "same", content: "second" }, { name: "same", content: "first" }]]
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>; ] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
const rawPlanConflictModes = ["rename", "skip", "overwrite"] as const;
beforeEach(() => { beforeEach(() => {
process.env.RD_EXTRACT_BACKEND = "legacy"; process.env.RD_EXTRACT_BACKEND = "legacy";
}); });
@@ -1741,7 +1768,7 @@ describe("extractor", () => {
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
}); });
it.each(archiveTargetCollisionCases)("rejects internal ZIP %s before any target mutation", async (_label, entries) => { it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects internal ZIP %s %s before any target mutation", async (conflictMode, _label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-target-plan-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-target-plan-"));
tempDirs.push(root); tempDirs.push(root);
const packageDir = path.join(root, "pkg"); const packageDir = path.join(root, "pkg");
@@ -1750,34 +1777,36 @@ describe("extractor", () => {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
const sentinelPath = path.join(targetDir, "sentinel.txt"); const sentinelPath = path.join(targetDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "foreign"); fs.writeFileSync(sentinelPath, "foreign");
const before = readTargetTree(targetDir);
writeZipFixture(path.join(packageDir, "collision.zip"), entries); writeZipFixture(path.join(packageDir, "collision.zip"), entries);
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir, packageDir,
targetDir, targetDir,
cleanupMode: "none", cleanupMode: "none",
conflictMode: "overwrite", conflictMode,
removeLinks: false, removeLinks: false,
removeSamples: false removeSamples: false
}); });
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]); expect(readTargetTree(targetDir)).toEqual(before);
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign");
}); });
it.each(archiveTargetCollisionCases)("rejects native preflight %s", (_label, entries) => { it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects native preflight before %s conflict handling for %s", (_conflictMode, _label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-target-plan-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-target-plan-"));
tempDirs.push(root); tempDirs.push(root);
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "sentinel.bin"), Buffer.from([0, 1, 2, 255]));
const before = readTargetTree(targetDir);
const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name); const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name);
expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i); expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i);
expect(fs.readdirSync(targetDir)).toEqual([]); expect(readTargetTree(targetDir)).toEqual(before);
}); });
it("allows safely identical duplicate directory targets", async () => { it.each(rawPlanConflictModes)("allows safely identical duplicate directory targets before %s conflict handling", async (conflictMode) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-identical-directory-plan-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-identical-directory-plan-"));
tempDirs.push(root); tempDirs.push(root);
const packageDir = path.join(root, "pkg"); const packageDir = path.join(root, "pkg");
@@ -1793,7 +1822,7 @@ describe("extractor", () => {
packageDir, packageDir,
targetDir, targetDir,
cleanupMode: "none", cleanupMode: "none",
conflictMode: "overwrite", conflictMode,
removeLinks: false, removeLinks: false,
removeSamples: false removeSamples: false
}); });