From a266610c3193668a98bbf3339379a216287101e9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 12:27:59 +0200 Subject: [PATCH] fix: scope extracted output counts to package provenance Snapshot non-archive outputs around each main, hybrid, and nested extraction and attribute only files created or changed by that package. Persist deduplicated hashed provenance keys so shared extract directories and restarts cannot count unrelated package outputs. Cover startup extraction in a shared target containing an existing foreign file and validate persisted provenance normalization. --- src/main/download-manager.ts | 54 ++++++++++++++++++++++++---------- src/main/storage.ts | 3 ++ src/shared/types.ts | 1 + tests/download-manager.test.ts | 8 +++-- tests/storage.test.ts | 2 ++ 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 65bab73..db57975 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4363,12 +4363,12 @@ export class DownloadManager extends EventEmitter { return false; } - private async countPackageOutputFiles(rootDir: string): Promise { + private async snapshotPackageOutputFiles(rootDir: string): Promise> { + const snapshot = new Map(); if (!rootDir) { - return 0; + return snapshot; } const stack = [rootDir]; - let count = 0; while (stack.length > 0) { const current = stack.pop() as string; let entries: fs.Dirent[] = []; @@ -4385,11 +4385,29 @@ export class DownloadManager extends EventEmitter { if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) { - count += 1; + try { + const stat = await fs.promises.stat(fullPath); + const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/"); + const key = process.platform === "win32" ? relativePath.toLowerCase() : relativePath; + snapshot.set(key, `${stat.size}:${stat.mtimeMs}`); + } catch { + } } } } - return count; + return snapshot; + } + + private async recordPackageOutputFiles(pkg: PackageEntry, before: ReadonlyMap): Promise { + const after = await this.snapshotPackageOutputFiles(pkg.extractDir); + const provenance = new Set(pkg.outputProvenance || []); + for (const [relativePath, signature] of after) { + if (before.get(relativePath) !== signature) { + provenance.add(createHash("sha256").update(relativePath).digest("hex")); + } + } + pkg.outputProvenance = [...provenance]; + pkg.outputCount = Math.max(pkg.outputCount || 0, provenance.size); } private async removeEmptyDirectoryTree(rootDir: string): Promise { @@ -12244,6 +12262,7 @@ export class DownloadManager extends EventEmitter { pkg.archiveOperations = []; pkg.remuxOperations = []; pkg.outputCount = 0; + pkg.outputProvenance = []; pkg.cleanupErrorCategory = ""; } return next; @@ -13221,11 +13240,12 @@ export class DownloadManager extends EventEmitter { try { await this.waitForCompletedArchiveFilesToSettle(pkg, hybridItems, signal, "hybrid"); - if (signal?.aborted) { - return 0; - } - - const result = await extractPackageArchives({ + if (signal?.aborted) { + return 0; + } + + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); + const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13384,7 +13404,7 @@ export class DownloadManager extends EventEmitter { } } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { @@ -13799,9 +13819,10 @@ export class DownloadManager extends EventEmitter { preExtractStatuses.set(entry.id, String(entry.fullStatus || "").trim()); entry.fullStatus = "Entpacken - Ausstehend"; entry.updatedAt = pendingAt; - } - this.emitState(); - const result = await extractPackageArchives({ + } + this.emitState(); + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); + const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13943,7 +13964,7 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(overallLabel); } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { extracted: result.extracted, @@ -14150,6 +14171,7 @@ export class DownloadManager extends EventEmitter { }); const nestedFailureCategories = new Map(); const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const packageOutputBefore = await this.snapshotPackageOutputFiles(pkg.extractDir); const nestedResult = await extractPackageArchives({ packageDir: pkg.extractDir, targetDir: pkg.extractDir, @@ -14177,7 +14199,7 @@ export class DownloadManager extends EventEmitter { } }); throwIfAborted(); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.recordPackageOutputFiles(pkg, packageOutputBefore); extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { diff --git a/src/main/storage.ts b/src/main/storage.ts index 5984c95..33d0e80 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1007,6 +1007,9 @@ export function normalizeLoadedSession(raw: unknown): SessionState { archiveOperations: normalizeArchiveOperations(pkg.archiveOperations), remuxOperations: normalizeRemuxOperations(pkg.remuxOperations), outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000), + outputProvenance: Array.isArray(pkg.outputProvenance) + ? [...new Set(pkg.outputProvenance.map((value) => asText(value).toLowerCase()).filter((value) => /^[a-f0-9]{64}$/.test(value)))].slice(0, 1_000_000) + : [], cleanupErrorCategory: asText(pkg.cleanupErrorCategory), resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), diff --git a/src/shared/types.ts b/src/shared/types.ts index 8f4506a..028ce74 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -588,6 +588,7 @@ export interface PackageEntry { archiveOperations?: ArchiveOperationMetric[]; remuxOperations?: RemuxOperationMetric[]; outputCount?: number; + outputProvenance?: string[]; cleanupErrorCategory?: string; resultGeneration?: number; createdAt: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 2fece3c..d6fed80 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -11466,8 +11466,10 @@ describe("download manager", () => { tempDirs.push(root); const outputDir = path.join(root, "downloads", "recovery"); - const extractDir = path.join(root, "extract", "recovery"); - fs.mkdirSync(outputDir, { recursive: true }); + const extractDir = path.join(root, "extract"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "foreign-package.txt"), "foreign"); const zip = new AdmZip(); zip.addFile("episode.txt", Buffer.from("ok")); @@ -11518,7 +11520,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), - createExtractSubfolder: true, + createExtractSubfolder: false, autoExtract: true, enableIntegrityCheck: false, cleanupMode: "none" diff --git a/tests/storage.test.ts b/tests/storage.test.ts index c64a58e..0db7385 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1200,6 +1200,7 @@ describe("settings storage", () => { }], remuxOperations: [], outputCount: 1, + outputProvenance: ["a".repeat(64), "invalid", "a".repeat(64)], cleanupErrorCategory: "", createdAt: 1_000, updatedAt: 21_000 @@ -1226,6 +1227,7 @@ describe("settings storage", () => { archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })], remuxOperations: [], outputCount: 1, + outputProvenance: ["a".repeat(64)], cleanupErrorCategory: "" })); });