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
+130
View File
@@ -13451,6 +13451,136 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false);
});
it("creates a package owner marker without hardlink support", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-no-link-"));
tempDirs.push(root);
const packageName = "no-link-package";
const session = emptySession();
const packageId = "no-link-package-id";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir: path.join(root, "downloads", packageName),
extractDir: path.join(root, "extract", packageName),
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const linkSpy = vi.spyOn(fs.promises, "link").mockRejectedValue(Object.assign(new Error("link unsupported"), { code: "ENOTSUP" }));
try {
await expect((manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).resolves.toBe(true);
expect(fs.existsSync(path.join(session.packages[packageId].extractDir, ".rd-package-output-owner-v1.json"))).toBe(true);
} finally {
linkSpy.mockRestore();
}
});
it("continues direct scoped output when owner marker creation is denied", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-denied-"));
tempDirs.push(root);
const packageName = "denied-marker-package";
const session = emptySession();
const packageId = "denied-marker-package-id";
const pkg: PackageEntry = {
id: packageId,
name: packageName,
outputDir: path.join(root, "downloads", packageName),
extractDir: path.join(root, "extract", packageName),
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
session.packageOrder = [packageId];
session.packages[packageId] = pkg;
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const originalOpen = fs.promises.open.bind(fs.promises);
const openSpy = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath: any, ...args: any[]) => {
if (String(filePath).includes("rd-package-output-owner-v1")) {
throw Object.assign(new Error("marker denied"), { code: "EPERM" });
}
return originalOpen(filePath, ...(args as [any, any]));
});
try {
await expect((manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => {
const outputPath = path.join(targetDir, "owned.mkv");
fs.writeFileSync(outputPath, "owned");
scope.add({
version: 1,
archivePath: path.join(pkg.outputDir, "archive.rar"),
entryPath: "owned.mkv",
outputPath,
state: "complete",
disposition: "written"
});
})).resolves.toBeUndefined();
} finally {
openSpy.mockRestore();
}
expect(fs.readFileSync(path.join(pkg.extractDir, "owned.mkv"), "utf8")).toBe("owned");
expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]);
expect(fs.existsSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"))).toBe(false);
});
it("rejects a replayed owner marker from an older package generation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-replay-"));
tempDirs.push(root);
const packageName = "replay-package";
const extractDir = path.join(root, "extract", packageName);
const libraryDir = path.join(root, "library");
fs.mkdirSync(extractDir, { recursive: true });
const foreignPath = path.join(extractDir, "foreign.mkv");
fs.writeFileSync(foreignPath, "foreign");
const ownerId = crypto.randomUUID().toLowerCase();
fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({
version: 1,
packageId: "replay-package-id",
generation: 1,
ownerId
}));
const session = emptySession();
const packageId = "replay-package-id";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir: path.join(root, "downloads", packageName),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
outputProvenanceVersion: 1,
outputRecords: [],
outputOwnerId: ownerId,
outputOwnerGeneration: 1,
resultGeneration: 2,
createdAt: 1_000,
updatedAt: 1_000
};
const manager = new DownloadManager(
{ ...defaultSettings(), autoExtract: true, collectMkvToLibrary: true, mkvLibraryDir: libraryDir },
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
expect(fs.existsSync(foreignPath)).toBe(true);
expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false);
expect(session.packages[packageId].outputRecords).toEqual([]);
});
it("does NOT move bonus files from Extras subdirectory to flat library", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
+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";
+66 -3
View File
@@ -4,7 +4,8 @@ import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildExternalExtractArgs,
buildExternalExtractArgs,
buildExternalListArgs,
cleanErrorText,
collectArchiveCleanupTargets,
extractPackageArchives,
@@ -18,9 +19,11 @@ import {
findArchiveCandidates,
orderExtractorCandidatesForArchive,
parseNativeExtractOutput,
parseNativeArchiveEntryList,
resolveExtractorBackendModeForArchive,
resolveExtractorBackendMode,
shouldFallbackLegacyRarToJvm,
resolveExtractorBackendMode,
shouldFallbackLegacyRarToJvm,
validateNativeArchiveEntryCandidates,
} from "../src/main/extractor";
const tempDirs: string[] = [];
@@ -1598,5 +1601,65 @@ describe("extractor", () => {
]);
});
it.each([
["file.mkv:stream", "file.mkv"],
["name.", "name"],
["name ", "name"],
["CON", "safe-base.txt"],
["aux.txt", "safe-base.txt"],
["folder/LPT1.mkv", "safe-base.txt"]
] as const)("rejects Win32-unsafe internal ZIP entry %s before changing its alias", async (entryName, baseName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-win32-entry-"));
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 basePath = path.join(targetDir, baseName);
fs.writeFileSync(basePath, "foreign");
const zip = new AdmZip();
zip.addFile(entryName, Buffer.from("package"));
zip.writeZip(path.join(packageDir, "release.zip"));
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
expect(fs.readFileSync(basePath, "utf8")).toBe("foreign");
});
it.each(["file.mkv:stream", "name.", "name ", "CON", "aux.txt", "folder/LPT1.mkv"])("rejects native preflight entry %s before extraction", (entryName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-preflight-"));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
expect(validateNativeArchiveEntryCandidates).toBeTypeOf("function");
expect(() => validateNativeArchiveEntryCandidates([entryName], targetDir)).toThrow(/Ausgabepfad|entry/i);
});
it("uses deterministic native list commands and parses entry-only output", () => {
expect(buildExternalListArgs("7z.exe", "archive.7z")).toEqual(["l", "-slt", "-sccUTF-8", "-p", "archive.7z"]);
expect(buildExternalListArgs("UnRAR.exe", "archive.rar")).toEqual(["lb", "-p-", "-y", "archive.rar"]);
expect(parseNativeArchiveEntryList("7z.exe", [
"Path = archive.7z",
"Type = 7z",
"----------",
"Path = folder/episode.mkv",
"Size = 10"
].join("\n"))).toEqual(["folder/episode.mkv"]);
expect(parseNativeArchiveEntryList("UnRAR.exe", "folder/episode.mkv\r\nsubtitle.srt\r\n")).toEqual([
"folder/episode.mkv",
"subtitle.srt"
]);
});
});
});
+7 -1
View File
@@ -52,7 +52,13 @@ describe("PackageOutputScope", () => {
"../foreign.mkv",
"folder/../../foreign.mkv",
"/absolute.mkv",
"C:\\absolute.mkv"
"C:\\absolute.mkv",
"file.mkv:stream",
"CON",
"aux.txt",
"folder/LPT1.mkv",
"name.",
"name "
])("rejects unsafe archive entry path %s", (entryPath) => {
const root = createRoot();
const outputPath = path.join(root, "safe.mkv");