ajhahn.de
← eeco
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")
	}
}