diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..d3cc2a6 --- /dev/null +++ b/.env.template @@ -0,0 +1,8 @@ +# Copy to .env (gitignored); the Makefile loads it automatically. + +# Access token with write:package scope. Required for `make release`. +GITEA_TOKEN= + +# Optional overrides (defaults shown). +# GITEA_URL=https://git.simon-franken.de +# GITEA_OWNER=simonfranken diff --git a/AGENTS.md b/AGENTS.md index 7511ebf..8c290b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ go run ./cmd/submarine [dir] # run on a repository (default: cwd) go run ./cmd/submarine --mock # run on mock data go run ./cmd/submarine --no-fetch [dir] # without background fetch scripts/make-fixture.sh DIR # build a test superproject in DIR +make dist # cross-compile into dist/ +GITEA_TOKEN=… make release # publish a tagged release (outward-facing) go vet ./... go test ./... gofmt -l . # must print nothing diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a234592 --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +# Build and publish submarine. Releases are built locally and uploaded to the +# Gitea generic package registry (see docs/architecture.md#distribution). +# +# make dist cross-compile into dist/ +# GITEA_TOKEN=... make release test, build and publish the tagged HEAD + +# Settings from .env (see .env.template); `make VAR=...` on the command line wins. +-include .env +export GITEA_TOKEN + +GITEA_URL ?= https://git.simon-franken.de +GITEA_OWNER ?= simonfranken +REGISTRY := $(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +PLATFORMS := linux_amd64 linux_arm64 darwin_amd64 darwin_arm64 +BINARIES := $(addprefix dist/submarine_,$(PLATFORMS)) + +.PHONY: build test vet dist release clean FORCE + +build: + go build -ldflags '$(LDFLAGS)' -o submarine ./cmd/submarine + +test: + go test ./... + +vet: + go vet ./... + @test -z "$$(gofmt -l .)" || { gofmt -l .; echo "gofmt: files need formatting"; exit 1; } + +dist: $(BINARIES) dist/checksums.txt + +# FORCE: always rebuild, since the version is baked in. +dist/submarine_%: FORCE + @mkdir -p dist + CGO_ENABLED=0 GOOS=$(word 1,$(subst _, ,$*)) GOARCH=$(word 2,$(subst _, ,$*)) \ + go build -trimpath -ldflags '$(LDFLAGS)' -o $@ ./cmd/submarine + +# Rebuilt every time so it always matches the binaries. +dist/checksums.txt: $(BINARIES) + cd dist && sha256sum $(notdir $(BINARIES)) > checksums.txt + +release: + @test -n "$$GITEA_TOKEN" || { echo "GITEA_TOKEN is not set (needs write:package)"; exit 1; } + @echo '$(VERSION)' | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \ + { echo "VERSION '$(VERSION)' is not a clean tag: tag HEAD as vX.Y.Z with a clean tree"; exit 1; } + @test -z "$$(git status --porcelain)" || { echo "working tree is not clean"; exit 1; } + $(MAKE) clean test vet dist VERSION=$(VERSION) + @set -e; \ + for f in $(BINARIES) dist/checksums.txt; do \ + echo "upload $$(basename $$f)"; \ + curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \ + --upload-file $$f "$(REGISTRY)/submarine/$(VERSION)/$$(basename $$f)" >/dev/null; \ + done; \ + echo "point latest at $(VERSION)"; \ + printf '%s\n' '$(VERSION)' > dist/VERSION; \ + curl -sS -X DELETE -H "Authorization: token $$GITEA_TOKEN" \ + "$(GITEA_URL)/api/v1/packages/$(GITEA_OWNER)/generic/submarine-latest/latest" >/dev/null || true; \ + curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \ + --upload-file dist/VERSION "$(REGISTRY)/submarine-latest/latest/VERSION" >/dev/null; \ + echo "released $(VERSION)" + +clean: + rm -rf dist submarine diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go index 19c9cc2..4c9c211 100644 --- a/cmd/submarine/main.go +++ b/cmd/submarine/main.go @@ -6,6 +6,8 @@ import ( "flag" "fmt" "os" + "path/filepath" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -15,13 +17,27 @@ import ( "submarine/internal/submodule/git" "submarine/internal/submodule/mock" "submarine/internal/tui" + "submarine/internal/update" ) +// version is set at build time (-ldflags "-X main.version=v1.2.3"). +var version = "dev" + func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "update": + os.Exit(runUpdate(os.Args[2:])) + case "version", "--version", "-version": + fmt.Println("submarine", version) + return + } + } + useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository") noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup") flag.Usage = func() { - fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]") + fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]\n submarine update [--check] [--force]\n submarine version") flag.PrintDefaults() } flag.Parse() @@ -56,9 +72,75 @@ func main() { zone.NewGlobal() defer zone.Close() + notice := checkForUpdate(ctx, *noFetch || *useMock) + p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + select { + case v := <-notice: + if v != "" { + fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version) + } + default: + } +} + +// checkForUpdate looks for a newer release in the background (at most once a +// day, cached) and delivers its version, or "" when there is none. Disabled +// for dev builds, --no-fetch, --mock and SUBMARINE_NO_UPDATE_CHECK. +func checkForUpdate(ctx context.Context, off bool) <-chan string { + out := make(chan string, 1) + cache, err := os.UserCacheDir() + if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" { + out <- "" + return out + } + go func() { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + v, err := update.New().LatestCached(ctx, filepath.Join(cache, "submarine", "latest-version")) + if err != nil || !update.Newer(version, v) { + v = "" + } + out <- v + }() + return out +} + +func runUpdate(args []string) int { + fs := flag.NewFlagSet("update", flag.ExitOnError) + check := fs.Bool("check", false, "only report whether a newer version exists") + force := fs.Bool("force", false, "install the latest version even if not newer (also for dev builds)") + fs.Parse(args) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + c := update.New() + latest, err := c.Latest(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "submarine: update check failed:", err) + return 1 + } + if !*force && !update.Newer(version, latest) { + fmt.Printf("submarine %s is up to date\n", version) + return 0 + } + if *check { + fmt.Printf("update available: %s (you have %s)\n", latest, version) + return 0 + } + if !update.Release(version) && !*force { + fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest) + return 1 + } + exe, err := c.Install(ctx, latest) + if err != nil { + fmt.Fprintln(os.Stderr, "submarine: update failed:", err) + return 1 + } + fmt.Printf("updated %s: %s -> %s\n", exe, version, latest) + return 0 } diff --git a/docs/README.md b/docs/README.md index b40248d..0fc201d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,16 @@ bare commit hash. | [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing | | [decisions.md](decisions.md) | Decision log with rationale (ADR style) | +## Install + +```sh +curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh +``` + +Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to +`~/.local/bin`. Update with `submarine update`. Maintainers publish with +`GITEA_TOKEN=… make release` (see [architecture](architecture.md#distribution)). + ## Status **Prototype.** The UI is complete for browsing. It reads real repositories diff --git a/docs/architecture.md b/docs/architecture.md index 7f661eb..f97ae6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,6 +21,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch) watch.go Watch: polls git metadata for changes gitdir.go resolves .git dirs and gitdir: files internal/submodule/mock/ fixture provider with artificial latency +internal/update/ self-update from the package registry; NO UI imports internal/tui/ Bubble Tea application app.go root model: layout, message routing, loading keys.go key bindings (also feed the help view) @@ -167,6 +168,32 @@ Fetch (network) ──writes refs──▶ .git/… ◀──writes refs── `ansi.Truncate`, which keeps escape sequences and bubblezone markers intact. - `zone.Scan` runs once, on the root view. +## Distribution + +No CI runner: releases are built and published from a developer machine. + +- `make dist` cross-compiles `CGO_ENABLED=0` binaries for linux/darwin × + amd64/arm64 into `dist/` plus `checksums.txt`; the version comes from + `git describe` and is baked in with `-X main.version`. +- `make release` with `GITEA_TOKEN` set, e.g. in `.env` (copy `.env.template`; token needs `write:package`; HEAD must be a + clean `vX.Y.Z` tag) runs tests, builds and uploads to the Gitea generic + registry `https://git.simon-franken.de/api/packages/simonfranken/generic`: + - `submarine//submarine__` and `checksums.txt` + - `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload + never advertises a broken version. +- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects + OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to + `~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`, + `SUBMARINE_BASE_URL` override). +- `submarine update [--check] [--force]` (package `internal/update`) reads + `VERSION`, downloads the asset for the running platform, verifies the + checksum and atomically replaces the real executable (symlinks resolved, + so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry. +- After the TUI exits, a one-line notice is printed if a newer release + exists. The check runs in the background at startup, at most once a day + (cached in the user cache dir) and is off for dev builds, `--no-fetch`, + `--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`. + ## Testing ```sh diff --git a/docs/decisions.md b/docs/decisions.md index 9a19b8a..b02967d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -183,3 +183,18 @@ once per submodule at a time and coalesce bursts. **Consequences.** One mechanism to test and trust. After our fetch the data updates up to one poll interval later; the header shows "fetched just now" right away. + +## D21 — Distribution: local build, Gitea generic registry, curl | sh + +**Decision.** Releases are built locally with `make` and uploaded with an +access token to the Gitea generic package registry. Installation is a +POSIX `install.sh`; `sub` is a symlink to `submarine`. Updates are +`submarine update`, driven by a `latest/VERSION` pointer file. +**Rationale.** The Gitea instance has no working runner. The generic +registry needs no extra hosting and is publicly readable. A symlink keeps +one binary to update. The pointer is uploaded last and lives in its own +package so versions stay clean. +**Consequences.** Integrity is a sha256 from the same host: it catches +corrupt or partial downloads, not a compromised registry (no signatures). +The update check is a deliberate, outside-the-provider network use: only +on `update` and a once-a-day background check that can be disabled. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..20702f7 --- /dev/null +++ b/install.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Install submarine (and the `sub` alias). +# curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh +# +# Environment: +# SUBMARINE_VERSION install this version (default: latest) +# SUBMARINE_INSTALL_DIR target directory (default: ~/.local/bin) +# SUBMARINE_BASE_URL registry root (default: the Gitea generic registry) +set -eu + +base="${SUBMARINE_BASE_URL:-https://git.simon-franken.de/api/packages/simonfranken/generic}" +dir="${SUBMARINE_INSTALL_DIR:-$HOME/.local/bin}" + +die() { echo "install: $*" >&2; exit 1; } + +case "$(uname -s)" in +Linux) os=linux ;; +Darwin) os=darwin ;; +*) die "unsupported OS: $(uname -s) (linux and macOS only)" ;; +esac +case "$(uname -m)" in +x86_64|amd64) arch=amd64 ;; +arm64|aarch64) arch=arm64 ;; +*) die "unsupported architecture: $(uname -m)" ;; +esac + +command -v curl >/dev/null 2>&1 || die "curl is required" +if command -v sha256sum >/dev/null 2>&1; then + sha() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + sha() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + die "sha256sum or shasum is required" +fi + +version="${SUBMARINE_VERSION:-}" +if [ -z "$version" ]; then + version="$(curl -fsSL "$base/submarine-latest/latest/VERSION")" || die "cannot determine the latest version" + version="$(printf '%s' "$version" | tr -d '[:space:]')" +fi +case "$version" in +v[0-9]*.[0-9]*.[0-9]*) ;; +*) die "invalid version: $version" ;; +esac + +asset="submarine_${os}_${arch}" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +echo "Installing submarine $version ($os/$arch) to $dir" +curl -fsSL -o "$tmp/$asset" "$base/submarine/$version/$asset" || die "download failed: $asset" +curl -fsSL -o "$tmp/checksums.txt" "$base/submarine/$version/checksums.txt" || die "download failed: checksums.txt" + +want="$(awk -v a="$asset" '$2 == a || $2 == "*" a { print $1 }' "$tmp/checksums.txt")" +[ -n "$want" ] || die "no checksum for $asset" +[ "$(sha "$tmp/$asset")" = "$want" ] || die "checksum mismatch for $asset" + +mkdir -p "$dir" +chmod +x "$tmp/$asset" +# Stage next to the target, then rename: atomic, and safe if it is running. +cp "$tmp/$asset" "$dir/.submarine.new" +mv -f "$dir/.submarine.new" "$dir/submarine" + +if [ -e "$dir/sub" ] && [ "$(readlink "$dir/sub" 2>/dev/null)" != "submarine" ]; then + echo "install: $dir/sub exists and is not ours, leaving it alone (use \`submarine\`)" >&2 +else + ln -sf submarine "$dir/sub" +fi + +echo "Installed: $dir/submarine (alias: sub)" +case ":$PATH:" in +*":$dir:"*) ;; +*) echo "Note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;; +esac +echo "Update later with: submarine update" diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..8143084 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,228 @@ +// Package update implements self-update from the Gitea generic package +// registry. It has no UI imports; the network is only used on explicit calls. +package update + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// DefaultBaseURL is the registry root; see docs/architecture.md#distribution. +const DefaultBaseURL = "https://git.simon-franken.de/api/packages/simonfranken/generic" + +// EnvBaseURL overrides the registry root (tests, manual verification). +const EnvBaseURL = "SUBMARINE_UPDATE_URL" + +// maxBinary bounds a download so a bad server cannot fill the disk. +const maxBinary = 256 << 20 + +// Client talks to the registry. +type Client struct { + BaseURL string + HTTP *http.Client + GOOS string + GOARCH string +} + +// New returns a client for the default (or $SUBMARINE_UPDATE_URL) registry. +func New() *Client { + base := os.Getenv(EnvBaseURL) + if base == "" { + base = DefaultBaseURL + } + return &Client{ + BaseURL: strings.TrimRight(base, "/"), + HTTP: &http.Client{Timeout: 5 * time.Minute}, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + } +} + +// Latest returns the newest released version, e.g. "v0.2.0". +func (c *Client) Latest(ctx context.Context) (string, error) { + b, err := c.get(ctx, c.BaseURL+"/submarine-latest/latest/VERSION", 1<<10) + if err != nil { + return "", err + } + v := strings.TrimSpace(string(b)) + if _, ok := parse(v); !ok { + return "", fmt.Errorf("registry returned an invalid version %q", v) + } + return v, nil +} + +// Newer reports whether latest is a higher version than current. Builds that +// are not release versions (e.g. "dev") are never considered up to date. +func Newer(current, latest string) bool { + l, ok := parse(latest) + if !ok { + return false + } + c, ok := parse(current) + if !ok { + return true + } + for i := range l { + if l[i] != c[i] { + return l[i] > c[i] + } + } + return false +} + +// Release reports whether v is a release version (vX.Y.Z). +func Release(v string) bool { _, ok := parse(v); return ok } + +func parse(v string) ([3]int, bool) { + var out [3]int + parts := strings.Split(strings.TrimPrefix(v, "v"), ".") + if !strings.HasPrefix(v, "v") || len(parts) != 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 || p != strconv.Itoa(n) { + return out, false + } + out[i] = n + } + return out, true +} + +// Install downloads version for this platform, verifies its checksum and +// replaces the running executable (symlinks resolved) atomically. +func (c *Client) Install(ctx context.Context, version string) (string, error) { + if !Release(version) { + return "", fmt.Errorf("invalid version %q", version) + } + exe, err := os.Executable() + if err != nil { + return "", err + } + if exe, err = filepath.EvalSymlinks(exe); err != nil { + return "", err + } + return exe, c.InstallTo(ctx, version, exe) +} + +// InstallTo is Install with an explicit target path. +func (c *Client) InstallTo(ctx context.Context, version, target string) error { + asset := fmt.Sprintf("submarine_%s_%s", c.GOOS, c.GOARCH) + base := c.BaseURL + "/submarine/" + version + "/" + + sums, err := c.get(ctx, base+"checksums.txt", 1<<20) + if err != nil { + return err + } + want, err := checksumFor(sums, asset) + if err != nil { + return err + } + + // Same directory as the target, so the final rename is atomic. + tmp, err := os.CreateTemp(filepath.Dir(target), ".submarine-update-*") + if err != nil { + return fmt.Errorf("cannot write next to %s (permission?): %w", target, err) + } + defer os.Remove(tmp.Name()) // no-op after a successful rename + + h := sha256.New() + if err := c.stream(ctx, base+asset, io.MultiWriter(tmp, h)); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", asset, got, want) + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + return err + } + return os.Rename(tmp.Name(), target) +} + +func checksumFor(sums []byte, asset string) (string, error) { + sc := bufio.NewScanner(strings.NewReader(string(sums))) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) == 2 && strings.TrimPrefix(f[1], "*") == asset { + return strings.ToLower(f[0]), nil + } + } + return "", fmt.Errorf("no checksum for %s in checksums.txt", asset) +} + +func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) { + var buf strings.Builder + if err := c.stream(ctx, url, &limitWriter{&buf, limit}); err != nil { + return nil, err + } + return []byte(buf.String()), nil +} + +func (c *Client) stream(ctx context.Context, url string, w io.Writer) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: %s", url, resp.Status) + } + n, err := io.Copy(w, io.LimitReader(resp.Body, maxBinary+1)) + if err != nil { + return err + } + if n > maxBinary { + return errors.New("download too large") + } + return nil +} + +type limitWriter struct { + w io.Writer + n int64 +} + +func (l *limitWriter) Write(p []byte) (int, error) { + l.n -= int64(len(p)) + if l.n < 0 { + return 0, errors.New("response too large") + } + return l.w.Write(p) +} + +// CheckInterval is how often the passive check may hit the network. +const CheckInterval = 24 * time.Hour + +// LatestCached is Latest with a file cache: within CheckInterval it returns +// the cached answer without any network access. A failed lookup is cached too, +// so an unreachable registry is not retried on every start. +func (c *Client) LatestCached(ctx context.Context, cacheFile string) (string, error) { + if fi, err := os.Stat(cacheFile); err == nil && time.Since(fi.ModTime()) < CheckInterval { + b, _ := os.ReadFile(cacheFile) + return strings.TrimSpace(string(b)), nil + } + v, err := c.Latest(ctx) + _ = os.MkdirAll(filepath.Dir(cacheFile), 0o755) + _ = os.WriteFile(cacheFile, []byte(v), 0o644) // v is "" on failure + return v, err +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..1e54134 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,106 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewer(t *testing.T) { + cases := []struct { + cur, latest string + want bool + }{ + {"v0.1.0", "v0.2.0", true}, + {"v0.2.0", "v0.2.0", false}, + {"v0.10.0", "v0.9.0", false}, + {"v1.0.0", "v0.9.9", false}, + {"dev", "v0.1.0", true}, + {"v0.1.0-3-gabc", "v0.1.0", true}, + {"v0.1.0", "garbage", false}, + } + for _, c := range cases { + if got := Newer(c.cur, c.latest); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.cur, c.latest, got, c.want) + } + } +} + +func registry(t *testing.T, bin, sum string) *Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/submarine-latest/latest/VERSION", func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("v0.2.0\n")) + }) + mux.HandleFunc("/submarine/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(sum + " submarine_linux_arm64\n")) + }) + mux.HandleFunc("/submarine/v0.2.0/submarine_linux_arm64", func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(bin)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return &Client{BaseURL: srv.URL, HTTP: srv.Client(), GOOS: "linux", GOARCH: "arm64"} +} + +func sha(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) } + +func TestLatest(t *testing.T) { + c := registry(t, "x", sha("x")) + v, err := c.Latest(context.Background()) + if err != nil || v != "v0.2.0" { + t.Fatalf("Latest = %q, %v", v, err) + } +} + +func TestInstallReplaces(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "submarine") + os.WriteFile(target, []byte("old"), 0o755) + c := registry(t, "new-binary", sha("new-binary")) + if err := c.InstallTo(context.Background(), "v0.2.0", target); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(target) + if string(got) != "new-binary" { + t.Fatalf("target = %q", got) + } + if fi, _ := os.Stat(target); fi.Mode().Perm()&0o111 == 0 { + t.Error("target not executable") + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("leftover files: %v", entries) + } +} + +func TestInstallChecksumMismatch(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "submarine") + os.WriteFile(target, []byte("old"), 0o755) + c := registry(t, "tampered", sha("original")) + err := c.InstallTo(context.Background(), "v0.2.0", target) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("err = %v", err) + } + if got, _ := os.ReadFile(target); string(got) != "old" { + t.Errorf("target modified: %q", got) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("temp file left behind: %v", entries) + } +} + +func TestInstallMissingAsset(t *testing.T) { + c := registry(t, "x", sha("x")) + c.GOARCH = "riscv64" + err := c.InstallTo(context.Background(), "v0.2.0", filepath.Join(t.TempDir(), "submarine")) + if err == nil { + t.Fatal("expected error") + } +}