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:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -263,6 +263,7 @@ public final class JBindExtractorMain {
|
|||||||
progress.emitStart();
|
progress.emitStart();
|
||||||
|
|
||||||
Set<String> preflightReserved = new HashSet<String>();
|
Set<String> preflightReserved = new HashSet<String>();
|
||||||
|
TargetPlanInvariant targetPlan = new TargetPlanInvariant();
|
||||||
for (FileHeader header : fileHeaders) {
|
for (FileHeader header : fileHeaders) {
|
||||||
if (header == null) {
|
if (header == null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -270,9 +271,11 @@ public final class JBindExtractorMain {
|
|||||||
String entryName = normalizeEntryName(header.getFileName(), "file");
|
String entryName = normalizeEntryName(header.getFileName(), "file");
|
||||||
if (header.isDirectory()) {
|
if (header.isDirectory()) {
|
||||||
File dir = resolveDirectory(request.targetDir, entryName);
|
File dir = resolveDirectory(request.targetDir, entryName);
|
||||||
|
targetPlan.add(dir, true);
|
||||||
preflightReserved.add(pathKey(dir));
|
preflightReserved.add(pathKey(dir));
|
||||||
} else {
|
} else {
|
||||||
resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved);
|
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved);
|
||||||
|
targetPlan.add(outputTarget.reportedFile, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +400,8 @@ public final class JBindExtractorMain {
|
|||||||
List<String> entryNames = new ArrayList<String>();
|
List<String> entryNames = new ArrayList<String>();
|
||||||
List<String> dispositions = new ArrayList<String>();
|
List<String> dispositions = new ArrayList<String>();
|
||||||
List<File> outputDirectories = new ArrayList<File>();
|
List<File> outputDirectories = new ArrayList<File>();
|
||||||
Set<String> reserved = new HashSet<String>();
|
Set<String> reserved = new HashSet<String>();
|
||||||
|
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 = (Boolean) archive.getProperty(i, PropID.IS_FOLDER);
|
||||||
@@ -406,6 +410,7 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
if (Boolean.TRUE.equals(isFolder)) {
|
if (Boolean.TRUE.equals(isFolder)) {
|
||||||
File dir = resolveDirectory(request.targetDir, entryName);
|
File dir = resolveDirectory(request.targetDir, entryName);
|
||||||
|
targetPlan.add(dir, true);
|
||||||
outputDirectories.add(dir);
|
outputDirectories.add(dir);
|
||||||
reserved.add(pathKey(dir));
|
reserved.add(pathKey(dir));
|
||||||
continue;
|
continue;
|
||||||
@@ -423,6 +428,7 @@ public final class JBindExtractorMain {
|
|||||||
totalUnits += itemSize;
|
totalUnits += itemSize;
|
||||||
|
|
||||||
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
|
OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
|
||||||
|
targetPlan.add(outputTarget.reportedFile, false);
|
||||||
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);
|
||||||
@@ -797,13 +803,54 @@ public final class JBindExtractorMain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String pathKey(File file) {
|
private static String pathKey(File file) {
|
||||||
String value = file.getAbsolutePath();
|
String value = file.toPath().toAbsolutePath().normalize().toString();
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
value = value.toLowerCase(Locale.ROOT);
|
value = value.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
return value;
|
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() {
|
private static boolean isWindows() {
|
||||||
String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||||
|
|||||||
+87
-10
@@ -291,9 +291,47 @@ export async function detectArchiveSignature(filePath: string): Promise<ArchiveS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pathSetKey(filePath: string): string {
|
export function pathSetKey(filePath: string): string {
|
||||||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
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 {
|
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
|
||||||
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
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[] {
|
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/);
|
const lines = String(output || "").split(/\r?\n/);
|
||||||
if (isRarNativeCommand(command)) {
|
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 inEntries = false;
|
||||||
|
let current: NativeArchiveEntryCandidate | null = null;
|
||||||
|
const commitCurrent = (): void => {
|
||||||
|
if (current) {
|
||||||
|
entries.push(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (/^-{8,}\s*$/.test(line.trim())) {
|
if (/^-{8,}\s*$/.test(line.trim())) {
|
||||||
inEntries = true;
|
inEntries = true;
|
||||||
@@ -2271,21 +2327,36 @@ export function parseNativeArchiveEntryList(command: string, output: string): st
|
|||||||
}
|
}
|
||||||
const match = line.match(/^Path = (.*)$/);
|
const match = line.match(/^Path = (.*)$/);
|
||||||
if (match?.[1]) {
|
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;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateNativeArchiveEntryCandidates(entries: readonly string[], targetDir: string): void {
|
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]);
|
const scope = new PackageOutputScope([targetDir]);
|
||||||
for (const rawEntry of entries) {
|
const targetPlan = new TargetPlanInvariant();
|
||||||
const entryPath = String(rawEntry || "").replace(/\\/g, "/").replace(/\/$/, "");
|
for (const candidate of entries) {
|
||||||
|
const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, "");
|
||||||
if (!entryPath) {
|
if (!entryPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2359,11 +2430,11 @@ async function runNativeEntryPreflight(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entries = parseNativeArchiveEntryList(command, chunks.join(""));
|
const entries = parseNativeArchiveEntryCandidates(command, chunks.join(""));
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
throw new Error("Native Archivliste enthält keine validierbaren Einträge");
|
||||||
}
|
}
|
||||||
validateNativeArchiveEntryCandidates(entries, targetDir);
|
validateNativeArchiveTargetPlan(entries, targetDir);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -2943,6 +3014,7 @@ function isZipSafetyGuardError(error: unknown): boolean {
|
|||||||
|| text.includes("zip-eintrag verdaechtig gross")
|
|| text.includes("zip-eintrag verdaechtig gross")
|
||||||
|| text.includes("symbolischer link")
|
|| text.includes("symbolischer link")
|
||||||
|| text.includes("reparse point")
|
|| text.includes("reparse point")
|
||||||
|
|| text.includes("target-plan-kollision")
|
||||||
|| text.includes("extract_output_callback_failed");
|
|| text.includes("extract_output_callback_failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2989,6 +3061,7 @@ async function extractZipArchive(
|
|||||||
const entries = zip.getEntries();
|
const entries = zip.getEntries();
|
||||||
const resolvedTarget = path.resolve(targetDir);
|
const resolvedTarget = path.resolve(targetDir);
|
||||||
const plannedOutputs = new Set<string>();
|
const plannedOutputs = new Set<string>();
|
||||||
|
const targetPlan = new TargetPlanInvariant();
|
||||||
const renameCounters = new Map<string, number>();
|
const renameCounters = new Map<string, number>();
|
||||||
const directoryPlans: Array<{ entryPath: string; outputPath: string }> = [];
|
const directoryPlans: Array<{ entryPath: string; outputPath: string }> = [];
|
||||||
const filePlans: Array<{
|
const filePlans: Array<{
|
||||||
@@ -3012,6 +3085,8 @@ async function extractZipArchive(
|
|||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory";
|
const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory";
|
||||||
validateTarget?.(entryPath, baseOutputPath);
|
validateTarget?.(entryPath, baseOutputPath);
|
||||||
|
targetPlan.add(baseOutputPath, "directory");
|
||||||
|
plannedOutputs.add(pathSetKey(baseOutputPath));
|
||||||
directoryPlans.push({ entryPath, outputPath: baseOutputPath });
|
directoryPlans.push({ entryPath, outputPath: baseOutputPath });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3058,6 +3133,7 @@ async function extractZipArchive(
|
|||||||
if (mode === "skip") {
|
if (mode === "skip") {
|
||||||
const entryPath = entry.entryName.replace(/\\/g, "/");
|
const entryPath = entry.entryName.replace(/\\/g, "/");
|
||||||
validateTarget?.(entryPath, baseOutputPath);
|
validateTarget?.(entryPath, baseOutputPath);
|
||||||
|
targetPlan.add(baseOutputPath, "file");
|
||||||
filePlans.push({
|
filePlans.push({
|
||||||
entry,
|
entry,
|
||||||
entryPath,
|
entryPath,
|
||||||
@@ -3100,6 +3176,7 @@ async function extractZipArchive(
|
|||||||
|
|
||||||
const normalizedEntryPath = entry.entryName.replace(/\\/g, "/");
|
const normalizedEntryPath = entry.entryName.replace(/\\/g, "/");
|
||||||
validateTarget?.(normalizedEntryPath, outputPath);
|
validateTarget?.(normalizedEntryPath, outputPath);
|
||||||
|
targetPlan.add(outputPath, "file");
|
||||||
plannedOutputs.add(outputKey);
|
plannedOutputs.add(outputKey);
|
||||||
filePlans.push({
|
filePlans.push({
|
||||||
entry,
|
entry,
|
||||||
|
|||||||
+119
-2
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
@@ -7,7 +8,48 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import { extractPackageArchives } from "../src/main/extractor";
|
import { extractPackageArchives } from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
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 {
|
function hasJavaRuntime(): boolean {
|
||||||
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
|
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
|
||||||
@@ -401,6 +443,81 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe");
|
expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe");
|
||||||
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)))(
|
||||||
|
"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 () => {
|
it("emits progress callbacks with archiveName and percent", async () => {
|
||||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||||
|
|||||||
+114
-2
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import { createRequire } from "node:module";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
@@ -29,6 +30,47 @@ import {
|
|||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
||||||
const originalStatfs = fs.promises.statfs;
|
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(() => {
|
beforeEach(() => {
|
||||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||||
@@ -1659,6 +1701,13 @@ describe("extractor", () => {
|
|||||||
"folder/episode.mkv",
|
"folder/episode.mkv",
|
||||||
"subtitle.srt"
|
"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 () => {
|
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");
|
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", () => {
|
it("preserves raw RAR list trailing whitespace and dots for validation", () => {
|
||||||
const entries = parseNativeArchiveEntryList("UnRAR.exe", "safe.mkv\r\nname \r\nname.\r\n");
|
const entries = parseNativeArchiveEntryList("UnRAR.exe", "safe.mkv\r\nname \r\nname.\r\n");
|
||||||
expect(entries).toEqual(["safe.mkv", "name ", "name."]);
|
expect(entries).toEqual(["safe.mkv", "name ", "name."]);
|
||||||
|
|||||||
Reference in New Issue
Block a user