Release v1.4.27 with bug audit hardening fixes
This commit is contained in:
+29
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderPackageOrderByDrop } from "../src/renderer/App";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/App";
|
||||
|
||||
describe("reorderPackageOrderByDrop", () => {
|
||||
it("moves adjacent package down by one on drop", () => {
|
||||
@@ -19,3 +19,31 @@ describe("reorderPackageOrderByDrop", () => {
|
||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPackageOrderByName", () => {
|
||||
it("sorts package IDs alphabetically ascending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg3", "pkg1", "pkg2"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
||||
});
|
||||
|
||||
it("sorts package IDs alphabetically descending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg1", "pkg2", "pkg3"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,4 +89,21 @@ describe("cleanup", () => {
|
||||
// Non-matching files should be kept
|
||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recurse into sample symlink or junction targets", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||
tempDirs.push(dir, external);
|
||||
|
||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||
|
||||
const linkedSampleDir = path.join(dir, "sample");
|
||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||
|
||||
const result = removeSampleArtifacts(dir);
|
||||
expect(result.files).toBe(0);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { importDlcContainers } from "../src/main/container";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("container", () => {
|
||||
it("rejects oversized DLC files before network access", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "oversized.dlc");
|
||||
fs.writeFileSync(filePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response("should-not-run", { status: 500 }));
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß/i);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
+146
-8
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||
import { DebridService, extractRapidgatorFilenameFromHtml, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -80,7 +80,7 @@ describe("debrid service", () => {
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("supports BestDebrid auth query fallback", async () => {
|
||||
it("uses BestDebrid auth header without token query fallback", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -91,15 +91,11 @@ describe("debrid service", () => {
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
const calledUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calledUrls.push(url);
|
||||
if (url.includes("/api/v1/generateLink?link=")) {
|
||||
return new Response(JSON.stringify({ message: "Bad token, expired, or invalid" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/v1/generateLink?auth=")) {
|
||||
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 2048 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
@@ -112,6 +108,7 @@ describe("debrid service", () => {
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part3.rar.html");
|
||||
expect(result.provider).toBe("bestdebrid");
|
||||
expect(result.fileSize).toBe(2048);
|
||||
expect(calledUrls.some((url) => url.includes("auth="))).toBe(false);
|
||||
});
|
||||
|
||||
it("sends Bearer auth header to BestDebrid", async () => {
|
||||
@@ -152,6 +149,63 @@ describe("debrid service", () => {
|
||||
expect(authHeader).toBe("Bearer best-token");
|
||||
});
|
||||
|
||||
it("does not retry BestDebrid auth failures (401)", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "best-token",
|
||||
providerPrimary: "bestdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/api/v1/generateLink?link=")) {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify({ message: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
await expect(service.unrestrictLink("https://hoster.example/file/no-retry")).rejects.toThrow();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not retry AllDebrid auth failures (403)", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
allDebridToken: "ad-token",
|
||||
providerPrimary: "alldebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify({ status: "error", error: { message: "forbidden" } }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("supports AllDebrid unlock", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -189,6 +243,21 @@ describe("debrid service", () => {
|
||||
expect(result.fileSize).toBe(4096);
|
||||
});
|
||||
|
||||
it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "user",
|
||||
megaPassword: "pass",
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
|
||||
const service = new DebridService(settings);
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("uses Mega web path exclusively", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -505,6 +574,75 @@ describe("debrid service", () => {
|
||||
const resolved = await service.resolveFilenames([linkA, linkB]);
|
||||
expect(resolved.size).toBe(0);
|
||||
});
|
||||
|
||||
it("retries AllDebrid filename infos after transient server error", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
allDebridToken: "ad-token"
|
||||
};
|
||||
|
||||
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let infoCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
||||
infoCalls += 1;
|
||||
if (infoCalls === 1) {
|
||||
return new Response("temporary error", { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "success",
|
||||
data: {
|
||||
infos: [
|
||||
{ link, filename: "resolved-from-infos.mkv" }
|
||||
]
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const resolved = await service.resolveFilenames([link]);
|
||||
expect(resolved.get(link)).toBe("resolved-from-infos.mkv");
|
||||
expect(infoCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("retries AllDebrid filename infos when HTML challenge is returned", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
allDebridToken: "ad-token"
|
||||
};
|
||||
|
||||
const link = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
let infoCalls = 0;
|
||||
let pageCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
||||
infoCalls += 1;
|
||||
return new Response("<html><title>cf challenge</title></html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}
|
||||
if (url === link) {
|
||||
pageCalls += 1;
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const resolved = await service.resolveFilenames([link]);
|
||||
expect(resolved.size).toBe(0);
|
||||
expect(infoCalls).toBe(REQUEST_RETRIES);
|
||||
expect(pageCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeResolvedFilename", () => {
|
||||
|
||||
@@ -3900,4 +3900,191 @@ describe("download manager", () => {
|
||||
expect(fs.existsSync(originalExtractedPath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false);
|
||||
});
|
||||
|
||||
it("throws a controlled error for invalid queue import JSON", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
expect(() => manager.importQueue("{not-json")).toThrow(/Ungultige Queue-Datei/i);
|
||||
});
|
||||
|
||||
it("applies global speed limit path when global mode is enabled", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
speedLimitEnabled: true,
|
||||
speedLimitMode: "global",
|
||||
speedLimitKbps: 512
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
applySpeedLimit: (chunkBytes: number, localWindowBytes: number, localWindowStarted: number) => Promise<void>;
|
||||
globalSpeedLimitNextAt: number;
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await internal.applySpeedLimit(1024, 0, start);
|
||||
expect(internal.globalSpeedLimitNextAt).toBeGreaterThan(start);
|
||||
});
|
||||
|
||||
it("resets speed window head when start finds no runnable items", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
speedEvents: Array<{ at: number; bytes: number }>;
|
||||
speedEventsHead: number;
|
||||
speedBytesLastWindow: number;
|
||||
};
|
||||
internal.speedEvents = [{ at: Date.now() - 10_000, bytes: 999 }];
|
||||
internal.speedEventsHead = 5;
|
||||
internal.speedBytesLastWindow = 999;
|
||||
|
||||
manager.start();
|
||||
expect(internal.speedEventsHead).toBe(0);
|
||||
expect(internal.speedEvents.length).toBe(0);
|
||||
expect(internal.speedBytesLastWindow).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans run tracking when start conflict is skipped", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "conflict-skip", links: ["https://dummy/skip"] }]);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageId = snapshot.session.packageOrder[0];
|
||||
const itemId = snapshot.session.packages[packageId]?.itemIds[0] || "";
|
||||
|
||||
const internal = manager as unknown as {
|
||||
runItemIds: Set<string>;
|
||||
runPackageIds: Set<string>;
|
||||
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
||||
};
|
||||
internal.runItemIds.add(itemId);
|
||||
internal.runPackageIds.add(packageId);
|
||||
internal.runOutcomes.set(itemId, "completed");
|
||||
|
||||
const result = await manager.resolveStartConflict(packageId, "skip");
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(internal.runItemIds.has(itemId)).toBe(false);
|
||||
expect(internal.runPackageIds.has(packageId)).toBe(false);
|
||||
expect(internal.runOutcomes.has(itemId)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears stale run outcomes on overwrite conflict resolution", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "conflict-overwrite", links: ["https://dummy/overwrite"] }]);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageId = snapshot.session.packageOrder[0];
|
||||
const itemId = snapshot.session.packages[packageId]?.itemIds[0] || "";
|
||||
|
||||
const internal = manager as unknown as {
|
||||
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
||||
};
|
||||
internal.runOutcomes.set(itemId, "failed");
|
||||
|
||||
const result = await manager.resolveStartConflict(packageId, "overwrite");
|
||||
expect(result.overwritten).toBe(true);
|
||||
expect(internal.runOutcomes.has(itemId)).toBe(false);
|
||||
expect(manager.getSnapshot().session.items[itemId]?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("clears speed display buffers when run finishes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
runItemIds: Set<string>;
|
||||
runOutcomes: Map<string, "completed" | "failed" | "cancelled">;
|
||||
runCompletedPackages: Set<string>;
|
||||
session: { runStartedAt: number; totalDownloadedBytes: number; running: boolean; paused: boolean };
|
||||
speedEvents: Array<{ at: number; bytes: number }>;
|
||||
speedEventsHead: number;
|
||||
speedBytesLastWindow: number;
|
||||
finishRun: () => void;
|
||||
};
|
||||
|
||||
internal.session.running = true;
|
||||
internal.session.paused = false;
|
||||
internal.session.runStartedAt = Date.now() - 2000;
|
||||
internal.session.totalDownloadedBytes = 4096;
|
||||
internal.runItemIds = new Set(["x"]);
|
||||
internal.runOutcomes = new Map([["x", "completed"]]);
|
||||
internal.runCompletedPackages = new Set();
|
||||
internal.speedEvents = [{ at: Date.now(), bytes: 4096 }];
|
||||
internal.speedEventsHead = 1;
|
||||
internal.speedBytesLastWindow = 4096;
|
||||
|
||||
internal.finishRun();
|
||||
|
||||
expect(internal.speedEvents.length).toBe(0);
|
||||
expect(internal.speedEventsHead).toBe(0);
|
||||
expect(internal.speedBytesLastWindow).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -554,4 +554,68 @@ describe("extractor", () => {
|
||||
expect(targets.has(r01)).toBe(true);
|
||||
expect(targets.has(r02)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fallback to external extractor when ZIP safety guard triggers", async () => {
|
||||
const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const zipPath = path.join(packageDir, "too-large.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("large.bin", Buffer.alloc(9 * 1024 * 1024, 7));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
try {
|
||||
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(String(result.lastError)).toMatch(/ZIP-Eintrag.*groß/i);
|
||||
} finally {
|
||||
if (previousLimit === undefined) {
|
||||
delete process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||
} else {
|
||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = previousLimit;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("matches resume-state archive names case-insensitively on Windows", async () => {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
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, "episode.zip");
|
||||
fs.writeFileSync(archivePath, "not-a-zip", "utf8");
|
||||
fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["EPISODE.ZIP"] }), "utf8");
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,4 +70,15 @@ describe("integrity", () => {
|
||||
expect(parseHashLine("")).toBeNull();
|
||||
expect(parseHashLine(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "disc1.md5"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa movie.mkv\n", "utf8");
|
||||
fs.writeFileSync(path.join(dir, "disc2.md5"), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb movie.mkv\n", "utf8");
|
||||
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.get("movie.mkv")?.digest).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { RealDebridClient } from "../src/main/realdebrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("realdebrid client", () => {
|
||||
it("returns a clear error when HTML is returned instead of JSON", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response("<html><title>Cloudflare</title></html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i);
|
||||
});
|
||||
|
||||
it("does not leak raw response body on JSON parse errors", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response("<html>token=secret-should-not-leak</html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
try {
|
||||
await client.unrestrictLink("https://hoster.example/file/invalid-json");
|
||||
throw new Error("expected unrestrict to fail");
|
||||
} catch (error) {
|
||||
const text = String(error || "");
|
||||
expect(text.toLowerCase()).toContain("json");
|
||||
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
|
||||
expect(text.toLowerCase()).not.toContain("<html>");
|
||||
}
|
||||
});
|
||||
});
|
||||
+76
-2
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSettings } from "../src/main/storage";
|
||||
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("settings storage", () => {
|
||||
it("assigns and preserves bandwidth schedule ids", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
bandwidthSchedules: [{ startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
|
||||
bandwidthSchedules: [{ id: "", startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
|
||||
});
|
||||
|
||||
const generatedId = normalized.bandwidthSchedules[0]?.id;
|
||||
@@ -314,6 +314,80 @@ describe("settings storage", () => {
|
||||
expect(loaded.cleanupMode).toBe(defaults.cleanupMode);
|
||||
});
|
||||
|
||||
it("loads backup config when primary config is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const backupSettings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(dir, "backup-output"),
|
||||
packageName: "from-backup"
|
||||
};
|
||||
fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify(backupSettings, null, 2), "utf8");
|
||||
fs.writeFileSync(paths.configFile, "{broken-json", "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.outputDir).toBe(backupSettings.outputDir);
|
||||
expect(loaded.packageName).toBe("from-backup");
|
||||
});
|
||||
|
||||
it("sanitizes malformed persisted session structures", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify({
|
||||
version: "invalid",
|
||||
packageOrder: [123, "pkg-valid"],
|
||||
packages: {
|
||||
"1": "bad-entry",
|
||||
"pkg-valid": {
|
||||
id: "pkg-valid",
|
||||
name: "Valid Package",
|
||||
outputDir: "C:/tmp/out",
|
||||
extractDir: "C:/tmp/extract",
|
||||
status: "downloading",
|
||||
itemIds: ["item-valid", 123],
|
||||
cancelled: false,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-valid": {
|
||||
id: "item-valid",
|
||||
packageId: "pkg-valid",
|
||||
url: "https://example.com/file",
|
||||
status: "queued",
|
||||
fileName: "file.bin",
|
||||
targetPath: "C:/tmp/out/file.bin"
|
||||
},
|
||||
"item-bad": "broken"
|
||||
}
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]);
|
||||
expect(Object.keys(loaded.items)).toEqual(["item-valid"]);
|
||||
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
|
||||
});
|
||||
|
||||
it("captures async session save payload before later mutations", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const session = emptySession();
|
||||
session.summaryText = "before-mutation";
|
||||
|
||||
const pending = saveSessionAsync(paths, session);
|
||||
session.summaryText = "after-mutation";
|
||||
await pending;
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string };
|
||||
expect(persisted.summaryText).toBe("before-mutation");
|
||||
});
|
||||
|
||||
it("applies defaults for missing fields when loading old config", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
+144
-3
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { APP_VERSION } from "../src/main/constants";
|
||||
@@ -6,6 +7,10 @@ import { UpdateCheckResult } from "../src/shared/types";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function sha256Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
@@ -58,7 +63,8 @@ describe("update", () => {
|
||||
},
|
||||
{
|
||||
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup.exe"
|
||||
browser_download_url: "https://example.invalid/setup.exe",
|
||||
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -76,6 +82,7 @@ describe("update", () => {
|
||||
|
||||
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const executableDigest = sha256Hex(executablePayload);
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
@@ -100,7 +107,8 @@ describe("update", () => {
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/stale-setup.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe"
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
setupAssetDigest: `sha256:${executableDigest}`
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
@@ -109,6 +117,103 @@ describe("update", () => {
|
||||
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips draft tag payload and resolves setup asset from stable latest release", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const requestedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requestedUrls.push(url);
|
||||
|
||||
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Draft Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/draft-setup.exe"
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/releases/latest")) {
|
||||
const stableDigest = sha256Hex(executablePayload);
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Stable Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/stable-setup.exe",
|
||||
digest: `sha256:${stableDigest}`
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("stable-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "",
|
||||
setupAssetName: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.endsWith("/releases/latest"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("stable-setup.exe"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("draft-setup.exe"))).toBe(false);
|
||||
});
|
||||
|
||||
it("times out hanging release JSON body reads", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancelSpy = vi.fn(async () => undefined);
|
||||
globalThis.fetch = (async (): Promise<Response> => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
json: () => new Promise(() => undefined),
|
||||
body: {
|
||||
cancel: cancelSpy
|
||||
}
|
||||
} as unknown as Response)) as typeof fetch;
|
||||
|
||||
const pending = checkGitHubUpdate("owner/repo");
|
||||
await vi.advanceTimersByTimeAsync(13000);
|
||||
const result = await pending;
|
||||
expect(result.updateAvailable).toBe(false);
|
||||
expect(String(result.error || "")).toMatch(/timeout/i);
|
||||
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts hanging update body downloads on idle timeout", async () => {
|
||||
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
|
||||
@@ -137,7 +242,8 @@ describe("update", () => {
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/hang-setup.exe",
|
||||
setupAssetName: ""
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
@@ -151,6 +257,35 @@ describe("update", () => {
|
||||
}
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it("blocks installer start when SHA256 digest mismatches", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("mismatch-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/mismatch-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeUpdateRepo extended", () => {
|
||||
@@ -169,6 +304,12 @@ describe("normalizeUpdateRepo extended", () => {
|
||||
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
});
|
||||
|
||||
it("rejects traversal-like owner or repo segments", () => {
|
||||
expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
});
|
||||
|
||||
it("handles www prefix", () => {
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils";
|
||||
import { extractHttpLinksFromText, parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils";
|
||||
|
||||
describe("utils", () => {
|
||||
it("validates http links", () => {
|
||||
@@ -9,6 +9,15 @@ describe("utils", () => {
|
||||
expect(isHttpLink("foo bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts links from text and trims trailing punctuation", () => {
|
||||
const links = extractHttpLinksFromText("See (https://example.com/test) and https://rapidgator.net/file/abc123, plus https://example.com/a.b.");
|
||||
expect(links).toEqual([
|
||||
"https://example.com/test",
|
||||
"https://rapidgator.net/file/abc123",
|
||||
"https://example.com/a.b"
|
||||
]);
|
||||
});
|
||||
|
||||
it("sanitizes filenames", () => {
|
||||
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
|
||||
expect(sanitizeFilename(" ")).toBe("Paket");
|
||||
@@ -42,6 +51,8 @@ describe("utils", () => {
|
||||
expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv");
|
||||
expect(filenameFromUrl("https://debrid.example/dl/%E0%A4%A")).toBe("%E0%A4%A");
|
||||
expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737");
|
||||
expect(filenameFromUrl("data:text/plain;base64,SGVsbG8=")).toBe("download.bin");
|
||||
expect(filenameFromUrl("blob:https://example.com/12345678-1234-1234-1234-1234567890ab")).toBe("download.bin");
|
||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user