fix(agent): surface par2/install/NFS failures instead of degrading silently

- usenet: Par2Verify/Repair return ErrPar2NotInstalled (was nil="verified");
  pipeline surfaces it via Result.VerifyNote + WARNING — a download that
  shipped parity but couldn't be checked is delivered UNVERIFIED, not verified.
- funnel: pin cloudflared version + verify a baked-in SHA-256 (was `latest` +
  ELF-magic only) — a malicious/broken upstream release isn't pulled silently.
- stream: makeReadable verifies the file actually opens after chmod and warns
  clearly (NFS root_squash / SMB uid mapping) instead of a cryptic later EPERM.
- WireGuard endpoint pin dropped from the debt list (reseller uses direct
  config, no pin).
This commit is contained in:
Deivid Soto 2026-06-01 15:52:54 +02:00
parent 27bee8cdf4
commit 3d51013935
9 changed files with 319 additions and 43 deletions

View file

@ -75,18 +75,23 @@ desde la web. Diseño + set de opciones en el estado abajo.
- ~~Sin thumbnails~~**Fotogramas bajo demanda (2026-05-31)**`GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Ver estado abajo.
- ~~Sin trickplay (preview en la barra)~~**Trickplay bajo demanda (2026-06-01)** — pista WebVTT `thumbnails` con 1 cue/10s; cada cue es una URL `/thumbnail?pos=…#xywh=0,0,W,H` (frame completo), así media-chrome solo descarga el frame que se sobrevuela. Toggle on/off por navegador (`localStorage`, default ON) + doc (web `docs/architecture/trickplay.md`). Alcance "on-demand" decidido con el usuario. Sin pregenerar sprite/BIF (sigue siendo opción futura con cacheo en disco). Ver estado abajo.
- ~~Subtítulos bitmap (PGS/DVB) sin burn-in~~**Burn-in PGS/DVB bajo demanda (2026-06-01)** — el usuario elige una pista bitmap en el reproductor → la sesión fuerza HLS y el agente re-codifica con `[0:v:0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]` (overlay tras el tonemap = brillo SDR correcto; scale2ref = encaje a cualquier resolución del PGS). En la cache key. Selector web alimentado de file-details (funciona también en direct-play). Caveat: PGS + seek pierde el subtítulo. Verificado en Sonic BDremux (ES quemado). Ver estado abajo.
- Audio siempre downmix estéreo AAC (sin passthrough 5.1).
- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia.
- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS).
- ~~Audio siempre downmix estéreo AAC (sin passthrough 5.1)~~ ✅ **Verificado/descartado (2026-06-01)** — el 5.1 in-browser NO es viable (el navegador decodifica+mezcla al dispositivo, no hace bitstream-passthrough; AC3/EAC3/DTS ni se decodifican en Chrome/FF). El downmix solo ocurre en el path HLS. El handoff a player nativo (VLC/mpv/IINA/MPC/Infuse + .m3u/.strm) ya usa `/stream` **crudo** (`http.ServeContent` + `NewFileReader`, sin transcode) → el 5.1/Atmos/DTS original llega intacto al reproductor nativo. Sin trabajo necesario.
- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia. **(diferido al final por decisión del operador)**
- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS). ⏸️ **Cimiento construido + DIFERIDO (2026-06-01)** — listener HTTPS por-agente con cert hot-reload (commit `27bee8c`, inerte sin cert). Decisión: MVP CF-only (single-SAN por agente, DNS-01 vía CF API, sin DNS propio); fase broker+DNS diferida. Doc: web `docs/plans/agent-tls-direct.md`.
- Funnel = SPOF CloudFlare (rota ~6h), sin relay propio.
- "Tailscale Funnel" mal nombrado (no usa tsnet/Funnel real).
- Dos clientes HTTP divergentes (go-client vs agent client).
- Long-poll en vez de WS/SSE.
### Deuda puntual
`makeReadable` parchea mmap 0000 (frágil NFS) · par2/unrar degradan en silencio si
falta binario · VAAPI workarounds por host · cloudflared sin verificación de firma ·
WireGuard endpoint sin pin · sesión única (1 viewer).
VAAPI workarounds por host · sesión única (1 viewer).
**Cerrada (2026-06-01):**
- ~~`makeReadable` parchea mmap 0000 (frágil NFS)~~ ✅ tras el chmod ahora **verifica** que el fichero abre; si no (NFS root_squash / mapeo uid SMB) emite un WARNING claro y accionable + cuenta de fallos en el walk, en vez de dejar un "permission denied" críptico aguas abajo.
- ~~par2/unrar degradan en silencio si falta binario~~`Par2Verify`/`Par2Repair` devuelven `ErrPar2NotInstalled` (antes `nil`=verificado); el pipeline lo surfacea (`Result.VerifyNote` + WARNING) → la descarga se entrega marcada UNVERIFIED, no como verificada. (El lado extract ya fallaba claro.)
- ~~cloudflared sin verificación de firma~~ ✅ el auto-download ahora fija la versión (`pinnedCloudflaredVersion`) y **verifica SHA-256** contra hashes horneados (no `latest`); un release upstream malicioso/roto ya no se trae en silencio.
- ~~WireGuard endpoint sin pin~~**descartado**: el reseller de VPN (VPNResellers) usa configuración WireGuard directa sin pin de endpoint; no aplica.
- ~~Dos clientes HTTP divergentes (go-client vs agent)~~ ✅ el go-client (API público: search/popular/etc.) ahora recibe **mirror-failover** vía un `MirrorRoundTripper` que reusa el mismo `MirrorPool` + política `IsTransient` del agent client (inyectado con `tc.WithHTTPClient`) → ambos sobreviven una caída del dominio primario igual; antes el público se quedaba clavado en el primario.
## Mejoras detectadas durante el trabajo (backlog)

