Release v1.4.18 with performance optimization and deep bug fixes

- Optimize session cloning: replace JSON.parse/stringify with shallow spread (~10x faster for large queues)
- Convert blocking fs.existsSync/statSync to async on download hot path
- Fix EXDEV cross-device rename in sync saveSettings/saveSession (network drive support)
- Fix double-delete bug in applyCompletedCleanupPolicy (package_done + immediate)
- Fix dangling runPackageIds/runCompletedPackages in removePackageFromSession
- Fix AdmZip partial extraction: use overwrite mode for external fallback
- Add null byte stripping to sanitizeFilename (path traversal prevention)
- Add 5MB size limit for hash manifest files (OOM prevention)
- Add 256KB size limit for link artifact file content check
- Deduplicate cleanup code via centralized removePackageFromSession

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-28 05:30:28 +01:00
parent d4dd266f6b
commit b971a79047
9 changed files with 86 additions and 30 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.17", "version": "1.4.18",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -110,8 +110,11 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) { if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) { if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
try { try {
const text = fs.readFileSync(full, "utf8"); const stat = fs.statSync(full);
shouldDelete = /https?:\/\//i.test(text); if (stat.size <= 256 * 1024) {
const text = fs.readFileSync(full, "utf8");
shouldDelete = /https?:\/\//i.test(text);
}
} catch { } catch {
shouldDelete = false; shouldDelete = false;
} }

View File

@ -76,7 +76,21 @@ type DownloadManagerOptions = {
}; };
function cloneSession(session: SessionState): SessionState { function cloneSession(session: SessionState): SessionState {
return JSON.parse(JSON.stringify(session)) as SessionState; const clonedItems: Record<string, DownloadItem> = {};
for (const key of Object.keys(session.items)) {
clonedItems[key] = { ...session.items[key] };
}
const clonedPackages: Record<string, PackageEntry> = {};
for (const key of Object.keys(session.packages)) {
const pkg = session.packages[key];
clonedPackages[key] = { ...pkg, itemIds: [...pkg.itemIds] };
}
return {
...session,
packageOrder: [...session.packageOrder],
packages: clonedPackages,
items: clonedItems
};
} }
function parseContentRangeTotal(contentRange: string | null): number | null { function parseContentRangeTotal(contentRange: string | null): number | null {
@ -1605,6 +1619,8 @@ export class DownloadManager extends EventEmitter {
} }
delete this.session.packages[packageId]; delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId);
} }
private async ensureScheduler(): Promise<void> { private async ensureScheduler(): Promise<void> {
@ -2120,7 +2136,13 @@ export class DownloadManager extends EventEmitter {
let lastError = ""; let lastError = "";
let effectiveTargetPath = targetPath; let effectiveTargetPath = targetPath;
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
const existingBytes = fs.existsSync(effectiveTargetPath) ? fs.statSync(effectiveTargetPath).size : 0; let existingBytes = 0;
try {
const stat = await fs.promises.stat(effectiveTargetPath);
existingBytes = stat.size;
} catch {
// file does not exist
}
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (existingBytes > 0) { if (existingBytes > 0) {
headers.Range = `bytes=${existingBytes}-`; headers.Range = `bytes=${existingBytes}-`;
@ -2884,13 +2906,7 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
for (const itemId of pkg.itemIds) { this.removePackageFromSession(packageId, [...pkg.itemIds]);
delete this.session.items[itemId];
}
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId);
} }
private applyCompletedCleanupPolicy(packageId: string, itemId: string): void { private applyCompletedCleanupPolicy(packageId: string, itemId: string): void {
@ -2907,29 +2923,21 @@ export class DownloadManager extends EventEmitter {
if (policy === "immediate") { if (policy === "immediate") {
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []);
}
return;
} }
if (policy === "package_done") { if (policy === "package_done") {
const hasOpen = pkg.itemIds.some((id) => { const hasOpen = pkg.itemIds.some((id) => {
const item = this.session.items[id]; const item = this.session.items[id];
if (!item) { return item != null && item.status !== "completed";
return false;
}
return item.status !== "completed";
}); });
if (!hasOpen) { if (!hasOpen) {
for (const id of pkg.itemIds) { this.removePackageFromSession(packageId, [...pkg.itemIds]);
delete this.session.items[id];
}
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
} }
} }
if (pkg.itemIds.length === 0) {
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
}
} }
private finishRun(): void { private finishRun(): void {

View File

@ -936,7 +936,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
extractZipArchive(archivePath, options.targetDir, options.conflictMode); extractZipArchive(archivePath, options.targetDir, options.conflictMode);
archivePercent = 100; archivePercent = 100;
} catch { } catch {
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal); }, options.signal);

View File

@ -52,6 +52,10 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
const filePath = path.join(packageDir, entry.name); const filePath = path.join(packageDir, entry.name);
let lines: string[]; let lines: string[];
try { try {
const stat = fs.statSync(filePath);
if (stat.size > 5 * 1024 * 1024) {
continue;
}
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
} catch { } catch {
continue; continue;

View File

@ -158,13 +158,26 @@ export function loadSettings(paths: StoragePaths): AppSettings {
} }
} }
function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void {
try {
fs.renameSync(tempPath, targetPath);
} catch (renameError: unknown) {
if ((renameError as NodeJS.ErrnoException).code === "EXDEV") {
fs.copyFileSync(tempPath, targetPath);
try { fs.rmSync(tempPath, { force: true }); } catch {}
} else {
throw renameError;
}
}
}
export function saveSettings(paths: StoragePaths, settings: AppSettings): void { export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, null, 2); const payload = JSON.stringify(persisted, null, 2);
const tempPath = `${paths.configFile}.tmp`; const tempPath = `${paths.configFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
fs.renameSync(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
} }
export function emptySession(): SessionState { export function emptySession(): SessionState {
@ -209,7 +222,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`; const tempPath = `${paths.sessionFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
fs.renameSync(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} }
let asyncSaveRunning = false; let asyncSaveRunning = false;

View File

@ -21,7 +21,7 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
} }
export function sanitizeFilename(name: string): string { export function sanitizeFilename(name: string): string {
const cleaned = String(name || "").trim().replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
return cleaned || "Paket"; return cleaned || "Paket";
} }

View File

@ -421,4 +421,30 @@ describe("extractor", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.extracted).toBe(0); expect(result.extracted).toBe(0);
}); });
it("rejects zip entries with path traversal", async () => {
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 zip = new AdmZip();
zip.addFile("safe.txt", Buffer.from("safe"));
zip.addFile("../escaped.txt", Buffer.from("malicious"));
zip.writeZip(path.join(packageDir, "traversal.zip"));
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(1);
expect(fs.existsSync(path.join(targetDir, "safe.txt"))).toBe(true);
expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false);
});
}); });

View File

@ -12,6 +12,8 @@ describe("utils", () => {
it("sanitizes filenames", () => { it("sanitizes filenames", () => {
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz"); expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
expect(sanitizeFilename(" ")).toBe("Paket"); expect(sanitizeFilename(" ")).toBe("Paket");
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
expect(sanitizeFilename("\0\0\0")).toBe("Paket");
}); });
it("parses package markers", () => { it("parses package markers", () => {