fix: validate raw archive targets before conflicts
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user