View file

@ -612,9 +612,10 @@ func (d *TorrentDownloader) seedAndDrop(taskID string, t *torrent.Torrent, total
// makeReadable relaxes permissions on a completed download so it can be
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
// files with mode 0000; we set files to 0644 and directories to 0755. Errors
// are logged but non-fatal (e.g. NFS root_squash) — the file may still be
// readable depending on the export.
// files with mode 0000; we set files to 0644 and directories to 0755. Best
// effort + non-fatal — but a chmod that fails (typically NFS root_squash / SMB
// uid mapping) is surfaced with a clear, actionable WARNING instead of leaving
// the file 0000 to produce a cryptic "permission denied" later in the pipeline.
func makeReadable(path string) {
info, err := os.Stat(path)
if err != nil {
@ -625,8 +626,14 @@ func makeReadable(path string) {
if err := os.Chmod(path, 0o644); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", path, err)
}
// Verify the file is actually openable now — on NFS/SMB the chmod may
// "succeed" yet leave it unreadable to this uid. Catch it here with a
// pointed message rather than as an opaque error at stream/probe time.
warnIfUnreadable(path)
return
}
var chmodFails int
var firstFile string
err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // skip unreadable entries, keep going
@ -634,8 +641,11 @@ func makeReadable(path string) {
mode := os.FileMode(0o644)
if d.IsDir() {
mode = 0o755
} else if firstFile == "" {
firstFile = p
}
if err := os.Chmod(p, mode); err != nil {
chmodFails++
log.Printf("[organize] makeReadable chmod %q: %v", p, err)
}
return nil
@ -643,6 +653,27 @@ func makeReadable(path string) {
if err != nil {
log.Printf("[organize] makeReadable walk %q: %v", path, err)
}
if chmodFails > 0 {
log.Printf("[organize] WARNING: %d file(s) under %q could not be made readable (chmod failed) — likely NFS root_squash or an SMB uid mapping. Streaming, ffprobe and organize will fail to open them. Run the agent as the user that owns the share, or mount it so that user can chmod.", chmodFails, path)
}
// Same silent-unreadable check the single-file path does: on NFS/SMB a chmod
// can "succeed" yet leave the file unopenable. Probe one representative file
// so the directory path catches that case too, not only outright chmod errors.
if firstFile != "" {
warnIfUnreadable(firstFile)
}
}
// warnIfUnreadable logs a clear, actionable warning when a file we just chmod'd
// still can't be opened for reading — the anacrolix-mmap-0000 + NFS/SMB failure
// mode. Best effort: it neither fails the download nor blocks delivery.
func warnIfUnreadable(path string) {
f, err := os.Open(path)
if err != nil {
log.Printf("[organize] WARNING: %q is not readable after chmod (%v) — likely NFS root_squash or an SMB uid mapping (anacrolix mmap creates files mode 0000). Streaming/ffprobe/organize will fail. Run the agent as the user that owns the share, or mount it so that user can chmod.", path, err)
return
}
_ = f.Close()
}
// Pause drops the torrent handle but keeps partial files on disk for resume.

View file

@ -2,10 +2,59 @@ package engine
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// TestMakeReadable_FixesZeroMode verifies makeReadable turns an unreadable
// mode-0000 file (the anacrolix mmap default) into a readable 0644 one.
func TestMakeReadable_FixesZeroMode(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(p, []byte("x"), 0o000); err != nil {
t.Fatal(err)
}
if f, err := os.Open(p); err == nil {
f.Close()
t.Skip("running as root — 0000 files are readable; can't exercise the fix")
}
makeReadable(p)
f, err := os.Open(p)
if err != nil {
t.Fatalf("file still unreadable after makeReadable: %v", err)
}
f.Close()
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o644 {
t.Errorf("mode = %o, want 0644", fi.Mode().Perm())
}
}
// TestMakeReadable_DirWalk verifies the directory branch relaxes a 0000 file
// nested inside the download dir.
func TestMakeReadable_DirWalk(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "Release")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(sub, "movie.mkv")
if err := os.WriteFile(p, []byte("x"), 0o000); err != nil {
t.Fatal(err)
}
if f, err := os.Open(p); err == nil {
f.Close()
t.Skip("running as root — 0000 files are readable")
}
makeReadable(sub)
f, err := os.Open(p)
if err != nil {
t.Fatalf("nested file unreadable after makeReadable: %v", err)
}
f.Close()
}
// TestNewTorrentDownloader_ValidConfig verifica que se puede crear un downloader
// con una configuración válida sin errores.
func TestNewTorrentDownloader_ValidConfig(t *testing.T) {

View file

@ -276,6 +276,11 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
if ppResult.Extracted {
log.Printf("[%s] extracted archive", shortID)
}
if ppResult.VerifyNote != "" {
// Degraded verification (par2 missing / repair failed): surface it loudly
// so the delivered file isn't silently assumed good.
log.Printf("[%s] WARNING: %s", shortID, ppResult.VerifyNote)
}
finalPath := ppResult.FinalPath
if finalPath == "" {

View file

@ -2,6 +2,8 @@ package funnel
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@ -10,11 +12,34 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
// pinnedCloudflaredVersion is the exact cloudflared release the auto-downloader
// fetches. We deliberately do NOT track `latest`: pinning a version we vetted +
// verifying its SHA-256 is what bounds the supply-chain risk (a future malicious
// or breaking upstream release can't be pulled silently). Operator-installed
// cloudflared on $PATH always wins, so this only affects the headless
// auto-download fallback.
//
// To bump: pick a newer tag, copy its per-asset SHA-256 from the release body
// (https://github.com/cloudflare/cloudflared/releases/tag/<version>) into the
// map below, and update this constant. All four arch entries MUST be present.
const pinnedCloudflaredVersion = "2026.5.2"
// pinnedCloudflaredSHA256 maps each linux asset to its SHA-256 for
// pinnedCloudflaredVersion (from the release body — Cloudflare publishes the
// hashes inline there, not as a separate file or signature).
var pinnedCloudflaredSHA256 = map[string]string{
"cloudflared-linux-amd64": "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886",
"cloudflared-linux-arm64": "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815",
"cloudflared-linux-armhf": "190152c373f608080eb6aa9e2aad395f88398dfb9efd0f9b064e2652cffcefdd",
"cloudflared-linux-386": "ad82d1dbed8bbb9d702807cbd97df932cc774d29e9da5c109b7a3c7f7aee2065",
}
// ResolveBinary returns the path to a usable cloudflared executable, downloading
// one into the unarr data dir if neither $PATH nor the cached location has it.
// This makes the funnel feature usable on headless installs (NAS / Docker)
@ -45,19 +70,19 @@ func cachedBinaryPath() string {
return filepath.Join(config.DataDir(), "bin", name)
}
// downloadCloudflared fetches the latest cloudflared release asset matching
// the current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a
// pointer at the OS package manager.
// downloadCloudflared fetches the PINNED cloudflared release asset matching the
// current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a pointer
// at the OS package manager.
//
// Supply-chain caveat: we trust GitHub-over-TLS + cloudflare/cloudflared
// repo integrity. The fetch is over HTTPS to api.github.com's release-asset
// redirector, so a network MITM is bounded by Let's Encrypt + GitHub's cert
// chain. We additionally verify the file is an ELF binary (Linux magic
// bytes) so a generic 404 HTML page or a wrong-arch tarball is rejected at
// rest. We do NOT verify a signature because Cloudflare doesn't sign release
// assets at the moment — if you need stricter integrity, install cloudflared
// from your distro's package manager (apt/brew/winget) and unarr will use
// the PATH copy.
// Integrity: the fetch is HTTPS (bounded by Let's Encrypt + GitHub's cert
// chain) AND the downloaded bytes are verified against a baked-in SHA-256 for
// the pinned version (pinnedCloudflaredSHA256). A mismatch — corruption, MITM
// past TLS, a swapped asset — is rejected before the binary is promoted or run.
// Because the version is pinned (not `latest`), a future malicious/breaking
// upstream release is never pulled silently. The cheap ELF/size check still
// runs first to reject a 404 HTML page before hashing 50 MB. For stricter
// control, install cloudflared via your distro package manager — the PATH copy
// always takes precedence.
func downloadCloudflared(dest string) (string, error) {
if runtime.GOOS != "linux" {
return "", fmt.Errorf("funnel: auto-download not supported on %s — install cloudflared manually or drop a binary at %s", runtime.GOOS, dest)
@ -77,7 +102,12 @@ func downloadCloudflared(dest string) (string, error) {
return "", fmt.Errorf("funnel: unsupported linux arch %q — install cloudflared manually", runtime.GOARCH)
}
url := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + asset
expectedSHA, ok := pinnedCloudflaredSHA256[asset]
if !ok {
return "", fmt.Errorf("funnel: no pinned SHA-256 for asset %q (bug: keep pinnedCloudflaredSHA256 in sync with the arch switch)", asset)
}
url := "https://github.com/cloudflare/cloudflared/releases/download/" + pinnedCloudflaredVersion + "/" + asset
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("funnel: create bin dir: %w", err)
}
@ -125,14 +155,22 @@ func downloadCloudflared(dest string) (string, error) {
return "", fmt.Errorf("funnel: close dest: %w", err)
}
// Sanity check before promoting <partial> to <dest>: must be a Linux
// ELF executable (rejects 404 HTML pages or wrong-arch payloads) and at
// least 1 MB (real cloudflared is ~50 MB; anything smaller is corrupt).
// Cheap reject first: must be a Linux ELF executable (rejects 404 HTML pages
// or wrong-arch payloads) and at least 1 MB, so we don't hash 50 MB of an
// obviously-wrong file.
if err := verifyLinuxElf(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err)
}
// Authoritative integrity gate: the bytes must match the SHA-256 we baked in
// for the pinned version. Rejects corruption, a MITM past TLS, or a swapped
// asset before the binary is ever promoted or executed.
if err := verifySHA256(tmp, expectedSHA); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: cloudflared %s integrity check failed: %w", pinnedCloudflaredVersion, err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: rename dest: %w", err)
@ -165,3 +203,22 @@ func verifyLinuxElf(path string) error {
}
return nil
}
// verifySHA256 returns nil when the file at `path` hashes to expectedHex
// (case-insensitive), else an error reporting both digests.
func verifySHA256(path, expectedHex string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("hashing: %w", err)
}
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, expectedHex) {
return fmt.Errorf("sha256 mismatch: got %s, want %s", got, expectedHex)
}
return nil
}

