fix: validate extraction ownership before writes

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.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:24:19 +02:00
parent 58237bed0c
commit 11d63dd2be
21 changed files with 1010 additions and 378 deletions
+260 -183
View File
@@ -25,9 +25,23 @@ import { resetVideoToolingCache } from "../src/main/video-processor";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = [];
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
function writePackageOutputOwnerMarker(pkg: PackageEntry): void {
const ownerId = crypto.randomUUID().toLowerCase();
const generation = Math.max(1, Number(pkg.resultGeneration || 1));
pkg.outputOwnerId = ownerId;
pkg.outputOwnerGeneration = generation;
fs.mkdirSync(pkg.extractDir, { recursive: true });
fs.writeFileSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({
version: 1,
packageId: pkg.id,
generation,
ownerId
}));
}
describe("runWithLimitedConcurrency", () => {
it("processes the full batch without exceeding the configured worker count", async () => {
let active = 0;
@@ -6760,7 +6774,6 @@ describe("download manager", () => {
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -11596,17 +11609,18 @@ describe("download manager", () => {
downloadedBytes: 123,
totalBytes: 123,
progressPercent: 100,
fileName: "missing-source-ok.part01.rar",
fileName: "missing-source-ok.part01.rar",
targetPath: path.join(outputDir, "missing-source-ok.part01.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (123 B)",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -11715,9 +11729,10 @@ describe("download manager", () => {
fs.writeFileSync(path.join(extractDir, "Season 1", "Episode01.mkv"), "video", "utf8");
fs.writeFileSync(path.join(extractDir, "Season 1", "episode.links.txt"), "https://example.com/file", "utf8");
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "sample.mkv"), "sample-video", "utf8");
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8");
const mkvLibraryDir = path.join(root, "mkv-library");
fs.writeFileSync(path.join(extractDir, "Season 1", "sample", "readme.txt"), "sample-text", "utf8");
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -11764,9 +11779,10 @@ describe("download manager", () => {
} = createCompletedArchiveSession(root, packageName, sourceFileName);
session.packages[packageId].status = "completed";
session.items[itemId].fullStatus = "Entpackt - Done (<1s)";
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(originalExtractedPath, "video", "utf8");
session.items[itemId].fullStatus = "Entpackt - Done (<1s)";
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(originalExtractedPath, "video", "utf8");
writePackageOutputOwnerMarker(session.packages[packageId]);
const manager = new DownloadManager(
{
@@ -12664,20 +12680,21 @@ describe("download manager", () => {
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "downloading",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "downloading",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -12718,28 +12735,29 @@ describe("download manager", () => {
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const rawPath = path.join(epDir, rawName);
fs.writeFileSync(rawPath, Buffer.alloc(4096, 7));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const rawPath = path.join(epDir, rawName);
fs.writeFileSync(rawPath, Buffer.alloc(4096, 7));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -12831,30 +12849,31 @@ describe("download manager", () => {
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
const epName = `${episodeFolder}.mkv`;
fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
const epName = `${episodeFolder}.mkv`;
fs.writeFileSync(path.join(epDir, epName), Buffer.alloc(4096, 9));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -12887,29 +12906,30 @@ describe("download manager", () => {
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(extractDir, { recursive: true });
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
fs.writeFileSync(path.join(extractDir, bonusName), Buffer.alloc(4096, 2));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -12947,29 +12967,30 @@ describe("download manager", () => {
fs.mkdirSync(epDir, { recursive: true });
fs.writeFileSync(path.join(epDir, raw), Buffer.alloc(4096, 8));
};
const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
mk(folderA, "safari-fm-s04e08a.avi");
mk(folderB, "safari-fm-s04e08b.avi");
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const folderA = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const folderB = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
mk(folderA, "safari-fm-s04e08a.avi");
mk(folderB, "safari-fm-s04e08b.avi");
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -13003,30 +13024,31 @@ describe("download manager", () => {
const packageName = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
const epDir = path.join(extractDir, epFolder);
fs.mkdirSync(epDir, { recursive: true });
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
const epDir = path.join(extractDir, epFolder);
fs.mkdirSync(epDir, { recursive: true });
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
writePackageOutputOwnerMarker(session.packages[packageId]);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
@@ -13274,13 +13296,10 @@ describe("download manager", () => {
}
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
const s01Mkvs = [
"Ugly.Americans.S01E01.German.mkv",
"Ugly.Americans.S01E02.German.mkv"
];
for (const mkv of s01Mkvs) {
fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9));
}
const s01Mkvs = [
"Ugly.Americans.S01E01.German.mkv",
"Ugly.Americans.S01E02.German.mkv"
];
const session = emptySession();
const packageId = `${packageName}-pkg`;
@@ -13311,13 +13330,25 @@ describe("download manager", () => {
collectMkvToLibrary: true,
mkvLibraryDir,
enableIntegrityCheck: false,
cleanupMode: "delete"
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
cleanupMode: "delete"
},
session,
createStoragePaths(path.join(root, "state"))
);
expect(await (manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).toBe(true);
const ownerMarkerPath = path.join(extractDir, ".rd-package-output-owner-v1.json");
const ownerMarker = JSON.parse(fs.readFileSync(ownerMarkerPath, "utf8"));
expect(ownerMarker).toEqual(expect.objectContaining({
version: 1,
packageId,
generation: 1,
ownerId: session.packages[packageId].outputOwnerId
}));
for (const mkv of s01Mkvs) {
fs.writeFileSync(path.join(extractDir, mkv), Buffer.alloc(4096, 9));
}
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
for (const mkv of s01Mkvs) {
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
@@ -13373,8 +13404,54 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false);
expect(session.packages["package-a"].outputRecords).toEqual([]);
});
it("does NOT move bonus files from Extras subdirectory to flat library", async () => {
it("does not adopt foreign files from a reused package-name directory without its generation owner marker", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reused-owner-"));
tempDirs.push(root);
const packageName = "reused-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 session = emptySession();
const packageId = "reused-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: [],
createdAt: 1_000,
updatedAt: 1_000
};
const manager = new DownloadManager(
{
...defaultSettings(),
autoExtract: true,
createExtractSubfolder: 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([]);
expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false);
});
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);
@@ -13425,12 +13502,12 @@ describe("download manager", () => {
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (100 MB)",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
fullStatus: "Fertig (100 MB)",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager(
{
@@ -13516,12 +13593,12 @@ describe("download manager", () => {
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (100 MB)",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
fullStatus: "Fertig (100 MB)",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager(
{
@@ -13644,12 +13721,12 @@ describe("download manager", () => {
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager(
{
...defaultSettings(),
@@ -13723,12 +13800,12 @@ describe("download manager", () => {
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
fs.mkdirSync(mkvLibraryDir, { recursive: true });
fs.writeFileSync(path.join(mkvLibraryDir, "Episode01.mkv"), Buffer.from("video"));
+157 -5
View File
@@ -14,7 +14,7 @@ function hasJavaRuntime(): boolean {
return result.status === 0;
}
function hasJvmExtractorRuntime(): boolean {
function hasJvmExtractorRuntime(): boolean {
const root = path.join(process.cwd(), "resources", "extractor-jvm");
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
const requiredLibs = [
@@ -23,7 +23,24 @@ function hasJvmExtractorRuntime(): boolean {
path.join(root, "lib", "zip4j.jar")
];
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
}
}
function corruptFirstZipPayload(zipPath: string): void {
const bytes = fs.readFileSync(zipPath);
const signature = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
if (signature < 0) {
throw new Error("local ZIP header missing");
}
const compressedSize = bytes.readUInt32LE(signature + 18);
const nameLength = bytes.readUInt16LE(signature + 26);
const extraLength = bytes.readUInt16LE(signature + 28);
const dataOffset = signature + 30 + nameLength + extraLength;
if (compressedSize < 2 || dataOffset + compressedSize > bytes.length) {
throw new Error("ZIP payload missing");
}
bytes[dataOffset + Math.floor(compressedSize / 2)] ^= 0xff;
fs.writeFileSync(zipPath, bytes);
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
@@ -66,6 +83,14 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
expect(events).toEqual([
expect.objectContaining({
version: 1,
archivePath: path.resolve(zipPath),
entryPath: "episode.txt",
outputPath: path.join(targetDir, "episode.txt"),
state: "opened",
disposition: "written"
}),
expect.objectContaining({
version: 1,
archivePath: path.resolve(zipPath),
@@ -109,14 +134,141 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
], { encoding: "utf8" });
expect(run.status).toBe(0);
const outputLine = String(run.stdout).split(/\r?\n/).find((line) => line.startsWith("RD_OUTPUT "));
expect(outputLine).toBeTruthy();
const fields = String(outputLine).split(" ");
const outputLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_OUTPUT "));
expect(outputLines.map((line) => line.split(" ")[2])).toEqual(["opened", "complete"]);
const fields = outputLines[1].split(" ");
expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]);
expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath));
expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt");
expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt"));
});
it.each(["7zjbinding", "zip4j"])("rejects %s output behind a junction before opening it", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-junction-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const realDir = path.join(targetDir, "real");
const linkedDir = path.join(targetDir, "linked");
fs.mkdirSync(realDir, { recursive: true });
try {
fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return;
}
const protectedPath = path.join(realDir, "protected.txt");
fs.writeFileSync(protectedPath, "foreign");
const zipPath = path.join(root, "release.zip");
const zip = new AdmZip();
zip.addFile("linked/protected.txt", 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(protectedPath, "utf8")).toBe("foreign");
});
it("turns JVM output callback failures into a controlled archive failure and keeps the next request usable", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-callback-"));
tempDirs.push(root);
const firstPackage = path.join(root, "first-pkg");
const secondPackage = path.join(root, "second-pkg");
fs.mkdirSync(firstPackage, { recursive: true });
fs.mkdirSync(secondPackage, { recursive: true });
const firstZip = new AdmZip();
firstZip.addFile("first.txt", Buffer.from("first"));
firstZip.writeZip(path.join(firstPackage, "first.zip"));
const secondZip = new AdmZip();
secondZip.addFile("second.txt", Buffer.from("second"));
secondZip.writeZip(path.join(secondPackage, "second.zip"));
const first = await extractPackageArchives({
packageDir: firstPackage,
targetDir: path.join(root, "first-out"),
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: () => {
throw new Error("jvm-output-callback-failed");
}
});
const second = await extractPackageArchives({
packageDir: secondPackage,
targetDir: path.join(root, "second-out"),
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(first.extracted).toBe(0);
expect(first.failed).toBe(1);
expect(first.lastError).toContain("jvm-output-callback-failed");
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
}, 10000);
it.each(["7zjbinding", "zip4j"])("reports %s partial output before removing a failed file", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "corrupt.zip");
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
zip.writeZip(zipPath);
corruptFirstZipPayload(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" });
const states = String(run.stdout)
.split(/\r?\n/)
.filter((line) => line.startsWith("RD_OUTPUT "))
.map((line) => line.split(" ")[2]);
expect(run.status).not.toBe(0);
expect(states[0]).toBe("opened");
expect(states).toContain("partial");
expect(states[states.length - 1]).toBe("removed");
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
+107 -4
View File
@@ -1316,14 +1316,15 @@ describe("extractor", () => {
});
expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent);
expect(events).toEqual([expect.objectContaining({
expect(events.map((event) => event.state)).toEqual(disposition === "skipped" ? ["complete"] : ["opened", "complete"]);
expect(events[events.length - 1]).toEqual(expect.objectContaining({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: "episode.mkv",
outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"),
state: "complete",
disposition
})]);
}));
expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]);
});
@@ -1462,12 +1463,101 @@ describe("extractor", () => {
}
})).rejects.toThrow("aborted:extract");
expect(events).toHaveLength(1);
expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") }));
expect(events.map((event) => event.state)).toEqual(["opened", "complete"]);
expect(events[1]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") }));
expect(fs.existsSync(path.join(targetDir, "first.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false);
});
it("emits opened before writing and complete only after the internal ZIP write", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-lifecycle-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const archivePath = path.join(packageDir, "release.zip");
const zip = new AdmZip();
zip.addFile("episode.mkv", Buffer.from("video"));
zip.writeZip(archivePath);
const states: string[] = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: (event) => states.push(event.state)
});
expect(result.failed).toBe(0);
expect(states).toEqual(["opened", "complete"]);
expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe("video");
});
it("rejects an internal ZIP target behind a junction before changing it", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-junction-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
const realDir = path.join(targetDir, "real");
const linkedDir = path.join(targetDir, "linked");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(realDir, { recursive: true });
try {
fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return;
}
const protectedPath = path.join(realDir, "protected.txt");
fs.writeFileSync(protectedPath, "foreign");
const zip = new AdmZip();
zip.addFile("linked/protected.txt", 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(protectedPath, "utf8")).toBe("foreign");
});
it("aborts an internal ZIP entry callback failure before opening the target", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-callback-"));
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.mkv", Buffer.from("video"));
zip.writeZip(path.join(packageDir, "release.zip"));
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: () => {
throw new Error("output-callback-failed");
}
});
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
expect(result.lastError).toContain("output-callback-failed");
expect(fs.existsSync(path.join(targetDir, "episode.mkv"))).toBe(false);
});
it("strictly parses native output paths and fails closed for ambiguous rename output", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-"));
tempDirs.push(root);
@@ -1495,5 +1585,18 @@ describe("extractor", () => {
]);
});
it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-"));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const outputPath = path.join(targetDir, "episode.mkv");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(outputPath, "video");
expect(parseNativeExtractOutput("UnRAR.exe", `${verb} ${outputPath} OK`, path.join(root, "archive.rar"), targetDir, "overwrite")).toEqual([
expect.objectContaining({ outputPath, entryPath: "episode.mkv" })
]);
});
});
});
+23
View File
@@ -156,4 +156,27 @@ describe("PackageOutputScope", () => {
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([]);
});
});