Reject symlink and reparse boundaries before internal ZIP or JVM outputs are opened, track opened, committed, partial, removed output lifecycle events, and convert output callback failures into controlled extractor failures without poisoning the JVM daemon. Gate legacy recovery behind an atomically created package-generation owner marker and keep unmarked reused or shared directories fail closed. Parse native RAR output from strictly verified locale-independent candidates while retaining ambiguous rename rejection.
183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { PackageOutputScope } from "../src/main/package-output-scope";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function createRoot(): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-scope-"));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const directory of tempDirs.splice(0)) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("PackageOutputScope", () => {
|
|
it("normalizes and deduplicates complete Windows paths case-insensitively", () => {
|
|
const root = createRoot();
|
|
const outputPath = path.join(root, "Season", "Episode.mkv");
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, "video");
|
|
const scope = new PackageOutputScope([root]);
|
|
|
|
scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive-a.rar"),
|
|
entryPath: "Season/Episode.mkv",
|
|
outputPath,
|
|
state: "complete",
|
|
disposition: "written"
|
|
});
|
|
scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive-b.rar"),
|
|
entryPath: "season/episode.mkv",
|
|
outputPath: outputPath.toUpperCase(),
|
|
state: "complete",
|
|
disposition: "overwritten"
|
|
});
|
|
|
|
expect(scope.completeFiles()).toEqual([path.resolve(outputPath)]);
|
|
expect(scope.partialFiles()).toEqual([]);
|
|
expect(scope.records()).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
"../foreign.mkv",
|
|
"folder/../../foreign.mkv",
|
|
"/absolute.mkv",
|
|
"C:\\absolute.mkv"
|
|
])("rejects unsafe archive entry path %s", (entryPath) => {
|
|
const root = createRoot();
|
|
const outputPath = path.join(root, "safe.mkv");
|
|
fs.writeFileSync(outputPath, "video");
|
|
const scope = new PackageOutputScope([root]);
|
|
|
|
expect(() => scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath,
|
|
outputPath,
|
|
state: "complete",
|
|
disposition: "written"
|
|
})).toThrow(/Ausgabepfad|entry/i);
|
|
});
|
|
|
|
it("rejects outputs outside authorized roots and unknown event versions", () => {
|
|
const root = createRoot();
|
|
const foreignRoot = createRoot();
|
|
const foreignPath = path.join(foreignRoot, "foreign.mkv");
|
|
fs.writeFileSync(foreignPath, "foreign");
|
|
const scope = new PackageOutputScope([root]);
|
|
const event = {
|
|
version: 1 as const,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "foreign.mkv",
|
|
outputPath: foreignPath,
|
|
state: "complete" as const,
|
|
disposition: "written" as const
|
|
};
|
|
|
|
expect(() => scope.add(event)).toThrow(/autorisiert/i);
|
|
expect(() => scope.add({ ...event, outputPath: path.join(root, "safe.mkv"), version: 2 as 1 })).toThrow(/Version/i);
|
|
});
|
|
|
|
it("rejects symlinked parent chains and keeps complete and partial outputs separate", () => {
|
|
const root = createRoot();
|
|
const foreignRoot = createRoot();
|
|
const completePath = path.join(root, "complete.mkv");
|
|
const partialPath = path.join(root, "partial.mkv");
|
|
fs.writeFileSync(completePath, "complete");
|
|
fs.writeFileSync(partialPath, "partial");
|
|
const scope = new PackageOutputScope([root]);
|
|
|
|
scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "complete.mkv",
|
|
outputPath: completePath,
|
|
state: "complete",
|
|
disposition: "written"
|
|
});
|
|
scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "partial.mkv",
|
|
outputPath: partialPath,
|
|
state: "partial",
|
|
disposition: "written"
|
|
});
|
|
|
|
expect(scope.completeFiles()).toEqual([path.resolve(completePath)]);
|
|
expect(scope.partialFiles()).toEqual([path.resolve(partialPath)]);
|
|
|
|
const link = path.join(root, "linked");
|
|
try {
|
|
fs.symlinkSync(foreignRoot, link, "junction");
|
|
} catch {
|
|
return;
|
|
}
|
|
const linkedOutput = path.join(link, "linked.mkv");
|
|
fs.writeFileSync(path.join(foreignRoot, "linked.mkv"), "foreign");
|
|
expect(() => scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "linked/linked.mkv",
|
|
outputPath: linkedOutput,
|
|
state: "complete",
|
|
disposition: "written"
|
|
})).toThrow(/symbol|reparse/i);
|
|
});
|
|
|
|
it("updates ownership after rename and removal without enumerating the root", () => {
|
|
const root = createRoot();
|
|
const sourcePath = path.join(root, "source.mkv");
|
|
const targetPath = path.join(root, "renamed.mkv");
|
|
fs.writeFileSync(sourcePath, "video");
|
|
const scope = new PackageOutputScope([root]);
|
|
scope.add({
|
|
version: 1,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "source.mkv",
|
|
outputPath: sourcePath,
|
|
state: "complete",
|
|
disposition: "written"
|
|
});
|
|
fs.renameSync(sourcePath, targetPath);
|
|
|
|
scope.replacePath(sourcePath, targetPath);
|
|
expect(scope.completeFiles()).toEqual([path.resolve(targetPath)]);
|
|
expect(scope.removePath(targetPath)).toBe(true);
|
|
expect(scope.completeFiles()).toEqual([]);
|
|
});
|
|
|
|
it("validates opened targets before creation and removes discarded partial ownership", () => {
|
|
const root = createRoot();
|
|
const outputPath = path.join(root, "episode.mkv");
|
|
const scope = new PackageOutputScope([root]);
|
|
const opened = {
|
|
version: 1 as const,
|
|
archivePath: path.join(root, "archive.rar"),
|
|
entryPath: "episode.mkv",
|
|
outputPath,
|
|
state: "opened" as const,
|
|
disposition: "written" as const
|
|
};
|
|
|
|
expect(scope.add(opened)).toBe(false);
|
|
expect(scope.records()).toEqual([]);
|
|
fs.writeFileSync(outputPath, "partial");
|
|
scope.add({ ...opened, state: "partial" });
|
|
expect(scope.partialFiles()).toEqual([outputPath]);
|
|
fs.rmSync(outputPath, { force: true });
|
|
expect(scope.add({ ...opened, state: "removed" })).toBe(true);
|
|
expect(scope.records()).toEqual([]);
|
|
});
|
|
});
|