Go 1101 lines
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 <ws>/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")
}
}