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:
parent
d4dd266f6b
commit
b971a79047
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.17",
|
||||
"version": "1.4.18",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -110,8 +110,11 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
|
||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = fs.statSync(full);
|
||||
if (stat.size <= 256 * 1024) {
|
||||
const text = fs.readFileSync(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
} catch {
|
||||
shouldDelete = false;
|
||||
}
|
||||
|
||||
@ -76,7 +76,21 @@ type DownloadManagerOptions = {
|
||||
};
|
||||
|
||||
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 {
|
||||
@ -1605,6 +1619,8 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
delete this.session.packages[packageId];
|
||||
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||
this.runPackageIds.delete(packageId);
|
||||
this.runCompletedPackages.delete(packageId);
|
||||
}
|
||||
|
||||
private async ensureScheduler(): Promise<void> {
|
||||
@ -2120,7 +2136,13 @@ export class DownloadManager extends EventEmitter {
|
||||
let lastError = "";
|
||||
let effectiveTargetPath = targetPath;
|
||||
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> = {};
|
||||
if (existingBytes > 0) {
|
||||
headers.Range = `bytes=${existingBytes}-`;
|
||||
@ -2884,13 +2906,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const itemId of 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);
|
||||
this.removePackageFromSession(packageId, [...pkg.itemIds]);
|
||||
}
|
||||
|
||||
private applyCompletedCleanupPolicy(packageId: string, itemId: string): void {
|
||||
@ -2907,28 +2923,20 @@ export class DownloadManager extends EventEmitter {
|
||||
if (policy === "immediate") {
|
||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||
delete this.session.items[itemId];
|
||||
if (pkg.itemIds.length === 0) {
|
||||
this.removePackageFromSession(packageId, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy === "package_done") {
|
||||
const hasOpen = pkg.itemIds.some((id) => {
|
||||
const item = this.session.items[id];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
return item.status !== "completed";
|
||||
return item != null && item.status !== "completed";
|
||||
});
|
||||
if (!hasOpen) {
|
||||
for (const id of pkg.itemIds) {
|
||||
delete this.session.items[id];
|
||||
this.removePackageFromSession(packageId, [...pkg.itemIds]);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -936,7 +936,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||
archivePercent = 100;
|
||||
} 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);
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||
}, options.signal);
|
||||
|
||||
@ -52,6 +52,10 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
||||
const filePath = path.join(packageDir, entry.name);
|
||||
let lines: string[];
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 5 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@ -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 {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||
const payload = JSON.stringify(persisted, null, 2);
|
||||
const tempPath = `${paths.configFile}.tmp`;
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
fs.renameSync(tempPath, paths.configFile);
|
||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||
}
|
||||
|
||||
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 tempPath = `${paths.sessionFile}.tmp`;
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
fs.renameSync(tempPath, paths.sessionFile);
|
||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||
}
|
||||
|
||||
let asyncSaveRunning = false;
|
||||
|
||||
@ -21,7 +21,7 @@ export function compactErrorText(message: unknown, maxLen = 220): 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";
|
||||
}
|
||||
|
||||
|
||||
@ -421,4 +421,30 @@ describe("extractor", () => {
|
||||
expect(result.failed).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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,8 @@ describe("utils", () => {
|
||||
it("sanitizes filenames", () => {
|
||||
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
|
||||
expect(sanitizeFilename(" ")).toBe("Paket");
|
||||
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
|
||||
expect(sanitizeFilename("\0\0\0")).toBe("Paket");
|
||||
});
|
||||
|
||||
it("parses package markers", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user