package selfupdate import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/ajhahnde/eeco/internal/config" ) // fixtureRelease returns a serving handler that publishes the four // release artefacts (archive, SHA256SUMS, .sig, .pem) for a single tag. // The archive contains a single eeco binary with `payload` as its // contents and is built in the same layout as scripts/build.sh. func fixtureRelease(t *testing.T, tag, goos, goarch string, payload []byte) (*httptest.Server, map[string][]byte) { t.Helper() archiveName := archiveBasename(tag, goos, goarch) binName := "eeco" if goos == "windows" { binName = "eeco.exe" } dirInArchive := goos + "_" + goarch var archive []byte if goos == "windows" { var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.CreateHeader(&zip.FileHeader{Name: dirInArchive + "/" + binName, Method: zip.Deflate}) if err != nil { t.Fatalf("zip header: %v", err) } if _, err := w.Write(payload); err != nil { t.Fatalf("zip write: %v", err) } if err := zw.Close(); err != nil { t.Fatalf("zip close: %v", err) } archive = buf.Bytes() } else { var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) hdr := &tar.Header{Name: dirInArchive + "/" + binName, Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg} if err := tw.WriteHeader(hdr); err != nil { t.Fatalf("tar header: %v", err) } if _, err := tw.Write(payload); err != nil { t.Fatalf("tar write: %v", err) } if err := tw.Close(); err != nil { t.Fatalf("tar close: %v", err) } if err := gz.Close(); err != nil { t.Fatalf("gz close: %v", err) } archive = buf.Bytes() } sum := sha256.Sum256(archive) sumsLine := hex.EncodeToString(sum[:]) + " " + archiveName + "\n" assets := map[string][]byte{ archiveName: archive, "SHA256SUMS": []byte(sumsLine), "SHA256SUMS.sig": []byte("fake-sig\n"), "SHA256SUMS.pem": []byte("fake-cert\n"), } prefix := "/" + tag + "/" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, prefix) { http.NotFound(w, r) return } name := strings.TrimPrefix(r.URL.Path, prefix) body, ok := assets[name] if !ok { http.NotFound(w, r) return } _, _ = w.Write(body) })) t.Cleanup(srv.Close) return srv, assets } // newTestCfg builds a config.Config rooted at a fresh temp directory // with a .eeco workspace, matching what `eeco init` produces. func newTestCfg(t *testing.T) *config.Config { t.Helper() root := t.TempDir() ws := filepath.Join(root, ".eeco") if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil { t.Fatalf("mkdir workspace: %v", err) } return &config.Config{ RepoRoot: root, WorkspaceName: ".eeco", Workspace: ws, } } func TestApply_HappyPath(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" payload := []byte("FAKE-EECO-BINARY-PAYLOAD") srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, payload) binDir := t.TempDir() binName := "eeco" if runtime.GOOS == "windows" { binName = "eeco.exe" } running := filepath.Join(binDir, binName) if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatalf("write running: %v", err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { return "ok", nil }, Now: func() time.Time { return time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) }, }) if code != 0 { t.Fatalf("Apply -> %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } got, err := os.ReadFile(running) if err != nil { t.Fatalf("read running after swap: %v", err) } if !bytes.Equal(got, payload) { t.Fatalf("running binary not swapped: got %q, want %q", got, payload) } if !strings.Contains(stdout.String(), "eeco upgraded: v1.4.1 -> "+tag) { t.Errorf("missing upgrade confirmation:\n%s", stdout.String()) } led, err := LoadLedger(cfg) if err != nil { t.Fatalf("LoadLedger: %v", err) } if !led.Installed || led.ToVersion != tag || led.FromVersion != "v1.4.1" { t.Errorf("ledger: %+v", led) } bak := filepath.Join(cfg.Workspace, "state", "update-"+tag, backupName(runtime.GOOS)) bakBytes, err := os.ReadFile(bak) if err != nil { t.Fatalf("read backup: %v", err) } if string(bakBytes) != "OLD" { t.Errorf("backup contents: %q, want OLD", bakBytes) } } func TestApply_CosignMissing(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) binDir := t.TempDir() running := filepath.Join(binDir, "eeco") _ = os.WriteFile(running, []byte("OLD"), 0o755) var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { if name == "cosign" { return "", &exec.Error{Name: "cosign", Err: exec.ErrNotFound} } return "ok", nil }, }) if code != 2 { t.Fatalf("cosign-missing -> %d, want 2\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), "cosign is not on PATH") { t.Errorf("missing cosign hint: %s", stderr.String()) } } func TestApply_GhMissing(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) binDir := t.TempDir() running := filepath.Join(binDir, "eeco") _ = os.WriteFile(running, []byte("OLD"), 0o755) var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { if name == "gh" { return "", &exec.Error{Name: "gh", Err: exec.ErrNotFound} } return "ok", nil }, }) if code != 2 { t.Fatalf("gh-missing -> %d, want 2\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), "gh is not on PATH") { t.Errorf("missing gh hint: %s", stderr.String()) } } func TestApply_CosignFails(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) binDir := t.TempDir() running := filepath.Join(binDir, "eeco") _ = os.WriteFile(running, []byte("OLD"), 0o755) var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { if name == "cosign" { return "signature mismatch", errors.New("exit 1") } return "ok", nil }, }) if code != 1 { t.Fatalf("cosign-fail -> %d, want 1\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), "cosign verify-blob failed") { t.Errorf("missing cosign failure hint: %s", stderr.String()) } got, _ := os.ReadFile(running) if string(got) != "OLD" { t.Errorf("running binary unexpectedly swapped after cosign failure: %q", got) } } func TestApply_PackageManagerRefusal(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" // Use a path that looks brew-managed regardless of the host OS. binDir := t.TempDir() brewish := filepath.Join(binDir, "Cellar", "eeco", "1.4.1", "bin", "eeco") if err := os.MkdirAll(filepath.Dir(brewish), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(brewish, []byte("OLD"), 0o755); err != nil { t.Fatalf("write: %v", err) } // Force the detected path through pkgmgr by lying about the location. var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: "http://127.0.0.1:0", Executable: func() (string, error) { return "/opt/homebrew/bin/eeco", nil }, }) if code != 2 { t.Fatalf("brew-refusal -> %d, want 2\nstdout:\n%s", code, stdout.String()) } if !strings.Contains(stdout.String(), "brew upgrade eeco") { t.Errorf("missing brew hint:\n%s", stdout.String()) } } func TestApply_ChecksumMismatch(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" // Serve a SHA256SUMS that records a wrong hash for the archive. payload := []byte("REAL-PAYLOAD") srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, payload) archiveName := archiveBasename(tag, runtime.GOOS, runtime.GOARCH) assets["SHA256SUMS"] = []byte(strings.Repeat("0", 64) + " " + archiveName + "\n") binDir := t.TempDir() running := filepath.Join(binDir, "eeco") _ = os.WriteFile(running, []byte("OLD"), 0o755) var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { return "ok", nil }, }) if code != 1 { t.Fatalf("checksum-mismatch -> %d, want 1\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), "archive sha256 mismatch") { t.Errorf("missing mismatch hint:\n%s", stderr.String()) } got, _ := os.ReadFile(running) if string(got) != "OLD" { t.Errorf("running binary unexpectedly swapped after checksum mismatch: %q", got) } } func TestDetectPackageManager(t *testing.T) { cases := []struct { in string kind string }{ {"/opt/homebrew/bin/eeco", "Homebrew"}, {"/usr/local/Cellar/eeco/1.4.1/bin/eeco", "Homebrew"}, {"/home/linuxbrew/.linuxbrew/bin/eeco", "Homebrew"}, {"/home/user/.linuxbrew/bin/eeco", "Homebrew"}, {`C:\Users\foo\scoop\apps\eeco\current\eeco.exe`, "Scoop"}, {"/home/foo/scoop/apps/eeco/current/eeco", "Scoop"}, {"/usr/local/bin/eeco", ""}, {"/Users/foo/bin/eeco", ""}, } for _, c := range cases { got, _ := detectPackageManager(c.in) if got != c.kind { t.Errorf("detectPackageManager(%q) = %q, want %q", c.in, got, c.kind) } } } func TestArchiveBasename(t *testing.T) { got := archiveBasename("v1.5.0", "darwin", "arm64") want := "eeco_v1.5.0_darwin_arm64.tar.gz" if got != want { t.Errorf("darwin: got %q want %q", got, want) } got = archiveBasename("v1.5.0", "windows", "amd64") want = "eeco_v1.5.0_windows_amd64.zip" if got != want { t.Errorf("windows: got %q want %q", got, want) } } func TestChecksumFor(t *testing.T) { dir := t.TempDir() sums := filepath.Join(dir, "SHA256SUMS") data := "" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eeco_v1.5.0_darwin_amd64.tar.gz\n" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb eeco_v1.5.0_darwin_arm64.tar.gz\n" if err := os.WriteFile(sums, []byte(data), 0o644); err != nil { t.Fatalf("write: %v", err) } got, err := checksumFor(sums, "eeco_v1.5.0_darwin_arm64.tar.gz") if err != nil { t.Fatalf("checksumFor: %v", err) } if got != strings.Repeat("b", 64) { t.Errorf("got %q", got) } if _, err := checksumFor(sums, "missing.tar.gz"); err == nil { t.Error("expected error for missing entry") } // A short (non-64-char) hash for a matching entry is an explicit error. badHash := filepath.Join(dir, "SHA256SUMS.bad") if err := os.WriteFile(badHash, []byte("0123456789 eeco_v1.5.0_darwin_arm64.tar.gz\n"), 0o644); err != nil { t.Fatal(err) } if _, err := checksumFor(badHash, "eeco_v1.5.0_darwin_arm64.tar.gz"); err == nil || !strings.Contains(err.Error(), "SHA256SUMS: bad hash") { t.Errorf("bad-hash err = %v, want 'SHA256SUMS: bad hash'", err) } // A read error (the sums path is a directory) propagates. if _, err := checksumFor(dir, "anything.tar.gz"); err == nil { t.Error("expected error reading a directory as SHA256SUMS") } } func TestSwap_AtomicRename(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "binary") if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { t.Fatalf("write target: %v", err) } newPath := filepath.Join(dir, "binary.new") if err := os.WriteFile(newPath, []byte("NEW"), 0o755); err != nil { t.Fatalf("write new: %v", err) } if err := swap(newPath, target); err != nil { t.Fatalf("swap: %v", err) } got, err := os.ReadFile(target) if err != nil { t.Fatalf("read target: %v", err) } if string(got) != "NEW" { t.Errorf("target = %q, want NEW", got) } } func TestExtract_TarGz(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("tar.gz extraction tested on unix matrix") } dir := t.TempDir() archive := filepath.Join(dir, "eeco.tar.gz") payload := []byte("BINARY") if err := os.WriteFile(archive, buildTarGz(t, "linux_amd64/eeco", payload), 0o644); err != nil { t.Fatalf("write archive: %v", err) } got, err := extract(archive, dir, "linux") if err != nil { t.Fatalf("extract: %v", err) } if filepath.Base(got) != "eeco" { t.Errorf("extracted basename = %q", filepath.Base(got)) } b, _ := os.ReadFile(got) if !bytes.Equal(b, payload) { t.Errorf("payload mismatch: %q", b) } } func buildTarGz(t *testing.T, name string, payload []byte) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg} if err := tw.WriteHeader(hdr); err != nil { t.Fatal(err) } if _, err := tw.Write(payload); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestExtract_Zip(t *testing.T) { dir := t.TempDir() archive := filepath.Join(dir, "eeco.zip") payload := []byte("BINARY") if err := os.WriteFile(archive, buildZip(t, "windows_amd64/eeco.exe", payload), 0o644); err != nil { t.Fatalf("write archive: %v", err) } got, err := extract(archive, dir, "windows") if err != nil { t.Fatalf("extract: %v", err) } if filepath.Base(got) != "eeco.exe" { t.Errorf("extracted basename = %q", filepath.Base(got)) } b, _ := os.ReadFile(got) if !bytes.Equal(b, payload) { t.Errorf("payload mismatch: %q", b) } } func buildZip(t *testing.T, name string, payload []byte) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate}) if err != nil { t.Fatal(err) } if _, err := w.Write(payload); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestLedger_RoundTrip(t *testing.T) { cfg := newTestCfg(t) want := Ledger{ Installed: true, FromVersion: "v1.4.1", ToVersion: "v1.5.0", RunningPath: "/usr/local/bin/eeco", Backup: "/tmp/eeco.bak", SHA256: strings.Repeat("a", 64), At: "2026-05-21T12:00:00Z", } if err := writeLedger(cfg, want); err != nil { t.Fatalf("writeLedger: %v", err) } got, err := LoadLedger(cfg) if err != nil { t.Fatalf("LoadLedger: %v", err) } if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", want) { t.Errorf("round-trip mismatch:\n got %+v\n want %+v", got, want) } } // newTestCfgStateFile is newTestCfg's sibling: it writes /state as a // regular FILE rather than a directory, so any MkdirAll under state/ // (staging dir, ledger dir) fails with ENOTDIR — the root-immune // file-where-a-dir-is-expected trick (no chmod, so root CI can't bypass). func newTestCfgStateFile(t *testing.T) *config.Config { t.Helper() root := t.TempDir() ws := filepath.Join(root, ".eeco") if err := os.MkdirAll(ws, 0o755); err != nil { t.Fatalf("mkdir workspace: %v", err) } if err := os.WriteFile(filepath.Join(ws, "state"), []byte("x"), 0o644); err != nil { t.Fatalf("write state file: %v", err) } return &config.Config{ RepoRoot: root, WorkspaceName: ".eeco", Workspace: ws, } } // fileInTheWay drops a regular file at path so a later MkdirAll/OpenFile // that expects a directory there hits ENOTDIR (no chmod). func fileInTheWay(t *testing.T, path string) { t.Helper() if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { t.Fatalf("fileInTheWay %s: %v", path, err) } } // --- A. DI seam + pure functions --- func TestResolveRunning_ReturnsAbsResolvedPath(t *testing.T) { got, err := ResolveRunning() if err != nil { t.Fatalf("ResolveRunning: %v", err) } if !filepath.IsAbs(got) { t.Errorf("ResolveRunning returned non-absolute path: %q", got) } } func TestDefaultRunCmd_Runs(t *testing.T) { name, args := "true", []string(nil) if runtime.GOOS == "windows" { name, args = "cmd", []string{"/c", "exit", "0"} } if out, err := defaultRunCmd(name, args...); err != nil { t.Fatalf("defaultRunCmd(%q) err = %v (out=%q)", name, err, out) } } func TestBackupName_ByGOOS(t *testing.T) { cases := []struct{ goos, want string }{ {"windows", "eeco.exe.bak"}, {"linux", "eeco.bak"}, {"darwin", "eeco.bak"}, } for _, c := range cases { if got := backupName(c.goos); got != c.want { t.Errorf("backupName(%q) = %q, want %q", c.goos, got, c.want) } } } func TestWithDefaults_FillsNilFields(t *testing.T) { o := withDefaults(Options{}) if o.BaseURL != DefaultBaseURL { t.Errorf("BaseURL = %q, want %q", o.BaseURL, DefaultBaseURL) } if o.HTTPClient == nil { t.Error("HTTPClient not filled") } if o.Executable == nil { t.Error("Executable not filled") } if o.RunCmd == nil { t.Error("RunCmd not filled") } if o.Now == nil { t.Error("Now not filled") } if o.GOOS != runtime.GOOS { t.Errorf("GOOS = %q, want %q", o.GOOS, runtime.GOOS) } if o.GOARCH != runtime.GOARCH { t.Errorf("GOARCH = %q, want %q", o.GOARCH, runtime.GOARCH) } } // --- B. extract.go --- func buildTarGzNoBinary(t *testing.T) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { t.Fatal(err) } readme := []byte("readme") if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/README.md", Mode: 0o644, Size: int64(len(readme)), Typeflag: tar.TypeReg}); err != nil { t.Fatal(err) } if _, err := tw.Write(readme); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func buildZipNoBinary(t *testing.T) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.CreateHeader(&zip.FileHeader{Name: "windows_amd64/README.md", Method: zip.Deflate}) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte("readme")); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestExtract_Errors(t *testing.T) { dir := t.TempDir() t.Run("unknown format", func(t *testing.T) { p := filepath.Join(dir, "eeco.rar") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } if _, err := extract(p, dir, "linux"); err == nil || !strings.Contains(err.Error(), "unknown archive format") { t.Fatalf("err = %v, want 'unknown archive format'", err) } }) t.Run("missing tar.gz", func(t *testing.T) { if _, err := extract(filepath.Join(dir, "nope.tar.gz"), dir, "linux"); err == nil { t.Fatal("expected error for missing tar.gz") } }) t.Run("missing zip", func(t *testing.T) { if _, err := extract(filepath.Join(dir, "nope.zip"), dir, "windows"); err == nil { t.Fatal("expected error for missing zip") } }) t.Run("bad gzip", func(t *testing.T) { p := filepath.Join(dir, "bad.tar.gz") if err := os.WriteFile(p, []byte("not gzip data"), 0o644); err != nil { t.Fatal(err) } if _, err := extract(p, dir, "linux"); err == nil { t.Fatal("expected error for bad gzip") } }) t.Run("truncated tar", func(t *testing.T) { // Valid gzip stream wrapping bytes that are not a valid tar header, // so tr.Next() returns a non-EOF error. var buf bytes.Buffer gz := gzip.NewWriter(&buf) if _, err := gz.Write([]byte(strings.Repeat("x", 50))); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } p := filepath.Join(dir, "trunc.tar.gz") if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil { t.Fatal(err) } if _, err := extract(p, dir, "linux"); err == nil { t.Fatal("expected error for truncated tar") } }) t.Run("binary not found tar.gz", func(t *testing.T) { p := filepath.Join(dir, "nobin.tar.gz") if err := os.WriteFile(p, buildTarGzNoBinary(t), 0o644); err != nil { t.Fatal(err) } if _, err := extract(p, dir, "linux"); err == nil || !strings.Contains(err.Error(), "binary eeco not found in archive") { t.Fatalf("err = %v, want 'binary eeco not found in archive'", err) } }) t.Run("binary not found zip", func(t *testing.T) { p := filepath.Join(dir, "nobin.zip") if err := os.WriteFile(p, buildZipNoBinary(t), 0o644); err != nil { t.Fatal(err) } if _, err := extract(p, dir, "windows"); err == nil || !strings.Contains(err.Error(), "binary eeco.exe not found in archive") { t.Fatalf("err = %v, want 'binary eeco.exe not found in archive'", err) } }) } func TestModeFromHeader(t *testing.T) { if m := modeFromHeader(0); m&0o100 == 0 { t.Errorf("modeFromHeader(0) = %v, want exec bit set", m) } if m := modeFromHeader(0o644); m&0o100 == 0 { t.Errorf("modeFromHeader(0o644) = %v, want exec bit set", m) } } func TestExtract_ZeroModeSetsExecBit(t *testing.T) { dir := t.TempDir() var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) payload := []byte("BIN") if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/eeco", Mode: 0, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil { t.Fatal(err) } if _, err := tw.Write(payload); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } archive := filepath.Join(dir, "zero.tar.gz") if err := os.WriteFile(archive, buf.Bytes(), 0o644); err != nil { t.Fatal(err) } got, err := extract(archive, dir, "linux") if err != nil { t.Fatalf("extract: %v", err) } if runtime.GOOS != "windows" { info, serr := os.Stat(got) if serr != nil { t.Fatal(serr) } if info.Mode()&0o100 == 0 { t.Errorf("exec bit not set on zero-mode entry: %v", info.Mode()) } } } func TestWriteBinary_Errors(t *testing.T) { t.Run("mkdir parent is a file", func(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "afile") fileInTheWay(t, f) if err := writeBinary(filepath.Join(f, "sub", "eeco"), strings.NewReader("x"), 0o755); err == nil { t.Fatal("expected error when parent is a file") } }) t.Run("dst is a directory", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "isdir") if err := os.MkdirAll(dst, 0o755); err != nil { t.Fatal(err) } if err := writeBinary(dst, strings.NewReader("x"), 0o755); err == nil { t.Fatal("expected error when dst is a directory") } }) } func TestCopyFile_Errors(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src") if err := os.WriteFile(src, []byte("data"), 0o644); err != nil { t.Fatal(err) } t.Run("src missing", func(t *testing.T) { if err := copyFile(filepath.Join(dir, "nope"), filepath.Join(dir, "out")); err == nil { t.Fatal("expected error for missing source") } }) t.Run("dst parent is a file", func(t *testing.T) { f := filepath.Join(dir, "afile") fileInTheWay(t, f) if err := copyFile(src, filepath.Join(f, "sub", "out")); err == nil { t.Fatal("expected error when dst parent is a file") } }) t.Run("dst is a directory", func(t *testing.T) { dst := filepath.Join(dir, "outdir") if err := os.MkdirAll(dst, 0o755); err != nil { t.Fatal(err) } if err := copyFile(src, dst); err == nil { t.Fatal("expected error when dst is a directory") } }) } // --- C. download.go --- func TestDownload_NonOK(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer srv.Close() err := download(srv.Client(), srv.URL+"/x", filepath.Join(t.TempDir(), "out")) if err == nil || !strings.Contains(err.Error(), "http 404") { t.Fatalf("download err = %v, want 'http 404'", err) } } func TestDownload_CreateTempFails(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("BODY")) })) defer srv.Close() f := filepath.Join(t.TempDir(), "afile") fileInTheWay(t, f) // dst's parent is a regular file → CreateTemp(parentDir(dst)) ENOTDIRs. if err := download(srv.Client(), srv.URL+"/x", filepath.Join(f, "out")); err == nil { t.Fatal("expected error when dst parent is a file") } } func TestParentDir(t *testing.T) { cases := []struct{ in, want string }{ {"file", "."}, {"a/b", "a"}, {`a\b`, `a`}, {"a/b/c", "a/b"}, } for _, c := range cases { if got := parentDir(c.in); got != c.want { t.Errorf("parentDir(%q) = %q, want %q", c.in, got, c.want) } } } func TestSha256File_OpenErr(t *testing.T) { if _, err := sha256File(filepath.Join(t.TempDir(), "nope")); err == nil { t.Fatal("expected error for missing file") } } // --- D. ledger.go --- func TestLoadLedger_EdgeCases(t *testing.T) { t.Run("binary.json is a directory", func(t *testing.T) { cfg := newTestCfg(t) if err := os.MkdirAll(filepath.Join(cfg.Workspace, "state", ledgerName), 0o755); err != nil { t.Fatal(err) } // LoadLedger returns the raw (non-NotExist) read error here. if _, err := LoadLedger(cfg); err == nil { t.Fatal("expected error when binary.json is a directory") } }) t.Run("empty file", func(t *testing.T) { cfg := newTestCfg(t) if err := os.WriteFile(filepath.Join(cfg.Workspace, "state", ledgerName), nil, 0o644); err != nil { t.Fatal(err) } led, err := LoadLedger(cfg) if err != nil { t.Fatalf("LoadLedger empty: %v", err) } if led != (Ledger{}) { t.Errorf("empty file → %+v, want zero Ledger", led) } }) t.Run("malformed JSON", func(t *testing.T) { cfg := newTestCfg(t) if err := os.WriteFile(filepath.Join(cfg.Workspace, "state", ledgerName), []byte("{not json"), 0o644); err != nil { t.Fatal(err) } led, err := LoadLedger(cfg) if err != nil { t.Fatalf("LoadLedger malformed: %v", err) } if led != (Ledger{}) { t.Errorf("malformed → %+v, want zero Ledger", led) } }) } func TestWriteLedger_MkdirFails(t *testing.T) { cfg := newTestCfgStateFile(t) if err := writeLedger(cfg, Ledger{Installed: true}); err == nil || !strings.Contains(err.Error(), "binary ledger dir:") { t.Fatalf("writeLedger err = %v, want 'binary ledger dir:'", err) } } // --- F. Apply error paths (via Options DI + fixtures) --- func TestApply_ExecutableErr(t *testing.T) { cfg := newTestCfg(t) var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{ BaseURL: "http://127.0.0.1:0", Executable: func() (string, error) { return "", errors.New("boom") }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "cannot resolve running binary") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_StagingMkdirFails(t *testing.T) { cfg := newTestCfgStateFile(t) running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{ BaseURL: "http://127.0.0.1:0", Executable: func() (string, error) { return running, nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "prepare staging dir:") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_DownloadFails(t *testing.T) { cfg := newTestCfg(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer srv.Close() running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "download ") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_ChecksumReadFails(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) assets["SHA256SUMS"] = []byte("") // served empty → no entry for the archive running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { return "ok", nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "read SHA256SUMS:") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_GhVerifyFails(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { if name == "gh" { return "denied", errors.New("exit 1") } return "ok", nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "gh attestation verify failed") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_StagedMkdirFails(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) // stagingDir created up-front with a regular file where `staged` (a // directory) is expected, so its MkdirAll ENOTDIRs after verification. stagingDir := filepath.Join(cfg.Workspace, "state", "update-"+tag) if err := os.MkdirAll(stagingDir, 0o755); err != nil { t.Fatal(err) } fileInTheWay(t, filepath.Join(stagingDir, "staged")) running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { return "ok", nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "prepare staged dir:") { t.Errorf("stderr = %q", stderr.String()) } } func TestApply_ExtractFails(t *testing.T) { cfg := newTestCfg(t) tag := "v9.0.0" srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD")) archiveName := archiveBasename(tag, runtime.GOOS, runtime.GOARCH) // Serve a corrupt archive and record its true sha so the checksum // check passes (cosign+gh forced ok), leaving extract to fail. corrupt := []byte("this is not a valid archive") assets[archiveName] = corrupt sum := sha256.Sum256(corrupt) assets["SHA256SUMS"] = []byte(hex.EncodeToString(sum[:]) + " " + archiveName + "\n") running := filepath.Join(t.TempDir(), "eeco") if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{ BaseURL: srv.URL, HTTPClient: srv.Client(), Executable: func() (string, error) { return running, nil }, RunCmd: func(name string, args ...string) (string, error) { return "ok", nil }, }) if code != 1 { t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String()) } if !strings.Contains(stderr.String(), "extract:") { t.Errorf("stderr = %q", stderr.String()) } } // --- G. one-liners --- func TestTrimOutput_Truncates(t *testing.T) { got := trimOutput(strings.Repeat("x", 300)) if len(got) != 243 { t.Errorf("len = %d, want 243", len(got)) } if !strings.HasSuffix(got, "...") { t.Errorf("got %q, want '...' suffix", got) } if trimOutput("short") != "short" { t.Error("short input must be returned unchanged") } }