package tools import ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "vidarchive/internal/config" ) // fakeRelease serves the two GitHub paths the installer uses: the asset itself // and the checksum file next to it. func fakeRelease(t *testing.T, assets map[string][]byte) string { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { name := filepath.Base(r.URL.Path) body, ok := assets[name] if !ok { http.NotFound(w, r) return } _, _ = w.Write(body) })) t.Cleanup(srv.Close) return srv.URL } func sumsFile(asset string, body []byte) []byte { sum := sha256.Sum256(body) return []byte(fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)) } func testManager(t *testing.T, assets map[string][]byte) *Manager { t.Helper() dir := t.TempDir() cfg := &config.Config{ BinDir: dir, YTDLPPath: filepath.Join(dir, "yt-dlp"), YTDLPManaged: true, DenoPath: filepath.Join(dir, "deno"), DenoManaged: true, } return &Manager{cfg: cfg, baseURL: fakeRelease(t, assets)} } // requireArch returns the candidate list for this architecture, skipping when // the platform has no mapped release build. func requireArch(t *testing.T, assets map[string][]string) []string { t.Helper() candidates, ok := assets[runtime.GOARCH] if !ok { t.Skipf("no release asset mapped for %s", runtime.GOARCH) } return candidates } func requireYTDLPAsset(t *testing.T) string { t.Helper() return requireArch(t, ytdlpAssets)[0] } // script is a tiny executable stand-in for a real release binary. func script(body string) []byte { return []byte("#!/bin/sh\n" + body + "\n") } func TestEnsureInstallsYTDLP(t *testing.T) { asset := requireYTDLPAsset(t) body := script("echo 2025.09.05") m := testManager(t, map[string][]byte{ asset: body, "SHA2-256SUMS": sumsFile(asset, body), }) if err := m.Ensure(context.Background(), false); err != nil { t.Fatalf("Ensure: %v", err) } got, err := os.ReadFile(m.cfg.YTDLPPath) if err != nil { t.Fatalf("read installed binary: %v", err) } if !bytes.Equal(got, body) { t.Errorf("installed content = %q, want %q", got, body) } info, err := os.Stat(m.cfg.YTDLPPath) if err != nil { t.Fatal(err) } if info.Mode().Perm()&0o111 == 0 { t.Errorf("installed binary is not executable, mode %v", info.Mode()) } } func TestEnsureRejectsBadChecksum(t *testing.T) { asset := requireYTDLPAsset(t) m := testManager(t, map[string][]byte{ asset: []byte("tampered"), "SHA2-256SUMS": sumsFile(asset, []byte("original")), }) err := m.Ensure(context.Background(), false) if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { t.Fatalf("Ensure err = %v, want a checksum mismatch", err) } if Installed(m.cfg.YTDLPPath) { t.Error("a binary that failed verification was installed anyway") } // A failed download must not leave scratch files in the managed directory. entries, err := os.ReadDir(m.cfg.BinDir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("bin dir not clean after failure: %v", entries) } } func TestEnsureInstallsDenoFromZip(t *testing.T) { ytdlpAsset := requireYTDLPAsset(t) denoAsset := requireArch(t, denoAssets)[0] body := script("echo deno 2.1.4") var archive bytes.Buffer zw := zip.NewWriter(&archive) f, err := zw.Create("deno") if err != nil { t.Fatal(err) } if _, err := f.Write(body); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } ytdlpBody := script("echo 2025.09.05") m := testManager(t, map[string][]byte{ ytdlpAsset: ytdlpBody, "SHA2-256SUMS": sumsFile(ytdlpAsset, ytdlpBody), denoAsset: archive.Bytes(), denoAsset + ".sha256sum": sumsFile(denoAsset, archive.Bytes()), }) if err := m.Ensure(context.Background(), true); err != nil { t.Fatalf("Ensure: %v", err) } got, err := os.ReadFile(m.cfg.DenoPath) if err != nil { t.Fatalf("read deno: %v", err) } if !bytes.Equal(got, body) { t.Errorf("extracted deno = %q, want %q", got, body) } } // An external tool is the operator's to manage, so nothing is downloaded and // nothing is overwritten. func TestEnsureSkipsExternalTools(t *testing.T) { dir := t.TempDir() m := &Manager{ cfg: &config.Config{ BinDir: dir, YTDLPPath: "/usr/bin/yt-dlp", YTDLPManaged: false, DenoPath: "/usr/bin/deno", DenoManaged: false, }, // Any request would fail: the installer must not make one. baseURL: "http://127.0.0.1:1", } if err := m.Ensure(context.Background(), true); err != nil { t.Fatalf("Ensure: %v", err) } summary, err := m.Update(context.Background()) if err != nil { t.Fatalf("Update: %v", err) } if !strings.Contains(summary, "Nothing to update") { t.Errorf("Update summary = %q, want a note that nothing is under control", summary) } } func TestParseChecksum(t *testing.T) { multi := []byte("aa yt-dlp\nbb yt-dlp_linux\n") if got, ok := parseChecksum(multi, "yt-dlp_linux"); !ok || got != "bb" { t.Errorf("multi-line = %q,%v, want bb,true", got, ok) } if _, ok := parseChecksum(multi, "yt-dlp_macos"); ok { t.Error("missing asset reported as found") } // A hex with no filename is not sha256sum format and must not be trusted // for an arbitrary asset. bare := []byte(strings.Repeat("a", 64) + "\n") if _, ok := parseChecksum(bare, "deno.zip"); ok { t.Error("a checksum line with no filename was accepted") } } func TestLastLineStripsColor(t *testing.T) { out := "Looking up stable version\nLocal deno version \x1b[32m2.9.7\x1b[39m is the most recent release\n" if got, want := lastLine(out), "Local deno version 2.9.7 is the most recent release"; got != want { t.Errorf("lastLine = %q, want %q", got, want) } } // A build for the wrong C library or architecture downloads fine and then fails // to exec. The installer must fall through to the next candidate instead of // leaving a binary that cannot run. func TestEnsureFallsBackWhenCandidateDoesNotRun(t *testing.T) { candidates := requireArch(t, ytdlpAssets) if len(candidates) < 2 { t.Skipf("only one yt-dlp candidate for %s", runtime.GOARCH) } broken, working := candidates[0], candidates[1] brokenBody := script("exit 1") workingBody := script("echo 2025.09.05") sums := append(sumsFile(broken, brokenBody), sumsFile(working, workingBody)...) m := testManager(t, map[string][]byte{ broken: brokenBody, working: workingBody, "SHA2-256SUMS": sums, }) if err := m.Ensure(context.Background(), false); err != nil { t.Fatalf("Ensure: %v", err) } got, err := os.ReadFile(m.cfg.YTDLPPath) if err != nil { t.Fatalf("read installed binary: %v", err) } if !bytes.Equal(got, workingBody) { t.Errorf("installed the candidate that does not run: %q", got) } } // Every candidate failing must leave nothing behind, not a half-installed // binary that fails on the first download instead. func TestEnsureInstallsNothingWhenNoCandidateRuns(t *testing.T) { candidates := requireArch(t, ytdlpAssets) assets := map[string][]byte{} var sums []byte for _, asset := range candidates { body := script("exit 1") assets[asset] = body sums = append(sums, sumsFile(asset, body)...) } assets["SHA2-256SUMS"] = sums m := testManager(t, assets) err := m.Ensure(context.Background(), false) if err == nil || !strings.Contains(err.Error(), "does not run on this system") { t.Fatalf("Ensure err = %v, want a trial-run failure", err) } if Installed(m.cfg.YTDLPPath) { t.Error("a binary that never ran was installed") } entries, err := os.ReadDir(m.cfg.BinDir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("bin dir not clean after failure: %v", entries) } } // VIDARCHIVE_UPDATE_EXTERNAL_TOOLS is the escape hatch for a platform with no // mapped release build: the operator installs the binary once, VidArchive runs // its updater from then on. func TestUpdateRunsUpdaterForOptedInExternalTool(t *testing.T) { dir := t.TempDir() external := filepath.Join(dir, "my-yt-dlp") if err := os.WriteFile(external, script(`echo "yt-dlp is up to date (stable@2026.01.01)"`), 0o755); err != nil { t.Fatal(err) } m := &Manager{ cfg: &config.Config{ BinDir: dir, YTDLPPath: external, YTDLPManaged: false, DenoPath: filepath.Join(dir, "deno"), DenoManaged: false, UpdateExternalTools: true, }, // Any release request would fail: an external tool is never downloaded. baseURL: "http://127.0.0.1:1", } summary, err := m.Update(context.Background()) if err != nil { t.Fatalf("Update: %v", err) } if !strings.Contains(summary, "up to date") { t.Errorf("Update summary = %q, want the updater's own output", summary) } } // The opt-in grants update rights, not install rights. A missing external // binary must be reported, never downloaded over the operator's chosen path. func TestUpdateDoesNotInstallOverExternalPath(t *testing.T) { dir := t.TempDir() external := filepath.Join(dir, "my-yt-dlp") m := &Manager{ cfg: &config.Config{ BinDir: dir, YTDLPPath: external, YTDLPManaged: false, DenoPath: filepath.Join(dir, "deno"), DenoManaged: false, UpdateExternalTools: true, }, baseURL: "http://127.0.0.1:1", } _, err := m.Update(context.Background()) if err == nil || !strings.Contains(err.Error(), "is not installed at") { t.Fatalf("Update err = %v, want a not-installed report", err) } if Installed(external) { t.Error("an external path was populated by the updater") } } // The checksum is the only integrity control on a binary that gets executed // and then left on PATH, so a missing sums file must stop the install. func TestEnsureFailsClosedWithoutDenoChecksum(t *testing.T) { ytdlpAsset := requireYTDLPAsset(t) denoAsset := requireArch(t, denoAssets)[0] ytdlpBody := script("echo 2025.09.05") m := testManager(t, map[string][]byte{ ytdlpAsset: ytdlpBody, "SHA2-256SUMS": sumsFile(ytdlpAsset, ytdlpBody), // The asset is served, its .sha256sum is not. denoAsset: []byte("PK\x03\x04 not a real archive"), }) err := m.Ensure(context.Background(), true) if err == nil || !strings.Contains(err.Error(), "checksum file") { t.Fatalf("Ensure err = %v, want a missing-checksum failure", err) } if Installed(m.cfg.DenoPath) { t.Error("deno was installed without a verified checksum") } } // A file that cannot be executed is not an install. Treating it as one would // block the repair forever and fail every download instead. func TestEnsureReplacesUnexecutableBinary(t *testing.T) { asset := requireYTDLPAsset(t) body := script("echo 2025.09.05") m := testManager(t, map[string][]byte{ asset: body, "SHA2-256SUMS": sumsFile(asset, body), }) if err := os.WriteFile(m.cfg.YTDLPPath, []byte("truncated"), 0o644); err != nil { t.Fatal(err) } if Installed(m.cfg.YTDLPPath) { t.Fatal("a 0644 file counted as installed") } if err := m.Ensure(context.Background(), false); err != nil { t.Fatalf("Ensure: %v", err) } got, err := os.ReadFile(m.cfg.YTDLPPath) if err != nil { t.Fatal(err) } if !bytes.Equal(got, body) { t.Errorf("unexecutable file was not replaced: %q", got) } } // A second caller must be told, not queued: queueing spends its context waiting // and then reports a failure for an update that actually worked. func TestUpdateDoesNotQueueBehindARunningOne(t *testing.T) { m := testManager(t, nil) m.mu.Lock() defer m.mu.Unlock() summary, err := m.Update(context.Background()) if err != nil { t.Fatalf("Update: %v", err) } if !strings.Contains(summary, "already running") { t.Errorf("Update summary = %q, want a note that one is already running", summary) } }