fix: close Windows extraction namespace gaps

Reject alternate data streams, reserved Win32 device names, and trailing-dot or trailing-space aliases before internal ZIP, JVM, or native extraction writes. Preflight native archive entry lists, reconcile opened JVM outputs to partial or removed state on abnormal results, and replace hardlink-based owner markers with portable exclusive reservation plus atomic replacement while allowing direct scoped extraction to continue when marker persistence is unavailable.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:40:59 +02:00
parent 11d63dd2be
commit 6724b3605c
19 changed files with 566 additions and 54 deletions
+91
View File
@@ -269,6 +269,97 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(states[states.length - 1]).toBe("removed");
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
it.each([
["7zjbinding", "file.mkv:stream", "file.mkv"],
["7zjbinding", "name.", "name"],
["7zjbinding", "name ", "name"],
["7zjbinding", "CON", "safe-base.txt"],
["7zjbinding", "aux.txt", "safe-base.txt"],
["7zjbinding", "folder/LPT1.mkv", "safe-base.txt"],
["zip4j", "file.mkv:stream", "file.mkv"],
["zip4j", "name.", "name"],
["zip4j", "name ", "name"],
["zip4j", "CON", "safe-base.txt"],
["zip4j", "aux.txt", "safe-base.txt"],
["zip4j", "folder/LPT1.mkv", "safe-base.txt"]
] as const)("rejects %s Win32-unsafe entry %s before changing its alias", (backend, entryName, baseName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-win32-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const basePath = path.join(targetDir, baseName);
fs.writeFileSync(basePath, "foreign");
const zipPath = path.join(root, "unsafe.zip");
const zip = new AdmZip();
zip.addFile(entryName, Buffer.from("package"));
zip.writeZip(zipPath);
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.readFileSync(basePath, "utf8")).toBe("foreign");
});
it("reconciles a real aborted JVM opened output to partial or removed", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-abort-output-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.alloc(8 * 1024 * 1024, 7));
zip.writeZip(path.join(packageDir, "large.zip"));
const controller = new AbortController();
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const extraction = extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
signal: controller.signal,
onOutput: (event) => {
events.push(event);
if (event.state === "opened") {
controller.abort();
}
}
});
await expect(extraction).rejects.toThrow("aborted:extract");
expect(events[0]?.state).toBe("opened");
expect(["partial", "removed"]).toContain(events[events.length - 1]?.state);
const outputPath = path.join(targetDir, "episode.bin");
if (events[events.length - 1]?.state === "partial") {
expect(fs.statSync(outputPath).isFile()).toBe(true);
} else {
expect(fs.existsSync(outputPath)).toBe(false);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}, 10000);
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";