Fix: Startup-Dedup ersetzt keine gute kanonische Datei mehr durch ein kleineres Duplikat

Beim Start gleicht das Tool Duplikat-benannte Dateien ("Name (1).ext") gegen die
kanonische Datei ("Name.ext") ab. Im Zweig "Duplikat rangiert hoeher und das
kanonische Item ist nicht 'completed'" wurde bisher bedingungslos
fs.rmSync(kanonisch) + fs.renameSync(Duplikat → kanonisch) ausgefuehrt — ohne die
echten Dateigroessen zu vergleichen. Der Rang mischt den persistierten STATUS mit
der Disk-Existenz; nach einem Absturz kann der Status von der Realitaet abweichen.
War die kanonische Datei auf der Platte tatsaechlich vollstaendig/gut, ihr Item
aber z.B. "failed"/"queued", waehrend das Duplikat-Item ein veraltetes
"completed" ueber einer kleineren/partiellen Datei trug, wurde die gute
kanonische Datei geloescht und durch das schlechtere Duplikat ersetzt —
unwiederbringlicher Verlust der besseren Datei. Zusatzrisiko: rmSync erfolgreich,
renameSync wirft EXDEV → kanonisch weg, nichts installiert.

Fix:
- Size-Guard: Ist die kanonische Datei >= dem Duplikat, wird sie behalten und das
  (kleinere/gleich grosse) Duplikat verworfen. Eine kleinere Datei kann eine
  groessere nie ueberschreiben.
- Sichere Reihenfolge fuer den echten Austausch (kanonisch kleiner): kanonisch →
  ".dedupbak" umbenennen, dann Duplikat → kanonisch; bei Fehler (EXDEV o.ae.)
  wird die kanonische Datei aus dem Backup wiederhergestellt. Kein Loss-Fenster
  mehr. Erst nach Erfolg wird ".dedupbak" entfernt.

Test: kanonisch 1024 B (gut) + Duplikat 256 B (partiell), primaeres Item "failed"
(Duplikat rangiert hoeher) → die kanonische Datei bleibt mit 1024 B erhalten.
Ohne den Size-Guard wird sie durch die 256-B-Datei ersetzt (rot bewiesen). Volle
download-manager-Suite (168) gruen, tsc unveraendert 6.
This commit is contained in:
Sucukdeluxe
2026-06-17 05:13:40 +02:00
parent 45789918b0
commit c8ea2f6765
3 changed files with 168 additions and 18 deletions
+90
View File
@@ -536,6 +536,96 @@ describe("download manager", () => {
expect(fs.existsSync(duplicatePath)).toBe(false);
});
it("does not replace a larger good canonical file with a smaller duplicate on startup dedup", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-size-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "dup-size-pkg";
const originalItemId = "dup-size-original";
const duplicateItemId = "dup-size-copy";
const createdAt = Date.now() - 20_000;
const outputDir = path.join(root, "downloads", "Dup Size");
const extractDir = path.join(root, "extract", "Dup Size");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
const canonicalPath = path.join(outputDir, "movie.mkv");
const duplicatePath = path.join(outputDir, "movie (1).mkv");
fs.writeFileSync(canonicalPath, Buffer.alloc(1024, 5));
fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Dup Size",
outputDir,
extractDir,
status: "completed",
itemIds: [originalItemId, duplicateItemId],
cancelled: false,
enabled: true,
priority: "normal",
createdAt,
updatedAt: createdAt
};
session.items[originalItemId] = {
id: originalItemId,
packageId,
url: "https://example.com/movie.mkv",
provider: "realdebrid",
status: "failed",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: 1024,
progressPercent: 0,
fileName: "movie.mkv",
targetPath: canonicalPath,
resumable: true,
attempts: 1,
lastError: "Fehlgeschlagen",
fullStatus: "",
createdAt,
updatedAt: createdAt + 10_000
};
session.items[duplicateItemId] = {
id: duplicateItemId,
packageId,
url: "https://example.com/movie.mkv",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 256,
totalBytes: 256,
progressPercent: 100,
fileName: "movie.mkv",
targetPath: duplicatePath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt + 5_000
};
new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false
},
session,
createStoragePaths(path.join(root, "state"))
);
expect(fs.existsSync(canonicalPath)).toBe(true);
expect(fs.statSync(canonicalPath).size).toBe(1024);
});
it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-"));
tempDirs.push(root);