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();
|
||||
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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."]);
|
||||
|
||||
Reference in New Issue
Block a user