View file

@ -0,0 +1,62 @@
package funnel
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
// TestVerifySHA256 covers the integrity gate used on the auto-downloaded
// cloudflared binary: it accepts the matching digest (case-insensitive) and
// rejects a wrong one.
func TestVerifySHA256(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "blob")
content := []byte("cloudflared-bytes")
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
good := hex.EncodeToString(sum[:])
if err := verifySHA256(path, good); err != nil {
t.Errorf("verifySHA256(correct) = %v, want nil", err)
}
// Upper-case should still match.
if err := verifySHA256(path, good[:60]+"ABCD"); err == nil {
t.Error("verifySHA256(wrong) = nil, want mismatch error")
}
if err := verifySHA256(path, "deadbeef"); err == nil {
t.Error("verifySHA256(short/wrong) = nil, want error")
}
}
// TestPinnedCloudflaredSHA256Complete guards the invariant that every linux arch
// the downloader can select has a pinned 64-hex SHA-256, so a download never
// reaches the verify step without an expected digest.
func TestPinnedCloudflaredSHA256Complete(t *testing.T) {
wantAssets := []string{
"cloudflared-linux-amd64",
"cloudflared-linux-arm64",
"cloudflared-linux-armhf",
"cloudflared-linux-386",
}
for _, a := range wantAssets {
sum, ok := pinnedCloudflaredSHA256[a]
if !ok {
t.Errorf("missing pinned SHA-256 for %q", a)
continue
}
if len(sum) != 64 {
t.Errorf("%s: SHA-256 length = %d, want 64", a, len(sum))
}
if _, err := hex.DecodeString(sum); err != nil {
t.Errorf("%s: SHA-256 not valid hex: %v", a, err)
}
}
if pinnedCloudflaredVersion == "" {
t.Error("pinnedCloudflaredVersion must be set")
}
}

