fix(engine): cross-backend integrity guard with retry-then-damaged

A truncated debrid download (in-memory byte counter hit 100% while the
NFS write-back silently dropped most of the bytes) was marked completed.
The 1.1.6 fsync fix closed the debrid-specific hole; this generalizes the
guarantee so "completed" never means a corrupt file on ANY backend.

- IntegrityError + bounded retry: on a corrupt/short result the manager
  re-downloads the same source up to 3x (clean start), then surfaces the
  task as damaged ("corrupt download:" prefix) instead of completing it.
- verify (size mismatch / empty), debrid (incomplete / post-write / flush),
  torrent (BytesMissing), usenet (par2 unrepairable / repair-failed) all
  classify integrity failures so they route through the retry/damaged path.
- scanner: a file ffprobe can't read is emitted as a damaged library_item
  (reason "unreadable") instead of being silently dropped from the sync.
- tests: manager retry-then-success + retry-exhausted-then-damaged,
  verifying->resolving transition, damaged sync item.
This commit is contained in:
Deivid Soto 2026-06-17 12:51:47 +02:00
parent 271413e0f9
commit a5f3f0914a
13 changed files with 400 additions and 91 deletions

View file

@ -14,6 +14,13 @@ import (
// be checked is delivered UNVERIFIED, not verified.
var ErrPar2NotInstalled = errors.New("par2 not installed")
// ErrPar2Unrepairable is returned by Par2Verify when parity confirms the data is
// damaged AND par2 reports repair is not possible — the file is definitively
// corrupt (distinct from a transient par2 probe error). The pipeline marks the
// delivery Corrupt so the engine treats it as an integrity failure and
// re-downloads, rather than shipping a broken file with a soft warning.
var ErrPar2Unrepairable = errors.New("par2: verification failed and repair not possible")
// par2Lookup probes whether the par2 binary is on PATH. It's a package var so
// tests can simulate a missing binary without touching the real PATH.
var par2Lookup = func() bool {
@ -42,7 +49,7 @@ func Par2Verify(par2File string) error {
return &Par2RepairableError{Par2File: par2File}
}
if strings.Contains(outStr, "Repair is not possible") {
return fmt.Errorf("par2: verification failed and repair not possible:\n%s", outStr)
return fmt.Errorf("%w:\n%s", ErrPar2Unrepairable, outStr)
}
return fmt.Errorf("par2 verify: %w\n%s", err, outStr)
}

View file

@ -23,6 +23,12 @@ type Result struct {
// the file is unverified rather than silently assuming it's good. Empty means
// either "verified OK" or "no parity shipped" — both are non-degraded.
VerifyNote string
// Corrupt is true when par2 DEFINITIVELY confirmed the data is damaged and it
// could not be repaired (repair failed, or corruption detected with no par2
// binary to fix it). The engine treats this as an integrity failure and
// re-downloads — distinct from VerifyNote's softer "unverified but delivered"
// (e.g. no parity shipped, or a transient probe error).
Corrupt bool
}
// Options configures post-processing behavior.
@ -58,14 +64,28 @@ func Process(dir string, downloadedFiles map[string]string, opts Options) (*Resu
result.Repaired = true
log.Printf("[usenet] par2: repair successful")
case errors.Is(repairErr, ErrPar2NotInstalled):
result.VerifyNote = "par2 corruption detected but `par2` is not installed — cannot repair, delivered POSSIBLY CORRUPT"
// Damage confirmed by parity, but no binary to repair it — the
// delivered file IS corrupt. Mark it so the engine re-downloads.
result.Corrupt = true
result.VerifyNote = "par2 corruption detected but `par2` is not installed — cannot repair, file is CORRUPT"
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
default:
result.VerifyNote = fmt.Sprintf("par2 repair failed — file may be corrupt: %v", repairErr)
// Repair attempted and failed — the data is damaged beyond recovery.
result.Corrupt = true
result.VerifyNote = fmt.Sprintf("par2 repair failed — file is corrupt: %v", repairErr)
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
}
case errors.Is(err, ErrPar2Unrepairable):
// Parity confirmed the data is damaged and unrepairable — definitively
// corrupt (NOT a transient probe error). Engine re-downloads.
result.Corrupt = true
result.VerifyNote = fmt.Sprintf("par2: file is corrupt and cannot be repaired: %v", err)
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
default:
result.VerifyNote = fmt.Sprintf("par2 verification error — file may be corrupt: %v", err)
// A transient par2 probe/exec error (binary crash, I/O hiccup) — can't
// confirm corruption, so deliver UNVERIFIED with a loud note rather than
// nuking a possibly-good file.
result.VerifyNote = fmt.Sprintf("par2 verification error — file unverified: %v", err)
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
}
}