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 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. - ~~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. - ~~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). - ~~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. - 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). - 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. - Funnel = SPOF CloudFlare (rota ~6h), sin relay propio.
- "Tailscale Funnel" mal nombrado (no usa tsnet/Funnel real). - "Tailscale Funnel" mal nombrado (no usa tsnet/Funnel real).
- Dos clientes HTTP divergentes (go-client vs agent client). - Dos clientes HTTP divergentes (go-client vs agent client).
- Long-poll en vez de WS/SSE. - Long-poll en vez de WS/SSE.
### Deuda puntual ### Deuda puntual
`makeReadable` parchea mmap 0000 (frágil NFS) · par2/unrar degradan en silencio si VAAPI workarounds por host · sesión única (1 viewer).
falta binario · VAAPI workarounds por host · cloudflared sin verificación de firma ·
WireGuard endpoint sin pin · 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) ## 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 // makeReadable relaxes permissions on a completed download so it can be
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates // re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
// files with mode 0000; we set files to 0644 and directories to 0755. Errors // files with mode 0000; we set files to 0644 and directories to 0755. Best
// are logged but non-fatal (e.g. NFS root_squash) — the file may still be // effort + non-fatal — but a chmod that fails (typically NFS root_squash / SMB
// readable depending on the export. // 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) { func makeReadable(path string) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@ -625,8 +626,14 @@ func makeReadable(path string) {
if err := os.Chmod(path, 0o644); err != nil { if err := os.Chmod(path, 0o644); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", path, err) 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 return
} }
var chmodFails int
var firstFile string
err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error { err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return nil // skip unreadable entries, keep going return nil // skip unreadable entries, keep going
@ -634,8 +641,11 @@ func makeReadable(path string) {
mode := os.FileMode(0o644) mode := os.FileMode(0o644)
if d.IsDir() { if d.IsDir() {
mode = 0o755 mode = 0o755
} else if firstFile == "" {
firstFile = p
} }
if err := os.Chmod(p, mode); err != nil { if err := os.Chmod(p, mode); err != nil {
chmodFails++
log.Printf("[organize] makeReadable chmod %q: %v", p, err) log.Printf("[organize] makeReadable chmod %q: %v", p, err)
} }
return nil return nil
@ -643,6 +653,27 @@ func makeReadable(path string) {
if err != nil { if err != nil {
log.Printf("[organize] makeReadable walk %q: %v", path, err) 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. // Pause drops the torrent handle but keeps partial files on disk for resume.

View file

@ -2,10 +2,59 @@ package engine
import ( import (
"context" "context"
"os"
"path/filepath"
"testing" "testing"
"time" "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 // TestNewTorrentDownloader_ValidConfig verifica que se puede crear un downloader
// con una configuración válida sin errores. // con una configuración válida sin errores.
func TestNewTorrentDownloader_ValidConfig(t *testing.T) { 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 { if ppResult.Extracted {
log.Printf("[%s] extracted archive", shortID) 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 finalPath := ppResult.FinalPath
if finalPath == "" { if finalPath == "" {

View file

@ -2,6 +2,8 @@ package funnel
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -10,11 +12,34 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"time" "time"
"github.com/torrentclaw/unarr/internal/config" "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 // 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. // 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) // 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) return filepath.Join(config.DataDir(), "bin", name)
} }
// downloadCloudflared fetches the latest cloudflared release asset matching // downloadCloudflared fetches the PINNED cloudflared release asset matching the
// the current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a // current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a pointer
// pointer at the OS package manager. // at the OS package manager.
// //
// Supply-chain caveat: we trust GitHub-over-TLS + cloudflare/cloudflared // Integrity: the fetch is HTTPS (bounded by Let's Encrypt + GitHub's cert
// repo integrity. The fetch is over HTTPS to api.github.com's release-asset // chain) AND the downloaded bytes are verified against a baked-in SHA-256 for
// redirector, so a network MITM is bounded by Let's Encrypt + GitHub's cert // the pinned version (pinnedCloudflaredSHA256). A mismatch — corruption, MITM
// chain. We additionally verify the file is an ELF binary (Linux magic // past TLS, a swapped asset — is rejected before the binary is promoted or run.
// bytes) so a generic 404 HTML page or a wrong-arch tarball is rejected at // Because the version is pinned (not `latest`), a future malicious/breaking
// rest. We do NOT verify a signature because Cloudflare doesn't sign release // upstream release is never pulled silently. The cheap ELF/size check still
// assets at the moment — if you need stricter integrity, install cloudflared // runs first to reject a 404 HTML page before hashing 50 MB. For stricter
// from your distro's package manager (apt/brew/winget) and unarr will use // control, install cloudflared via your distro package manager — the PATH copy
// the PATH copy. // always takes precedence.
func downloadCloudflared(dest string) (string, error) { func downloadCloudflared(dest string) (string, error) {
if runtime.GOOS != "linux" { 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) 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) 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 { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("funnel: create bin dir: %w", err) 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) return "", fmt.Errorf("funnel: close dest: %w", err)
} }
// Sanity check before promoting <partial> to <dest>: must be a Linux // Cheap reject first: must be a Linux ELF executable (rejects 404 HTML pages
// ELF executable (rejects 404 HTML pages or wrong-arch payloads) and at // or wrong-arch payloads) and at least 1 MB, so we don't hash 50 MB of an
// least 1 MB (real cloudflared is ~50 MB; anything smaller is corrupt). // obviously-wrong file.
if err := verifyLinuxElf(tmp); err != nil { if err := verifyLinuxElf(tmp); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err) 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 { if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return "", fmt.Errorf("funnel: rename dest: %w", err) return "", fmt.Errorf("funnel: rename dest: %w", err)
@ -165,3 +203,22 @@ func verifyLinuxElf(path string) error {
} }
return nil 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 package postprocess
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os/exec" "os/exec"
"strings" "strings"
) )
// Par2Available checks if par2cmdline is installed. // ErrPar2NotInstalled is returned by Par2Verify/Par2Repair when parity data is
func Par2Available() bool { // 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") _, err := exec.LookPath("par2")
return err == nil return err == nil
} }
// Par2Verify verifies files using a par2 file. // Par2Available checks if par2cmdline is installed.
// Returns nil if verification passes, error otherwise. 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 { func Par2Verify(par2File string) error {
if !Par2Available() { if !Par2Available() {
log.Printf("[usenet] par2 not installed, skipping verification") return ErrPar2NotInstalled
return nil
} }
cmd := exec.Command("par2", "verify", par2File) cmd := exec.Command("par2", "verify", par2File)
@ -42,7 +54,7 @@ func Par2Verify(par2File string) error {
// Par2Repair attempts to repair files using par2 parity data. // Par2Repair attempts to repair files using par2 parity data.
func Par2Repair(par2File string) error { func Par2Repair(par2File string) error {
if !Par2Available() { if !Par2Available() {
return fmt.Errorf("par2 not installed") return ErrPar2NotInstalled
} }
cmd := exec.Command("par2", "repair", par2File) cmd := exec.Command("par2", "repair", par2File)

View file

@ -1,6 +1,7 @@
package postprocess package postprocess
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -16,6 +17,12 @@ type Result struct {
Files []string // all final files Files []string // all final files
Repaired bool // whether par2 repair was needed Repaired bool // whether par2 repair was needed
Extracted bool // whether archive extraction was performed 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. // Options configures post-processing behavior.
@ -29,21 +36,37 @@ type Options struct {
func Process(dir string, downloadedFiles map[string]string, opts Options) (*Result, error) { func Process(dir string, downloadedFiles map[string]string, opts Options) (*Result, error) {
result := &Result{} 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) par2File := findPar2File(downloadedFiles)
if par2File != "" { if par2File != "" {
var repairable *Par2RepairableError
err := Par2Verify(par2File) err := Par2Verify(par2File)
if err != nil { switch {
if _, ok := err.(*Par2RepairableError); ok { case err == nil:
log.Printf("[usenet] attempting par2 repair...") // Verified OK — nothing to surface.
if repairErr := Par2Repair(par2File); repairErr != nil { case errors.Is(err, ErrPar2NotInstalled):
log.Printf("[usenet] par2 repair failed: %v", repairErr) result.VerifyNote = "par2 parity present but `par2` is not installed — delivered UNVERIFIED (install par2cmdline to enable verification/repair)"
} else { 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 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 { default:
log.Printf("[usenet] par2 verification error: %v", err) 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" "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) { func TestFindPar2File(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()