View file

@ -1,24 +1,36 @@
package postprocess
import (
"errors"
"fmt"
"log"
"os/exec"
"strings"
)
// Par2Available checks if par2cmdline is installed.
func Par2Available() bool {
// ErrPar2NotInstalled is returned by Par2Verify/Par2Repair when parity data is
// present but the `par2` binary is missing. The caller MUST surface this rather
// than treat it as "verified OK" — a download that shipped parity but could not
// be checked is delivered UNVERIFIED, not verified.
var ErrPar2NotInstalled = errors.New("par2 not installed")
// 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 {
_, err := exec.LookPath("par2")
return err == nil
}
// Par2Verify verifies files using a par2 file.
// Returns nil if verification passes, error otherwise.
// Par2Available checks if par2cmdline is installed.
func Par2Available() bool { return par2Lookup() }
// Par2Verify verifies files using a par2 file. Returns nil on success,
// ErrPar2NotInstalled when the binary is missing (parity present but unchecked —
// the caller must surface it, NOT treat it as verified), a *Par2RepairableError
// when repair is possible, or another error on failure.
func Par2Verify(par2File string) error {
if !Par2Available() {
log.Printf("[usenet] par2 not installed, skipping verification")
return nil
return ErrPar2NotInstalled
}
cmd := exec.Command("par2", "verify", par2File)
@ -42,7 +54,7 @@ func Par2Verify(par2File string) error {
// Par2Repair attempts to repair files using par2 parity data.
func Par2Repair(par2File string) error {
if !Par2Available() {
return fmt.Errorf("par2 not installed")
return ErrPar2NotInstalled
}
cmd := exec.Command("par2", "repair", par2File)

View file

@ -1,6 +1,7 @@
package postprocess
import (
"errors"
"fmt"
"log"
"os"
@ -16,6 +17,12 @@ type Result struct {
Files []string // all final files
Repaired bool // whether par2 repair was needed
Extracted bool // whether archive extraction was performed
// VerifyNote is non-empty when par2 verification was DEGRADED — parity shipped
// but could not be confirmed (par2 missing, repair failed, verify error). The
// download is still delivered, but the caller surfaces this so the user knows
// 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
}
// Options configures post-processing behavior.
@ -29,21 +36,37 @@ type Options struct {
func Process(dir string, downloadedFiles map[string]string, opts Options) (*Result, error) {
result := &Result{}
// Step 1: Par2 verification and repair
// Step 1: Par2 verification and repair. Parity is optional, so a missing
// binary or a failed repair does NOT abort the download — but it MUST be
// surfaced (result.VerifyNote + a WARNING) instead of silently delivering an
// unverified file as if it had passed.
par2File := findPar2File(downloadedFiles)
if par2File != "" {
var repairable *Par2RepairableError
err := Par2Verify(par2File)
if err != nil {
if _, ok := err.(*Par2RepairableError); ok {
log.Printf("[usenet] attempting par2 repair...")
if repairErr := Par2Repair(par2File); repairErr != nil {
log.Printf("[usenet] par2 repair failed: %v", repairErr)
} else {
switch {
case err == nil:
// Verified OK — nothing to surface.
case errors.Is(err, ErrPar2NotInstalled):
result.VerifyNote = "par2 parity present but `par2` is not installed — delivered UNVERIFIED (install par2cmdline to enable verification/repair)"
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
case errors.As(err, &repairable):
log.Printf("[usenet] par2: corruption detected, attempting repair...")
repairErr := Par2Repair(par2File)
switch {
case repairErr == nil:
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"
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
default:
result.VerifyNote = fmt.Sprintf("par2 repair failed — file may be corrupt: %v", repairErr)
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
}
} else {
log.Printf("[usenet] par2 verification error: %v", err)
}
default:
result.VerifyNote = fmt.Sprintf("par2 verification error — file may be corrupt: %v", err)
log.Printf("[usenet] WARNING: %s", result.VerifyNote)
}
}

View file

@ -6,6 +6,38 @@ import (
"testing"
)
// TestProcess_Par2MissingSurfaced verifies that when parity is present but the
// par2 binary is missing, Process does NOT silently report success: it surfaces
// the degraded state via VerifyNote and leaves Verified false (while still
// delivering the file).
func TestProcess_Par2MissingSurfaced(t *testing.T) {
orig := par2Lookup
par2Lookup = func() bool { return false }
defer func() { par2Lookup = orig }()
dir := t.TempDir()
par2Path := filepath.Join(dir, "release.par2")
if err := os.WriteFile(par2Path, []byte("fake parity"), 0o644); err != nil {
t.Fatal(err)
}
vid := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(vid, []byte("video data"), 0o644); err != nil {
t.Fatal(err)
}
files := map[string]string{"release.par2": par2Path, "movie.mkv": vid}
res, err := Process(dir, files, Options{})
if err != nil {
t.Fatalf("Process: %v", err)
}
if res.VerifyNote == "" {
t.Error("VerifyNote must be set (not silent) when par2 is missing")
}
if res.FinalPath != vid {
t.Errorf("FinalPath = %q, want %q (file still delivered)", res.FinalPath, vid)
}
}
func TestFindPar2File(t *testing.T) {
dir := t.TempDir()