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