From bc95d28ad04677473d62978657d2f08f0c8a64cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 19 Sep 2026 19:58:19 +0200 Subject: [PATCH 1/2] feat(cli): add `machineid update` self-update command CLI only: the importable library gains no code, no network access and no dependencies. All logic lives in internal/selfupdate (standard library only) and cmd/machineid/update.go is a thin adapter. Release lookup for a public repository - The newest tag is read from the redirect that github.com//releases/latest returns; a tag's existence from its release page; assets from releases/download//. No REST API, no gh, no token, so the unauthenticated 60/h quota never applies. - Redirects are followed only to github.com and *.githubusercontent.com. Hourly budget - At most 5 live lookups per rolling hour per user (MACHINEID_UPDATE_BUDGET overrides, 0 disables), the last answer cached for an hour in the user cache directory (0600). -check serves the cache; over budget it still answers from the stale cache while a real update refuses with the next allowed time. 429/403 Retry-After is persisted as a backoff. A corrupt or unwritable state file never blocks an update. Install - Linux zip and the new macOS universal zip replace the binary in place with an atomic rename staged in the target's directory. The macOS .pkg is used only when the running binary is in /usr/local/bin and needs root, which is never requested: the remedy prints the sudo command. `go install` is the fallback and the only method on Windows. - Both fixed-destination methods are guarded against installing somewhere other than the running binary; -force overrides. - SHA-256 is mandatory. Sigstore bundles are verified with cosign when present, Apple signatures with pkgutil/codesign; -require-signature makes a skipped check fatal. - Exit codes: 0 ok/current/-check/declined, 1 prerequisite (nothing attempted), 2 bad arguments, 3 attempted and failed. Release pipeline - Publish machineid-darwin-universal.zip + .zip.sha256 from the signed universal binary, so go-installed macOS copies can update in place. Docs - README "Updating the CLI" section and security bullets, CONTRIBUTING asset-name contract, package docs, copilot-instructions layout. Tests use an in-memory fake GitHub (httptest.NewTestServer), a fake exec and an injected clock; nothing reaches the real network. Co-Authored-By: Claude Fable 5.1 --- .github/copilot-instructions.md | 1 + .github/workflows/release.yml | 12 + CONTRIBUTING.md | 14 + README.md | 57 +++- cmd/machineid/main.go | 16 +- cmd/machineid/update.go | 222 ++++++++++++++ cmd/machineid/update_test.go | 54 ++++ internal/selfupdate/asset.go | 120 ++++++++ internal/selfupdate/asset_test.go | 58 ++++ internal/selfupdate/budget.go | 256 ++++++++++++++++ internal/selfupdate/budget_test.go | 199 ++++++++++++ internal/selfupdate/doc.go | 61 ++++ internal/selfupdate/download.go | 200 ++++++++++++ internal/selfupdate/download_test.go | 163 ++++++++++ internal/selfupdate/errors.go | 268 ++++++++++++++++ internal/selfupdate/fake_test.go | 255 ++++++++++++++++ internal/selfupdate/install.go | 154 ++++++++++ internal/selfupdate/install_test.go | 128 ++++++++ internal/selfupdate/lookup.go | 302 ++++++++++++++++++ internal/selfupdate/lookup_test.go | 127 ++++++++ internal/selfupdate/prereq.go | 360 ++++++++++++++++++++++ internal/selfupdate/prereq_test.go | 186 ++++++++++++ internal/selfupdate/updater.go | 438 +++++++++++++++++++++++++++ internal/selfupdate/updater_test.go | 367 ++++++++++++++++++++++ internal/selfupdate/verify.go | 191 ++++++++++++ internal/selfupdate/verify_test.go | 64 ++++ internal/selfupdate/version.go | 209 +++++++++++++ internal/selfupdate/version_test.go | 116 +++++++ 28 files changed, 4595 insertions(+), 3 deletions(-) create mode 100644 cmd/machineid/update.go create mode 100644 cmd/machineid/update_test.go create mode 100644 internal/selfupdate/asset.go create mode 100644 internal/selfupdate/asset_test.go create mode 100644 internal/selfupdate/budget.go create mode 100644 internal/selfupdate/budget_test.go create mode 100644 internal/selfupdate/doc.go create mode 100644 internal/selfupdate/download.go create mode 100644 internal/selfupdate/download_test.go create mode 100644 internal/selfupdate/errors.go create mode 100644 internal/selfupdate/fake_test.go create mode 100644 internal/selfupdate/install.go create mode 100644 internal/selfupdate/install_test.go create mode 100644 internal/selfupdate/lookup.go create mode 100644 internal/selfupdate/lookup_test.go create mode 100644 internal/selfupdate/prereq.go create mode 100644 internal/selfupdate/prereq_test.go create mode 100644 internal/selfupdate/updater.go create mode 100644 internal/selfupdate/updater_test.go create mode 100644 internal/selfupdate/verify.go create mode 100644 internal/selfupdate/verify_test.go create mode 100644 internal/selfupdate/version.go create mode 100644 internal/selfupdate/version_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 643f1be..28437f3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,6 +27,7 @@ Since this is a library build in native go, the files are mostly organized follo - LICENSE is the license file for the project. - README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. - go.mod and go.sum manage the project's dependencies. +- internal/selfupdate/ implements `machineid update`. It is CLI-only: the root library never imports it, makes no network requests and gains no dependencies from it. - \*.go files contain the main source code of the library. - \*\_test.go files contain the test cases for the library. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8d9023..88f3959 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -233,6 +233,18 @@ jobs: codesign --verify --verbose=4 "./dist/macos/${bin}" done + # The universal zip carries the same signed binary as the .pkg. It is + # what `machineid update` installs when the running binary does not + # live in /usr/local/bin (for example a `go install` copy). + - name: Create universal zip archives + run: | + mkdir -p ./dist/assets + for bin in machineid; do + zip --junk-paths "./dist/assets/${bin}-darwin-universal.zip" "./dist/macos/${bin}" + shasum -a 256 "./dist/assets/${bin}-darwin-universal.zip" \ + | cut -d ' ' -f 1 > "./dist/assets/${bin}-darwin-universal.zip.sha256" + done + - name: Create, sign, notarize & staple .pkg installers env: MACOS_INSTALLER_SIGNING_IDENTITY: ${{ secrets.MACOS_INSTALLER_SIGNING_IDENTITY }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81ad51e..d98d380 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,20 @@ Thank you for your interest in contributing to machineid! This document provides Runnable examples are listed with `go doc -ex github.com/slashdevops/machineid`. +### Release asset names are a contract + +`machineid update` downloads assets by name from the GitHub release. The names are pinned in +`internal/selfupdate/asset_test.go`; renaming an asset in the Makefile or the release workflow +fails that test on purpose. Today's names: + +| Platform | Asset | Checksum | Signature | +|----------|-------|----------|-----------| +| Linux amd64/arm64 | `machineid-linux-.zip` (contains `machineid`) | `machineid-linux-.sha256` | `machineid-linux-.sigstore.json` | +| macOS | `machineid-darwin-universal.pkg` | `machineid-darwin-universal.sha256` | Apple Developer ID, notarized | +| macOS (in-place updates) | `machineid-darwin-universal.zip` (contains `machineid`) | `machineid-darwin-universal.zip.sha256` | Apple codesign on the binary | + +If a name has to change, change the test and the updater together and note it in the release. + ### Getting Started 1. Fork the repository on GitHub diff --git a/README.md b/README.md index c826d9b..b97225a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ id, err := machineid.New().WithCPU().WithSystemUUID().ID(ctx) - [✨ Features](#-features) - [πŸ“¦ Installation](#-installation) +- [⬆️ Updating the CLI](#️-updating-the-cli) - [πŸš€ Quick start](#-quick-start) - [πŸ–₯️ CLI](#️-cli) - [πŸ“– Library guide](#-library-guide) @@ -128,6 +129,59 @@ make build --- +## ⬆️ Updating the CLI + +Once installed, the CLI updates itself: + +```bash +machineid update +``` + +It looks up the newest release, shows a checklist and the plan, asks for confirmation, downloads the asset for your platform, verifies its SHA-256 and signature, and replaces the binary you are running. Nothing is downloaded until every check has passed. Root is never requested; where it is needed (the macOS package) the exact `sudo` command is printed. + +```text +$ machineid update +Checking for updates… + βœ“ current version v0.2.0 + βœ“ running binary /usr/local/bin/machineid + βœ“ platform supported darwin/arm64 + βœ“ latest release v0.3.0 (live, 4 of 5 checks left this hour) + βœ“ install target /usr/local/bin (running as root) + +β†’ Updating machineid v0.2.0 β†’ v0.3.0 using the signed macOS package + +Update machineid now? [y/N] y + downloading machineid-darwin-universal.pkg… + βœ“ SHA-256 verified + βœ“ pkgutil: Developer ID Installer: SlashDevOps + βœ“ installed to /usr/local/bin + +βœ… Updated to v0.3.0 +``` + +| Flag | Meaning | +|------|---------| +| `-check` | Report what would happen and change nothing. Served from the cache when it is under an hour old. | +| `-refresh` | Look up the latest release now instead of using the cache. | +| `-version TAG` | Install a specific release, e.g. `-version v0.2.0`. This is also how to go back a version. | +| `-method auto\|release\|go` | `release` installs the signed asset (default where one exists), `go` rebuilds with `go install`. | +| `-force` | Install to the method's location even if this binary lives elsewhere, and reinstall an equal version. | +| `-yes` | Do not ask for confirmation. Required when stdin is not a terminal. | +| `-require-signature` | Fail unless the signature was verified: Sigstore via `cosign` on Linux, Apple's on macOS. Without the flag a missing `cosign` only prints a warning. | + +| Exit code | Meaning | +|-----------|---------| +| `0` | Updated, already current, `-check`, or you declined | +| `1` | A prerequisite was not met. **Nothing was attempted.** Fix it and re-run. | +| `2` | Invalid arguments | +| `3` | The update was attempted and failed. A checksum failure leaves the old binary untouched. | + +**Where it installs.** The Linux zip and the macOS universal zip replace the binary in place, wherever it is. The macOS `.pkg` always installs to `/usr/local/bin` and needs root, so it is only chosen when that is where you are running from. `go install` always writes to `GOBIN`. If the chosen method would land somewhere else, the update refuses and tells you which flag targets your copy. Windows has no published binaries yet, so it uses `-method go`. + +**Network use and limits.** `machineid update` is the **only** thing in this tool that touches the network. Normal runs, `-validate` and `-version` never do, and there is no background check. The lookup asks `github.com` for the newest tag with a plain HTTPS request, without the GitHub API and without any token. The answer is cached for an hour and at most **5 live lookups per hour** are made per user, so a cron job running `machineid update -check` costs GitHub nothing after the first call. `MACHINEID_UPDATE_BUDGET` raises the limit if you must. Downloads only happen after you confirm a newer version. + +--- + ## πŸš€ Quick start ```go @@ -451,7 +505,8 @@ Be deliberate about which of these your users are likely to do. - πŸͺͺ The output contains no personally identifiable information. - ⏱️ Every system command has a timeout and is killed, together with its output pipes, when the context ends. - πŸ›‘οΈ Firmware sentinels (nil and max UUIDs, "To be filled by O.E.M.") are rejected so they can never make two different machines share an ID. -- πŸ” Release binaries are signed: Apple Developer ID plus notarization on macOS, Sigstore keyless signatures on Linux. +- πŸ” Release binaries are signed: Apple Developer ID plus notarization on macOS, Sigstore keyless signatures on Linux. `machineid update` verifies both before installing. +- πŸ“΄ The tool never makes a network request unless you run `machineid update`. There is no telemetry and no background update check. Please report vulnerabilities as described in [SECURITY.md](SECURITY.md). diff --git a/cmd/machineid/main.go b/cmd/machineid/main.go index 881b6e7..46d4d99 100644 --- a/cmd/machineid/main.go +++ b/cmd/machineid/main.go @@ -12,6 +12,10 @@ // // Exit codes: 0 success, 1 generation or validation failed, 2 invalid // arguments. Run machineid -h for the full flag list. +// +// The update verb, machineid update, replaces this binary with the latest +// GitHub release. It is the only part of the program that uses the network +// and it never runs unless asked. See machineid update -h. package main import ( @@ -19,6 +23,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log/slog" "os" "os/signal" @@ -34,6 +39,12 @@ import ( const applicationName = "machineid" func main() { + // `machineid update` is a verb, not a flag: it has its own flag set and + // is the only code path in this program that touches the network. + if isUpdateVerb(os.Args[1:]) { + os.Exit(runUpdate(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)) + } + // Hardware component flags cpu := flag.Bool("cpu", false, "Include CPU identifier") motherboard := flag.Bool("motherboard", false, "Include motherboard serial number") @@ -184,7 +195,8 @@ func printUsage() { fmt.Fprintf(w, "Usage:\n") fmt.Fprintf(w, " %s [-cpu] [-uuid] [-motherboard] [-mac] [-disk] [options]\n", applicationName) fmt.Fprintf(w, " %s -all [options]\n", applicationName) - fmt.Fprintf(w, " %s -vm [options]\n\n", applicationName) + fmt.Fprintf(w, " %s -vm [options]\n", applicationName) + fmt.Fprintf(w, " %s update [options] Update this binary to the latest release (see: %s update -h)\n\n", applicationName, applicationName) fmt.Fprintf(w, "When no component flags are specified, the default is -cpu -motherboard -uuid.\n\n") @@ -238,7 +250,7 @@ func printUsage() { fmt.Fprintf(w, " 2 Invalid arguments\n") } -func printFlag(w *os.File, name, desc string) { +func printFlag(w io.Writer, name, desc string) { fmt.Fprintf(w, " %-20s %s\n", name, desc) } diff --git a/cmd/machineid/update.go b/cmd/machineid/update.go new file mode 100644 index 0000000..6a21dc3 --- /dev/null +++ b/cmd/machineid/update.go @@ -0,0 +1,222 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/slashdevops/machineid/internal/selfupdate" +) + +// Exit codes of the update verb. 2 stays "invalid arguments" as in the rest +// of the CLI; the ds-utils-style split between "nothing attempted" and +// "attempted and failed" uses 1 and 3. +const ( + exitUpdateOK = 0 + exitUpdatePrerequisite = 1 + exitUpdateUsage = 2 + exitUpdateFailed = 3 +) + +// updateVerb is the positional command that triggers a self-update. +const updateVerb = "update" + +// isUpdateVerb reports whether args (without the program name) start the update verb. +func isUpdateVerb(args []string) bool { + return len(args) > 0 && args[0] == updateVerb +} + +// runUpdate executes `machineid update` and returns the process exit code. +func runUpdate(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(applicationName+" "+updateVerb, flag.ContinueOnError) + fs.SetOutput(stderr) + + method := fs.String("method", string(selfupdate.MethodAuto), "How to update: auto, release (signed GitHub asset) or go (go install)") + version := fs.String("version", "", "Install a specific release tag, e.g. v0.3.0 (also how to go back a version)") + check := fs.Bool("check", false, "Report what would happen and exit without changing anything") + refresh := fs.Bool("refresh", false, "Bypass the one-hour cache and look up the latest release now (counts against the hourly budget)") + force := fs.Bool("force", false, "Install to the method's location even if this binary lives elsewhere, and reinstall an equal version") + yes := fs.Bool("yes", false, "Do not ask for confirmation") + requireSig := fs.Bool("require-signature", false, "Fail unless the release signature (Sigstore on Linux, Apple on macOS) was verified") + verbose := fs.Bool("verbose", false, "Log info-level messages to stderr") + debugFlag := fs.Bool("debug", false, "Log debug-level messages to stderr") + + fs.Usage = func() { printUpdateUsage(stderr, fs) } + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitUpdateOK + } + + return exitUpdateUsage + } + + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "Error: unexpected argument %q\n\n", fs.Arg(0)) + fs.Usage() + + return exitUpdateUsage + } + + m := selfupdate.Method(strings.ToLower(strings.TrimSpace(*method))) + switch m { + case selfupdate.MethodAuto, selfupdate.MethodRelease, selfupdate.MethodGo: + default: + fmt.Fprintf(stderr, "Error: unknown -method %q; valid values are auto, release, go\n", *method) + + return exitUpdateUsage + } + + var logger *slog.Logger + switch { + case *debugFlag: + logger = slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + case *verbose: + logger = slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + current := resolveVersion() + + statePath, err := selfupdate.DefaultStatePath() + if err != nil { + if logger != nil { + logger.Debug("no user cache directory; the update budget is not persisted", "error", err) + } + statePath = "" + } + + budget := selfupdate.NewBudget(statePath, selfupdate.LimitFromEnv(selfupdate.DefaultLimit)) + if err := budget.Load(); err != nil && logger != nil { + logger.Debug("update state not loaded", "error", err) + } + + updater := &selfupdate.Updater{ + Checker: selfupdate.NewChecker(current, selfupdate.DefaultExec), + Client: selfupdate.NewClient(current), + Budget: budget, + Exec: selfupdate.DefaultExec, + Out: stdout, + Confirm: confirmer(stdin, stdout, stderr), + Logger: logger, + } + + fmt.Fprintln(stdout, "Checking for updates…") + + result, err := updater.Run(ctx, selfupdate.Options{ + CurrentVersion: current, + Method: m, + Version: strings.TrimSpace(*version), + Check: *check, + Refresh: *refresh, + Force: *force, + AssumeYes: *yes, + RequireSignature: *requireSig, + }) + if err != nil { + return reportUpdateError(stderr, err) + } + + if result.Changed { + if result.To != "" { + fmt.Fprintf(stdout, "\nβœ… Updated to %s\n", result.To) + } else { + fmt.Fprintf(stdout, "\nβœ… Updated to %s\n", result.Tag) + } + } + + return exitUpdateOK +} + +// reportUpdateError prints the failure and its remedy and maps it to an exit code. +func reportUpdateError(stderr io.Writer, err error) int { + fmt.Fprintf(stderr, "\nError: %v\n", err) + + if remedy := selfupdate.RemedyOf(err); remedy != "" { + fmt.Fprintf(stderr, "\n%s\n", remedy) + } + + if selfupdate.IsPrerequisite(err) { + return exitUpdatePrerequisite + } + + return exitUpdateFailed +} + +// confirmer returns a y/N prompt bound to the given streams. When stdin is +// not a terminal the prompt declines instead of blocking, so a piped +// invocation without --yes installs nothing. +func confirmer(stdin io.Reader, stdout, stderr io.Writer) func(string) (bool, error) { + interactive := false + if f, ok := stdin.(*os.File); ok { + if info, err := f.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + interactive = true + } + } + + reader := bufio.NewReader(stdin) + + return func(prompt string) (bool, error) { + if !interactive { + fmt.Fprintln(stderr, "stdin is not a terminal; pass -yes to update without confirmation") + + return false, nil + } + + fmt.Fprintf(stdout, "\n%s [y/N] ", prompt) + + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("reading the confirmation: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return true, nil + default: + return false, nil + } + } +} + +// printUpdateUsage prints the help for the update verb. +func printUpdateUsage(w io.Writer, fs *flag.FlagSet) { + fmt.Fprintf(w, "Update %s to the latest release.\n\n", applicationName) + fmt.Fprintf(w, "Usage:\n %s update [options]\n\n", applicationName) + fmt.Fprintf(w, "The newest release is looked up on github.com; the answer is cached for an hour and at most\n") + fmt.Fprintf(w, "%d live lookups per hour are made (%s overrides). Nothing is downloaded until every\n", selfupdate.DefaultLimit, selfupdate.EnvBudget) + fmt.Fprintf(w, "check has passed and you have confirmed. Root is never requested; where it is needed the\n") + fmt.Fprintf(w, "exact command to re-run is printed.\n\n") + fmt.Fprintf(w, "Options:\n") + fs.VisitAll(func(f *flag.Flag) { + name := "-" + f.Name + switch f.Name { + case "method": + name += " M" + case "version": + name += " TAG" + } + printFlag(w, name, f.Usage) + }) + fmt.Fprintf(w, "\nExamples:\n") + fmt.Fprintf(w, " %s update Check, show the plan, ask, install\n", applicationName) + fmt.Fprintf(w, " %s update -check What would happen; nothing changes\n", applicationName) + fmt.Fprintf(w, " %s update -version v0.3.0 Install a specific release\n", applicationName) + fmt.Fprintf(w, " %s update -method go Rebuild from source with go install\n", applicationName) + fmt.Fprintf(w, " sudo %s update -yes Non-interactive, e.g. for the macOS package\n", applicationName) + fmt.Fprintf(w, "\nExit Codes:\n") + fmt.Fprintf(w, " 0 Updated, already current, -check, or declined\n") + fmt.Fprintf(w, " 1 A prerequisite was not met; nothing was attempted\n") + fmt.Fprintf(w, " 2 Invalid arguments\n") + fmt.Fprintf(w, " 3 The update was attempted and failed\n") +} diff --git a/cmd/machineid/update_test.go b/cmd/machineid/update_test.go new file mode 100644 index 0000000..a987a8c --- /dev/null +++ b/cmd/machineid/update_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestIsUpdateVerb(t *testing.T) { + if !isUpdateVerb([]string{"update"}) || !isUpdateVerb([]string{"update", "-check"}) { + t.Error("update verb not recognised") + } + if isUpdateVerb(nil) || isUpdateVerb([]string{"-cpu"}) || isUpdateVerb([]string{"-cpu", "update"}) { + t.Error("false positive") + } +} + +func TestRunUpdateHelp(t *testing.T) { + var out, errOut bytes.Buffer + if code := runUpdate([]string{"-h"}, &bytes.Buffer{}, &out, &errOut); code != exitUpdateOK { + t.Errorf("exit = %d", code) + } + for _, want := range []string{"machineid update [options]", "-check", "-require-signature", "Exit Codes", "MACHINEID_UPDATE_BUDGET"} { + if !strings.Contains(errOut.String(), want) { + t.Errorf("help missing %q:\n%s", want, errOut.String()) + } + } +} + +func TestRunUpdateUsageErrors(t *testing.T) { + tests := [][]string{ + {"-method", "bogus"}, + {"-nope"}, + {"extra"}, + } + for _, args := range tests { + var out, errOut bytes.Buffer + if code := runUpdate(args, &bytes.Buffer{}, &out, &errOut); code != exitUpdateUsage { + t.Errorf("runUpdate(%v) exit = %d, want %d\n%s", args, code, exitUpdateUsage, errOut.String()) + } + } +} + +func TestConfirmerNonInteractiveDeclines(t *testing.T) { + var out, errOut bytes.Buffer + ask := confirmer(strings.NewReader("y\n"), &out, &errOut) + ok, err := ask("Update now?") + if err != nil || ok { + t.Errorf("a non-terminal stdin must decline: ok=%v err=%v", ok, err) + } + if !strings.Contains(errOut.String(), "-yes") { + t.Error(errOut.String()) + } +} diff --git a/internal/selfupdate/asset.go b/internal/selfupdate/asset.go new file mode 100644 index 0000000..1052646 --- /dev/null +++ b/internal/selfupdate/asset.go @@ -0,0 +1,120 @@ +package selfupdate + +import ( + "fmt" + "slices" +) + +// Names shared by the whole package. They are the contract with the release +// pipeline: a test pins every asset name so a Makefile rename fails CI +// instead of breaking updates in the field. +const ( + // ToolName is the binary being updated. + ToolName = "machineid" + + // Repository is the GitHub "owner/name" that publishes releases. + Repository = "slashdevops/machineid" + + // Module is the Go module path, for `go install`. + Module = "github.com/slashdevops/machineid" + + // CommandPath is the package `go install` builds. + CommandPath = Module + "/cmd/machineid" + + // PkgInstallDir is where the macOS package always installs. + PkgInstallDir = "/usr/local/bin" +) + +// Kind is how a release asset has to be installed. +type Kind int + +const ( + // KindZip is an archive holding the binary; installing means extract and replace in place. + KindZip Kind = iota + + // KindPkg is a macOS installer package; installing means running `installer`, + // which always writes to /usr/local/bin. + KindPkg +) + +// String implements fmt.Stringer. +func (k Kind) String() string { + if k == KindPkg { + return "pkg" + } + + return "zip" +} + +// Asset identifies one release artefact and the files that verify it. +type Asset struct { + // Name is the filename within the GitHub release. + Name string + + // ChecksumName holds the SHA-256 of Name (a bare hex digest). + ChecksumName string + + // BundleName holds the Sigstore bundle for Name, or "" when none is published. + BundleName string + + // InnerName is the filename inside the archive; "" for KindPkg. + InnerName string + + // Kind is how to install it. + Kind Kind +} + +// releaseArches are the architectures the pipeline publishes; they match runtime.GOARCH. +var releaseArches = []string{"amd64", "arm64"} + +// AssetFor returns the primary release asset for a platform. +// +// Linux gets the per-architecture zip. macOS gets the signed universal +// package, which installs to [PkgInstallDir]; a macOS binary living elsewhere +// is served by [DarwinZipAsset] instead, and the planner chooses between the +// two by looking at where the running binary is. Windows has no published +// asset and reports [UnsupportedPlatformError]. +func AssetFor(goos, goarch string) (Asset, error) { + if !slices.Contains(releaseArches, goarch) { + return Asset{}, &UnsupportedPlatformError{GOOS: goos, GOARCH: goarch} + } + + switch goos { + case "linux": + base := fmt.Sprintf("%s-linux-%s", ToolName, goarch) + + return Asset{ + Name: base + ".zip", + ChecksumName: base + ".sha256", + BundleName: base + ".sigstore.json", + InnerName: ToolName, + Kind: KindZip, + }, nil + + case "darwin": + base := ToolName + "-darwin-universal" + + return Asset{ + Name: base + ".pkg", + ChecksumName: base + ".sha256", + Kind: KindPkg, + }, nil + + default: + return Asset{}, &UnsupportedPlatformError{GOOS: goos, GOARCH: goarch} + } +} + +// DarwinZipAsset is the universal macOS zip, for updating a binary that does +// not live in [PkgInstallDir]. It carries the same signed and notarized +// binary as the package. Published from v0.3.0 on. +func DarwinZipAsset() Asset { + base := ToolName + "-darwin-universal" + + return Asset{ + Name: base + ".zip", + ChecksumName: base + ".zip.sha256", + InnerName: ToolName, + Kind: KindZip, + } +} diff --git a/internal/selfupdate/asset_test.go b/internal/selfupdate/asset_test.go new file mode 100644 index 0000000..016e3db --- /dev/null +++ b/internal/selfupdate/asset_test.go @@ -0,0 +1,58 @@ +package selfupdate + +import ( + "errors" + "testing" +) + +// TestAssetNamesAreAContract pins every published filename. If the release +// pipeline renames an asset this test fails, which is the point. +func TestAssetNamesAreAContract(t *testing.T) { + tests := []struct { + goos, goarch string + name, sum string + bundle string + inner string + kind Kind + }{ + {"linux", "amd64", "machineid-linux-amd64.zip", "machineid-linux-amd64.sha256", "machineid-linux-amd64.sigstore.json", "machineid", KindZip}, + {"linux", "arm64", "machineid-linux-arm64.zip", "machineid-linux-arm64.sha256", "machineid-linux-arm64.sigstore.json", "machineid", KindZip}, + {"darwin", "arm64", "machineid-darwin-universal.pkg", "machineid-darwin-universal.sha256", "", "", KindPkg}, + {"darwin", "amd64", "machineid-darwin-universal.pkg", "machineid-darwin-universal.sha256", "", "", KindPkg}, + } + + for _, tt := range tests { + t.Run(tt.goos+"/"+tt.goarch, func(t *testing.T) { + a, err := AssetFor(tt.goos, tt.goarch) + if err != nil { + t.Fatal(err) + } + if a.Name != tt.name || a.ChecksumName != tt.sum || a.BundleName != tt.bundle || a.InnerName != tt.inner || a.Kind != tt.kind { + t.Errorf("AssetFor = %+v", a) + } + }) + } + + z := DarwinZipAsset() + if z.Name != "machineid-darwin-universal.zip" || z.ChecksumName != "machineid-darwin-universal.zip.sha256" || z.InnerName != "machineid" || z.Kind != KindZip { + t.Errorf("DarwinZipAsset = %+v", z) + } +} + +func TestAssetForUnsupported(t *testing.T) { + for _, p := range [][2]string{{"windows", "amd64"}, {"linux", "386"}, {"freebsd", "amd64"}} { + _, err := AssetFor(p[0], p[1]) + if _, ok := errors.AsType[*UnsupportedPlatformError](err); !ok { + t.Errorf("AssetFor(%s/%s) error = %v, want UnsupportedPlatformError", p[0], p[1], err) + } + if RemedyOf(err) == "" { + t.Errorf("UnsupportedPlatformError for %s/%s should carry a remedy", p[0], p[1]) + } + } +} + +func TestKindString(t *testing.T) { + if KindZip.String() != "zip" || KindPkg.String() != "pkg" { + t.Error("Kind.String") + } +} diff --git a/internal/selfupdate/budget.go b/internal/selfupdate/budget.go new file mode 100644 index 0000000..0370771 --- /dev/null +++ b/internal/selfupdate/budget.go @@ -0,0 +1,256 @@ +package selfupdate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "time" +) + +// Budget defaults. One "lookup" is a network round trip that resolves a +// release tag; downloads are not counted because they only follow a confirmed +// plan to install a newer version. +const ( + // DefaultLimit is how many live lookups are allowed per Window. + DefaultLimit = 5 + + // Window is the rolling period the limit applies to. + Window = time.Hour + + // CacheTTL is how long a resolved "latest" tag is reused without a lookup. + CacheTTL = time.Hour + + // EnvBudget overrides DefaultLimit. "0" disables the limit. + EnvBudget = "MACHINEID_UPDATE_BUDGET" + + stateSchema = 1 +) + +// State is what the budget persists between runs. +type State struct { + Latest *CachedLatest `json:"latest,omitempty"` + NotBefore time.Time `json:"not_before,omitzero"` + Lookups []time.Time `json:"lookups"` + Schema int `json:"schema"` +} + +// CachedLatest is the last "latest release" answer and when it was fetched. +type CachedLatest struct { + CheckedAt time.Time `json:"checked_at"` + Tag string `json:"tag"` +} + +// Budget throttles live lookups per user per machine and caches their result. +// +// It is a courtesy to github.com, not a security control: an unreadable, +// corrupt or unwritable state file never blocks an update, it only means the +// limit is not enforced for that run. +type Budget struct { + + // loadErr is the informational error from the last Load, if any. + loadErr error + + // Now supplies the clock; nil uses time.Now. + Now func() time.Time + + // Path is the state file. "" disables persistence (in-memory only). + Path string + + state State + + // Limit is the number of live lookups per Window; <= 0 disables the limit. + Limit int + + loaded bool +} + +// DefaultStatePath is /machineid/update-state.json. +func DefaultStatePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, ToolName, "update-state.json"), nil +} + +// LimitFromEnv returns the limit set by EnvBudget, or def when unset or invalid. +func LimitFromEnv(def int) int { + v, ok := os.LookupEnv(EnvBudget) + if !ok { + return def + } + + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return def + } + + return n +} + +// NewBudget returns a budget persisted at path with the given limit. +func NewBudget(path string, limit int) *Budget { + return &Budget{Path: path, Limit: limit} +} + +func (b *Budget) now() time.Time { + if b.Now != nil { + return b.Now() + } + + return time.Now() +} + +// Load reads the state file. Missing or unreadable files yield empty state +// and no error; the returned error is informational for a debug log. +func (b *Budget) Load() error { + b.loaded = true + b.state = State{Schema: stateSchema} + + if b.Path == "" { + return nil + } + + data, err := os.ReadFile(b.Path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("reading %s: %w", b.Path, err) + } + + var s State + if err := json.Unmarshal(data, &s); err != nil || s.Schema != stateSchema { + return fmt.Errorf("ignoring unreadable state in %s", b.Path) + } + + b.state = s + + return nil +} + +// Save writes the state file with 0600 permissions, creating the directory. +func (b *Budget) Save() error { + if b.Path == "" { + return nil + } + + if err := os.MkdirAll(filepath.Dir(b.Path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(b.state, "", " ") + if err != nil { + return err + } + + tmp := b.Path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + + return os.Rename(tmp, b.Path) +} + +func (b *Budget) ensureLoaded() { + if !b.loaded { + b.loadErr = b.Load() + } +} + +// LoadError returns the informational error from the last Load, if any. +func (b *Budget) LoadError() error { return b.loadErr } + +// Cached returns the cached latest tag when it is younger than CacheTTL. +func (b *Budget) Cached() (tag string, age time.Duration, ok bool) { + tag, checkedAt, ok := b.CachedAny() + if !ok { + return "", 0, false + } + + age = b.now().Sub(checkedAt) + if age < 0 || age >= CacheTTL { + return "", age, false + } + + return tag, age, true +} + +// CachedAny returns the cached latest tag regardless of age. +func (b *Budget) CachedAny() (tag string, checkedAt time.Time, ok bool) { + b.ensureLoaded() + + if b.state.Latest == nil || b.state.Latest.Tag == "" { + return "", time.Time{}, false + } + + return b.state.Latest.Tag, b.state.Latest.CheckedAt, true +} + +// prune drops lookups outside the rolling window. +func (b *Budget) prune() { + cutoff := b.now().Add(-Window) + b.state.Lookups = slices.DeleteFunc(b.state.Lookups, func(t time.Time) bool { return !t.After(cutoff) }) +} + +// Allow reports whether a live lookup may happen now. When it may not, nextAt +// is when it will be allowed again. +func (b *Budget) Allow() (ok bool, nextAt time.Time) { + b.ensureLoaded() + b.prune() + + now := b.now() + if b.state.NotBefore.After(now) { + return false, b.state.NotBefore + } + + if b.Limit <= 0 || len(b.state.Lookups) < b.Limit { + return true, now + } + + oldest := slices.MinFunc(b.state.Lookups, time.Time.Compare) + + return false, oldest.Add(Window) +} + +// Remaining is how many live lookups are left in the current window. +func (b *Budget) Remaining() int { + b.ensureLoaded() + b.prune() + + if b.Limit <= 0 { + return -1 + } + + return max(b.Limit-len(b.state.Lookups), 0) +} + +// RecordLookup notes that a live lookup happened now and, when tag is not +// empty, caches it as the latest release. Persisting failures are returned +// for logging and never fail the caller. +func (b *Budget) RecordLookup(tag string) error { + b.ensureLoaded() + b.prune() + + now := b.now() + b.state.Lookups = append(b.state.Lookups, now) + if tag != "" { + b.state.Latest = &CachedLatest{Tag: tag, CheckedAt: now} + } + + return b.Save() +} + +// RecordBackoff records that GitHub asked us to wait until t. +func (b *Budget) RecordBackoff(t time.Time) error { + b.ensureLoaded() + b.state.NotBefore = t + + return b.Save() +} diff --git a/internal/selfupdate/budget_test.go b/internal/selfupdate/budget_test.go new file mode 100644 index 0000000..ef9fcb9 --- /dev/null +++ b/internal/selfupdate/budget_test.go @@ -0,0 +1,199 @@ +package selfupdate + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func newTestBudget(t *testing.T, limit int) (*Budget, *time.Time) { + t.Helper() + now := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) + b := NewBudget(filepath.Join(t.TempDir(), "state.json"), limit) + b.Now = func() time.Time { return now } + + return b, &now +} + +func TestBudgetAllowsUpToLimitThenRefuses(t *testing.T) { + b, now := newTestBudget(t, 3) + + for i := range 3 { + ok, _ := b.Allow() + if !ok { + t.Fatalf("lookup %d should be allowed", i+1) + } + if err := b.RecordLookup("v0.3.0"); err != nil { + t.Fatal(err) + } + *now = now.Add(time.Minute) + } + + ok, next := b.Allow() + if ok { + t.Fatal("fourth lookup within the hour should be refused") + } + wantNext := time.Date(2026, 9, 19, 13, 0, 0, 0, time.UTC) + if !next.Equal(wantNext) { + t.Errorf("next = %v, want %v", next, wantNext) + } + if b.Remaining() != 0 { + t.Errorf("Remaining = %d", b.Remaining()) + } + + // The window slides: once the oldest lookup is an hour old, one slot frees up. + *now = wantNext.Add(time.Second) + if ok, _ := b.Allow(); !ok { + t.Fatal("a slot should free up after the window slides") + } + if b.Remaining() != 1 { + t.Errorf("Remaining after slide = %d, want 1", b.Remaining()) + } +} + +func TestBudgetCacheTTL(t *testing.T) { + b, now := newTestBudget(t, 5) + if _, _, ok := b.Cached(); ok { + t.Fatal("empty budget should have no cache") + } + + mustOK(t, b.RecordLookup("v0.3.0")) + *now = now.Add(30 * time.Minute) + tag, age, ok := b.Cached() + if !ok || tag != "v0.3.0" || age != 30*time.Minute { + t.Errorf("Cached = %q, %v, %v", tag, age, ok) + } + + *now = now.Add(31 * time.Minute) + if _, _, ok := b.Cached(); ok { + t.Error("cache older than CacheTTL should not be served") + } + if tag, _, ok := b.CachedAny(); !ok || tag != "v0.3.0" { + t.Error("CachedAny should still return the stale answer") + } +} + +func TestBudgetPersistsAcrossInstances(t *testing.T) { + b, now := newTestBudget(t, 2) + mustOK(t, b.RecordLookup("v0.3.0")) + mustOK(t, b.RecordLookup("")) + again := NewBudget(b.Path, 2) + again.Now = func() time.Time { return *now } + if ok, _ := again.Allow(); ok { + t.Fatal("a fresh instance must see the persisted lookups") + } + if tag, _, ok := again.Cached(); !ok || tag != "v0.3.0" { + t.Errorf("cached tag not persisted: %q %v", tag, ok) + } + + info, err := os.Stat(b.Path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("state file mode = %o, want 600", perm) + } +} + +func TestBudgetBackoff(t *testing.T) { + b, now := newTestBudget(t, 5) + until := now.Add(10 * time.Minute) + mustOK(t, b.RecordBackoff(until)) + ok, next := b.Allow() + if ok || !next.Equal(until) { + t.Errorf("Allow during backoff = %v, %v", ok, next) + } + + *now = until.Add(time.Second) + if ok, _ := b.Allow(); !ok { + t.Error("backoff should expire") + } +} + +func TestBudgetCorruptStateIsIgnored(t *testing.T) { + b, _ := newTestBudget(t, 5) + if err := os.WriteFile(b.Path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if err := b.Load(); err == nil { + t.Error("Load should report the corrupt file") + } + if ok, _ := b.Allow(); !ok { + t.Error("corrupt state must not block lookups") + } +} + +func TestBudgetUnwritablePathDoesNotBlock(t *testing.T) { + b := NewBudget(filepath.Join(t.TempDir(), "missing", "deeper", "state.json"), 5) + // The directory is created on save; make the parent a file so MkdirAll fails. + parent := filepath.Dir(filepath.Dir(b.Path)) + if err := os.WriteFile(parent, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := b.RecordLookup("v0.3.0"); err == nil { + t.Error("expected a save error") + } + if ok, _ := b.Allow(); !ok { + t.Error("a failed save must not block lookups") + } +} + +func TestBudgetLimitZeroDisables(t *testing.T) { + b, _ := newTestBudget(t, 0) + for range 50 { + if ok, _ := b.Allow(); !ok { + t.Fatal("limit 0 must always allow") + } + mustOK(t, b.RecordLookup("v0.3.0")) + } + if b.Remaining() != -1 { + t.Errorf("Remaining with no limit = %d, want -1", b.Remaining()) + } +} + +func TestLimitFromEnv(t *testing.T) { + t.Setenv(EnvBudget, "10") + if got := LimitFromEnv(5); got != 10 { + t.Errorf("got %d", got) + } + t.Setenv(EnvBudget, "0") + if got := LimitFromEnv(5); got != 0 { + t.Errorf("got %d", got) + } + t.Setenv(EnvBudget, "nope") + if got := LimitFromEnv(5); got != 5 { + t.Errorf("invalid should fall back, got %d", got) + } + t.Setenv(EnvBudget, "-1") + if got := LimitFromEnv(5); got != 5 { + t.Errorf("negative should fall back, got %d", got) + } +} + +func TestDefaultStatePath(t *testing.T) { + p, err := DefaultStatePath() + if err != nil { + t.Skip("no user cache dir:", err) + } + if filepath.Base(p) != "update-state.json" || filepath.Base(filepath.Dir(p)) != ToolName { + t.Errorf("DefaultStatePath = %s", p) + } +} + +func TestBudgetInMemoryOnly(t *testing.T) { + b := NewBudget("", 1) + if err := b.RecordLookup("v1.0.0"); err != nil { + t.Fatal(err) + } + if ok, _ := b.Allow(); ok { + t.Error("limit should apply in memory too") + } + if err := b.Load(); err != nil { + t.Error(err) + } + if !errors.Is(nil, nil) { // keep errors imported for symmetry with other tests + t.Fatal() + } +} diff --git a/internal/selfupdate/doc.go b/internal/selfupdate/doc.go new file mode 100644 index 0000000..d0fcc7e --- /dev/null +++ b/internal/selfupdate/doc.go @@ -0,0 +1,61 @@ +// Package selfupdate implements `machineid update`, which replaces the +// running CLI binary with a published release. +// +// It is a feature of the command-line tool only. The importable library at +// the module root has no update code, makes no network requests and gains no +// dependencies; this package is internal and cannot be imported from outside +// the module. +// +// # Flow +// +// Checker.Run probe the machine (local, free) +// Checker.Resolve pick release or go install +// Updater.plan resolve the tag via cache/budget, name the asset, HEAD it +// CheckInstallTarget will the install land where this binary lives? +// Fetch download, SHA-256 +// Verify* Sigstore (cosign) / Apple (pkgutil, codesign) +// ReplaceBinary/InstallPkg/GoInstall +// ConfirmVersion run the result and read its version back +// +// Everything before Fetch costs no bandwidth, so -check is cheap and a +// permission or location problem is found before a download. +// +// # A public repository needs no API and no token +// +// The newest tag is read from the redirect that +// https://github.com//releases/latest returns; a tag's existence from the +// status of its release page; assets from releases/download//. +// None of these are the REST API, so the unauthenticated 60-requests-per-hour +// quota never applies and no credential is needed, which a public binary could +// not keep secret anyway. +// +// # The hourly budget +// +// Live lookups are throttled per user per machine: at most [DefaultLimit] per +// rolling [Window], with the last answer cached for [CacheTTL]. The state +// lives in the user cache directory. Downloads are not counted; they only +// follow a confirmed plan to install a newer version. A missing or corrupt +// state file never blocks an update: the budget is a courtesy to github.com, +// not a security control. [EnvBudget] overrides the limit. +// +// # Nothing runs without asking +// +// Normal machineid runs never touch the network. Only the update verb does, +// and it never escalates privileges: where root is required the error carries +// the exact command to re-run. +// +// # Both install methods have a fixed destination +// +// `installer -pkg` always writes /usr/local/bin and `go install` always +// writes GOBIN, neither consulting the running binary. An update landing +// elsewhere would report success while the copy on PATH stays old, so +// [Checker.CheckInstallTarget] guards both directions and -force overrides +// it. The Linux zip and the macOS universal zip replace the binary in place +// through an atomic rename staged in the target's own directory. +// +// # Errors carry their remedy +// +// Every failure implements [Error], adding Remedy() so the command can print +// what to do next. [PrerequisiteError] wraps failures that happened before +// anything was installed, which the command maps to a distinct exit code. +package selfupdate diff --git a/internal/selfupdate/download.go b/internal/selfupdate/download.go new file mode 100644 index 0000000..7dd067e --- /dev/null +++ b/internal/selfupdate/download.go @@ -0,0 +1,200 @@ +package selfupdate + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// maxExtractedSize caps what will be written out of an archive (256 MB). The +// archives are our own release artefacts, so this is a backstop against a +// corrupt zip filling the disk, not a security boundary. +const maxExtractedSize = 256 << 20 + +// Fetch downloads an asset, its checksum and, when published, its Sigstore +// bundle into dir, verifies the asset against the checksum and returns the +// verified archive path and the bundle path ("" when none). +// +// Verification is not optional and happens here, before the artefact reaches +// any install step, so a truncated or tampered download never touches the target. +func Fetch(ctx context.Context, client *Client, tag string, asset Asset, dir string) (archive, bundle string, err error) { + if tag == "" { + return "", "", errors.New("no release tag given to download") + } + + archive = filepath.Join(dir, asset.Name) + checksum := filepath.Join(dir, asset.ChecksumName) + + if err := client.Download(ctx, tag, asset.Name, archive); err != nil { + return "", "", err + } + + if err := client.Download(ctx, tag, asset.ChecksumName, checksum); err != nil { + return "", "", err + } + + if asset.BundleName != "" { + bundle = filepath.Join(dir, asset.BundleName) + if err := client.Download(ctx, tag, asset.BundleName, bundle); err != nil { + if _, ok := errors.AsType[*AssetNotFoundError](err); !ok { + return "", "", err + } + // A release without a bundle is still installable; the signature step reports it. + bundle = "" + } + } + + expected, err := readChecksumFile(checksum) + if err != nil { + return "", "", err + } + + actual, err := fileSHA256(archive) + if err != nil { + return "", "", err + } + + if !strings.EqualFold(expected, actual) { + return "", "", &ChecksumMismatchError{Asset: asset.Name, Expected: expected, Actual: actual} + } + + return archive, bundle, nil +} + +// readChecksumFile reads a published `.sha256` file. The pipeline writes a +// bare hash; the conventional shasum form " " is accepted too. +func readChecksumFile(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading the published checksum: %w", err) + } + + fields := strings.Fields(string(content)) + if len(fields) == 0 { + return "", fmt.Errorf("the published checksum %s is empty", filepath.Base(path)) + } + + sum := fields[0] + if len(sum) != sha256.Size*2 { + return "", fmt.Errorf("the published checksum %s is not a SHA-256 digest: %q", filepath.Base(path), sum) + } + + if _, err := hex.DecodeString(sum); err != nil { + return "", fmt.Errorf("the published checksum %s is not hexadecimal: %q", filepath.Base(path), sum) + } + + return sum, nil +} + +// fileSHA256 hashes a file, streaming. +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("opening the download to verify it: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("hashing the download: %w", err) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// ExtractBinary extracts the executable from a release zip into destDir and +// returns its path. innerName is the expected filename; when it is absent and +// the archive holds exactly one file, that file is used, so a rename in the +// release pipeline does not break updating outright. +func ExtractBinary(archivePath, innerName, destDir string) (extracted string, err error) { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return "", fmt.Errorf("opening %s: %w", filepath.Base(archivePath), err) + } + defer func() { + if closeErr := reader.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("closing %s: %w", filepath.Base(archivePath), closeErr) + } + }() + + entry, err := pickBinaryEntry(reader.File, innerName, filepath.Base(archivePath)) + if err != nil { + return "", err + } + + // The destination is destDir plus the entry's base name only, never its + // path, which could contain ".." and escape destDir (zip slip). + destPath := filepath.Join(destDir, filepath.Base(entry.Name)) + + if err := writeZipEntry(entry, destPath); err != nil { + return "", err + } + + return destPath, nil +} + +// pickBinaryEntry chooses which archive entry is the executable. +func pickBinaryEntry(files []*zip.File, innerName, archiveName string) (*zip.File, error) { + var regular []*zip.File + + for _, f := range files { + if f.FileInfo().IsDir() { + continue + } + if innerName != "" && filepath.Base(f.Name) == innerName { + return f, nil + } + regular = append(regular, f) + } + + switch len(regular) { + case 0: + return nil, fmt.Errorf("%s contains no files", archiveName) + case 1: + return regular[0], nil + default: + names := make([]string, 0, len(regular)) + for _, f := range regular { + names = append(names, f.Name) + } + + return nil, fmt.Errorf("%s does not contain %q and holds several files, so the executable is ambiguous: %s", + archiveName, innerName, strings.Join(names, ", ")) + } +} + +// writeZipEntry writes one archive entry to destPath, executable. +func writeZipEntry(entry *zip.File, destPath string) error { + src, err := entry.Open() + if err != nil { + return fmt.Errorf("reading %s from the archive: %w", entry.Name, err) + } + defer src.Close() + + dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("creating %s: %w", destPath, err) + } + + written, copyErr := io.Copy(dest, io.LimitReader(src, maxExtractedSize+1)) + closeErr := dest.Close() + + if copyErr != nil { + return errors.Join(fmt.Errorf("extracting %s: %w", entry.Name, copyErr), closeErr, os.Remove(destPath)) + } + if closeErr != nil { + return fmt.Errorf("closing %s: %w", destPath, closeErr) + } + if written > maxExtractedSize { + return errors.Join(fmt.Errorf("%s is larger than the %d byte extraction limit", entry.Name, int64(maxExtractedSize)), os.Remove(destPath)) + } + + return nil +} diff --git a/internal/selfupdate/download_test.go b/internal/selfupdate/download_test.go new file mode 100644 index 0000000..97d7328 --- /dev/null +++ b/internal/selfupdate/download_test.go @@ -0,0 +1,163 @@ +package selfupdate + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFetchVerifiesChecksum(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addArchive("v0.3.0", "machineid-linux-amd64.zip", "machineid-linux-amd64.sha256", "machineid", []byte("binary")) + c := newTestClient(t, g) + asset := mustAsset(t, "linux", "amd64") + + archive, bundle, err := Fetch(context.Background(), c, "v0.3.0", asset, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if filepath.Base(archive) != asset.Name { + t.Errorf("archive = %s", archive) + } + if bundle != "" { + t.Errorf("no bundle was published, got %q", bundle) + } +} + +func TestFetchDownloadsBundleWhenPublished(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addArchive("v0.3.0", "machineid-linux-amd64.zip", "machineid-linux-amd64.sha256", "machineid", []byte("binary")) + g.addAsset("v0.3.0", "machineid-linux-amd64.sigstore.json", []byte("{}")) + c := newTestClient(t, g) + asset := mustAsset(t, "linux", "amd64") + + _, bundle, err := Fetch(context.Background(), c, "v0.3.0", asset, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if filepath.Base(bundle) != asset.BundleName { + t.Errorf("bundle = %q", bundle) + } +} + +func TestFetchChecksumMismatch(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addAsset("v0.3.0", "machineid-linux-amd64.zip", makeZip("machineid", []byte("binary"))) + g.addAsset("v0.3.0", "machineid-linux-amd64.sha256", []byte(strings.Repeat("0", 64))) + c := newTestClient(t, g) + asset := mustAsset(t, "linux", "amd64") + + _, _, err := Fetch(context.Background(), c, "v0.3.0", asset, t.TempDir()) + if _, ok := errors.AsType[*ChecksumMismatchError](err); !ok { + t.Fatalf("error = %v, want ChecksumMismatchError", err) + } + if RemedyOf(err) == "" { + t.Error("expected a remedy") + } +} + +func TestReadChecksumFileForms(t *testing.T) { + dir := t.TempDir() + sum := strings.Repeat("ab", 32) + + for name, content := range map[string]string{ + "bare": sum + "\n", + "shasum": sum + " machineid-linux-amd64.zip\n", + } { + p := filepath.Join(dir, name) + _ = os.WriteFile(p, []byte(content), 0o600) + got, err := readChecksumFile(p) + if err != nil || got != sum { + t.Errorf("%s: got %q, %v", name, got, err) + } + } + + for name, content := range map[string]string{ + "empty": "", + "short": "abcd\n", + "nonhex": strings.Repeat("zz", 32), + } { + p := filepath.Join(dir, name) + _ = os.WriteFile(p, []byte(content), 0o600) + if _, err := readChecksumFile(p); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func TestExtractBinary(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "a.zip") + _ = os.WriteFile(archive, makeZip("machineid", []byte("hello")), 0o600) + + out, err := ExtractBinary(archive, "machineid", dir) + if err != nil { + t.Fatal(err) + } + got := mustRead(t, out) + if string(got) != "hello" { + t.Errorf("extracted %q", got) + } + info := mustStat(t, out) + if info.Mode().Perm()&0o100 == 0 { + t.Error("extracted binary should be executable") + } +} + +func TestExtractBinaryFallsBackToSingleFile(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "a.zip") + _ = os.WriteFile(archive, makeZip("machineid-linux-amd64", []byte("x")), 0o600) + + out, err := ExtractBinary(archive, "machineid", dir) + if err != nil { + t.Fatal(err) + } + if filepath.Base(out) != "machineid-linux-amd64" { + t.Errorf("out = %s", out) + } +} + +func TestExtractBinaryAmbiguous(t *testing.T) { + dir := t.TempDir() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, n := range []string{"a", "b"} { + f, err := zw.Create(n) + mustOK(t, err) + _, err = f.Write([]byte(n)) + mustOK(t, err) + } + mustOK(t, zw.Close()) + archive := filepath.Join(dir, "a.zip") + _ = os.WriteFile(archive, buf.Bytes(), 0o600) + + if _, err := ExtractBinary(archive, "machineid", dir); err == nil { + t.Fatal("expected ambiguity error") + } +} + +func TestExtractBinaryZipSlip(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "dest") + mustOK(t, os.Mkdir(dest, 0o755)) + + archive := filepath.Join(dir, "evil.zip") + _ = os.WriteFile(archive, makeZip("../../escaped", []byte("x")), 0o600) + + out, err := ExtractBinary(archive, "machineid", dest) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(out) != dest { + t.Errorf("entry escaped the destination: %s", out) + } + if _, err := os.Stat(filepath.Join(dir, "escaped")); err == nil { + t.Error("a file was written outside the destination") + } +} diff --git a/internal/selfupdate/errors.go b/internal/selfupdate/errors.go new file mode 100644 index 0000000..0306730 --- /dev/null +++ b/internal/selfupdate/errors.go @@ -0,0 +1,268 @@ +package selfupdate + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// Error is a failure that knows what the user should do next. +// +// The value of an update command is that it says what to do, so the guidance +// lives with the failure instead of being reconstructed by every caller. +type Error interface { + error + Remedy() string +} + +// RemedyOf returns the remedy carried by err or any error it wraps, or "". +func RemedyOf(err error) string { + if e, ok := errors.AsType[Error](err); ok { + return e.Remedy() + } + + return "" +} + +// PrerequisiteError marks a failure that happened before anything was +// installed. The command maps it to a different exit code from an update that +// was attempted and broke, so a script can tell "fix and retry" from "damage +// may need attention". +type PrerequisiteError struct { + Err error +} + +func (e *PrerequisiteError) Error() string { return e.Err.Error() } + +// Unwrap exposes the underlying failure, which carries the remedy. +func (e *PrerequisiteError) Unwrap() error { return e.Err } + +// IsPrerequisite reports whether err is a prerequisite failure, so nothing was installed. +func IsPrerequisite(err error) bool { + _, ok := errors.AsType[*PrerequisiteError](err) + + return ok +} + +// NotComparableError is returned when the running version is not a release +// version and so cannot be ordered against one. +type NotComparableError struct { + Version string +} + +func (e *NotComparableError) Error() string { + return fmt.Sprintf("the running version %q is not a release version, so it cannot be compared with a release", e.Version) +} + +func (e *NotComparableError) Remedy() string { + return "Name the release to install explicitly, e.g. " + ToolName + " update -version v0.3.0" +} + +// UnsupportedPlatformError is returned when no release asset exists for the platform. +type UnsupportedPlatformError struct { + GOOS string + GOARCH string +} + +func (e *UnsupportedPlatformError) Error() string { + return fmt.Sprintf("no release asset is published for %s/%s", e.GOOS, e.GOARCH) +} + +func (e *UnsupportedPlatformError) Remedy() string { + return "Rebuild from source instead: " + ToolName + " update -method go" +} + +// MissingToolError is returned when a required external command is not on PATH. +type MissingToolError struct { + Tool string + Purpose string + Install string +} + +func (e *MissingToolError) Error() string { + return fmt.Sprintf("%s is not installed (%s)", e.Tool, e.Purpose) +} + +func (e *MissingToolError) Remedy() string { return e.Install } + +// NoConnectivityError is returned when GitHub could not be reached. +type NoConnectivityError struct { + Err error + URL string +} + +func (e *NoConnectivityError) Error() string { + return fmt.Sprintf("cannot reach %s: %v", e.URL, e.Err) +} + +func (e *NoConnectivityError) Unwrap() error { return e.Err } + +func (e *NoConnectivityError) Remedy() string { + return "Check your network connection or proxy settings; github.com must be reachable over HTTPS." +} + +// RateLimitedError is returned when GitHub asked us to back off. +type RateLimitedError struct { + RetryAt time.Time + Status int +} + +func (e *RateLimitedError) Error() string { + return fmt.Sprintf("github.com answered HTTP %d (rate limited); retry after %s", e.Status, e.RetryAt.Local().Format(time.Kitchen)) +} + +func (e *RateLimitedError) Remedy() string { + return "Wait until " + e.RetryAt.Local().Format(time.Kitchen) + " and run " + ToolName + " update again. Use -check in the meantime; it serves the cached answer." +} + +// BudgetExhaustedError is returned when the local hourly lookup budget is spent. +type BudgetExhaustedError struct { + NextAt time.Time + Limit int + Window time.Duration +} + +func (e *BudgetExhaustedError) Error() string { + return fmt.Sprintf("the local limit of %d update checks per %s is reached; the next live check is allowed at %s", + e.Limit, e.Window, e.NextAt.Local().Format(time.Kitchen)) +} + +func (e *BudgetExhaustedError) Remedy() string { + return "Run " + ToolName + " update -check to see the cached result, or wait until " + e.NextAt.Local().Format(time.Kitchen) + + ". The limit protects github.com from repeated checks; " + EnvBudget + " raises it if you must." +} + +// ReleaseNotFoundError is returned when a named tag does not exist. +type ReleaseNotFoundError struct { + Tag string +} + +func (e *ReleaseNotFoundError) Error() string { + return fmt.Sprintf("release %q was not found in github.com/%s", e.Tag, Repository) +} + +func (e *ReleaseNotFoundError) Remedy() string { + return "See the published releases at https://github.com/" + Repository + "/releases and pass one of those tags to -version." +} + +// AssetNotFoundError is returned when a release does not carry the expected asset. +type AssetNotFoundError struct { + Asset string + Tag string +} + +func (e *AssetNotFoundError) Error() string { + return fmt.Sprintf("release %s does not carry %s", e.Tag, e.Asset) +} + +func (e *AssetNotFoundError) Remedy() string { + return "That release was published without this platform's asset. Pick another with -version, or rebuild from source with -method go." +} + +// ChecksumMismatchError is returned when a download does not match its published SHA-256. +type ChecksumMismatchError struct { + Asset string + Expected string + Actual string +} + +func (e *ChecksumMismatchError) Error() string { + return fmt.Sprintf("%s does not match its published SHA-256 (expected %s…, got %s…); nothing was installed", + e.Asset, prefix(e.Expected, 12), prefix(e.Actual, 12)) +} + +func (e *ChecksumMismatchError) Remedy() string { + return "The download is corrupt or tampered with. Retry; if it persists, report it at https://github.com/" + Repository + "/issues." +} + +// SignatureError is returned when a signature check ran and failed, or was +// required and could not run. +type SignatureError struct { + Asset string + Tool string + Detail string +} + +func (e *SignatureError) Error() string { + return fmt.Sprintf("signature verification of %s with %s failed: %s; nothing was installed", e.Asset, e.Tool, e.Detail) +} + +func (e *SignatureError) Remedy() string { + if e.Tool == "cosign" { + return "Install cosign (https://docs.sigstore.dev/cosign/system_config/installation/) to verify Linux releases, or drop -require-signature to rely on the SHA-256 only." + } + + return "Do not install this artefact. Download it again; if it still fails, report it at https://github.com/" + Repository + "/issues." +} + +// NeedsRootError is returned when the install step requires root and the +// process does not have it. The update never escalates on its own. +type NeedsRootError struct { + Reason string + Command string +} + +func (e *NeedsRootError) Error() string { + return "root privileges are required: " + e.Reason +} + +func (e *NeedsRootError) Remedy() string { + return "Re-run as root: sudo " + e.Command +} + +// NotWritableError is returned when the directory holding the binary cannot be written. +type NotWritableError struct { + Err error + Dir string +} + +func (e *NotWritableError) Error() string { + return fmt.Sprintf("%s is not writable: %v", e.Dir, e.Err) +} + +func (e *NotWritableError) Unwrap() error { return e.Err } + +func (e *NotWritableError) Remedy() string { + return "Re-run with enough privileges to write " + e.Dir + " (for example: sudo " + ToolName + " update -yes)." +} + +// WrongInstallLocationError is returned when the chosen method would install +// somewhere other than where the running binary lives. +type WrongInstallLocationError struct { + Running string + Destination string + Alternative string // the flag that would target Running, or "" +} + +func (e *WrongInstallLocationError) Error() string { + return fmt.Sprintf("this method installs to %s, but the binary you are running is %s", e.Destination, e.Running) +} + +func (e *WrongInstallLocationError) Remedy() string { + if e.Alternative != "" { + return "Use " + ToolName + " update " + e.Alternative + " to update the copy you are running, or -force to install to " + e.Destination + " anyway." + } + + return "Nothing installs to " + e.Running + ". Pass -force to install to " + e.Destination + " and then use that copy, or replace the file by hand." +} + +// prefix returns the first n characters of s. +func prefix(s string, n int) string { + if len(s) <= n { + return s + } + + return s[:n] +} + +// firstLine returns the first non-empty line of s, trimmed. +func firstLine(s string) string { + for line := range strings.SplitSeq(s, "\n") { + if t := strings.TrimSpace(line); t != "" { + return t + } + } + + return "" +} diff --git a/internal/selfupdate/fake_test.go b/internal/selfupdate/fake_test.go new file mode 100644 index 0000000..bac108d --- /dev/null +++ b/internal/selfupdate/fake_test.go @@ -0,0 +1,255 @@ +package selfupdate + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "sync/atomic" + "testing" +) + +// fakeGitHub serves the three endpoints the client uses, in memory. +type fakeGitHub struct { + mu sync.Mutex + latest string + assets map[string]map[string][]byte // tag -> name -> bytes + latestHeads atomic.Int32 + rateLimit bool + retryAfter string +} + +func newFakeGitHub(latest string) *fakeGitHub { + return &fakeGitHub{latest: latest, assets: map[string]map[string][]byte{}} +} + +func (g *fakeGitHub) addAsset(tag, name string, content []byte) { + g.mu.Lock() + defer g.mu.Unlock() + if g.assets[tag] == nil { + g.assets[tag] = map[string][]byte{} + } + g.assets[tag][name] = content +} + +// addArchive publishes a zip holding one file plus its bare .sha256. +func (g *fakeGitHub) addArchive(tag, name, sumName, inner string, content []byte) { + archive := makeZip(inner, content) + g.addAsset(tag, name, archive) + g.addAsset(tag, sumName, []byte(sha256Hex(archive)+"\n")) +} + +func (g *fakeGitHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + defer g.mu.Unlock() + + const repo = "/slashdevops/machineid" + + if g.rateLimit { + if g.retryAfter != "" { + w.Header().Set("Retry-After", g.retryAfter) + } + w.WriteHeader(http.StatusTooManyRequests) + + return + } + + p := r.URL.Path + switch { + case p == repo+"/releases/latest": + g.latestHeads.Add(1) + if g.latest == "" { + w.WriteHeader(http.StatusNotFound) + + return + } + w.Header().Set("Location", repo+"/releases/tag/"+g.latest) + w.WriteHeader(http.StatusFound) + + case strings.HasPrefix(p, repo+"/releases/tag/"): + tag := strings.TrimPrefix(p, repo+"/releases/tag/") + if _, ok := g.assets[tag]; ok || tag == g.latest { + w.WriteHeader(http.StatusOK) + + return + } + w.WriteHeader(http.StatusNotFound) + + case strings.HasPrefix(p, repo+"/releases/download/"): + rest := strings.TrimPrefix(p, repo+"/releases/download/") + tag, name, ok := strings.Cut(rest, "/") + if !ok || g.assets[tag] == nil || g.assets[tag][name] == nil { + w.WriteHeader(http.StatusNotFound) + + return + } + w.Header().Set("Location", "/objects/"+tag+"/"+name) + w.WriteHeader(http.StatusFound) + + case strings.HasPrefix(p, "/objects/"): + rest := strings.TrimPrefix(p, "/objects/") + tag, name, _ := strings.Cut(rest, "/") + content := g.assets[tag][name] + if content == nil { + w.WriteHeader(http.StatusNotFound) + + return + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write(content); err != nil { + return + } + + default: + w.WriteHeader(http.StatusNotFound) + } +} + +// newTestClient wires a Client to an in-memory fake GitHub. +func newTestClient(t *testing.T, g *fakeGitHub) *Client { + t.Helper() + srv := httptest.NewTestServer(t, g) + + return &Client{ + HTTP: srv.Client(), + BaseURL: srv.URL, + UserAgent: "machineid/test", + AllowHost: func(string) bool { return true }, + } +} + +func makeZip(inner string, content []byte) []byte { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + f, err := zw.Create(inner) + if err != nil { + panic(err) + } + if _, err := f.Write(content); err != nil { + panic(err) + } + if err := zw.Close(); err != nil { + panic(err) + } + + return buf.Bytes() +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + + return hex.EncodeToString(sum[:]) +} + +// fakeExec answers commands from a table keyed by "name arg1 arg2…" prefix. +type fakeExec struct { + mu sync.Mutex + answers map[string]ExecResult + errs map[string]error + calls []string +} + +func newFakeExec() *fakeExec { + return &fakeExec{answers: map[string]ExecResult{}, errs: map[string]error{}} +} + +func (f *fakeExec) on(prefix string, res ExecResult) { f.answers[prefix] = res } +func (f *fakeExec) fail(prefix string, err error) { f.errs[prefix] = err } + +func (f *fakeExec) run(_ context.Context, name string, args ...string) (ExecResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + + line := strings.Join(append([]string{name}, args...), " ") + f.calls = append(f.calls, line) + + // Longest matching prefix wins, so a specific answer can be overridden by + // a shorter, later one only when it is the longest match. + if prefix := longestPrefix(f.errs, line); prefix != "" { + return ExecResult{}, f.errs[prefix] + } + if prefix := longestPrefix(f.answers, line); prefix != "" { + return f.answers[prefix], nil + } + + return ExecResult{ExitCode: 127, Stderr: fmt.Sprintf("fake: no answer for %q", line)}, nil +} + +func (f *fakeExec) called(prefix string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + + return false +} + +// longestPrefix returns the longest key of m that prefixes line, or "". +func longestPrefix[V any](m map[string]V, line string) string { + best := "" + for prefix := range m { + if strings.HasPrefix(line, prefix) && len(prefix) > len(best) { + best = prefix + } + } + + return best +} + +// mustOK fails the test on err. +func mustOK(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + return b +} + +func mustStat(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + return info +} + +func mustReadDir(t *testing.T, dir string) []os.DirEntry { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + return entries +} + +func mustAsset(t *testing.T, goos, goarch string) Asset { + t.Helper() + a, err := AssetFor(goos, goarch) + if err != nil { + t.Fatal(err) + } + + return a +} diff --git a/internal/selfupdate/install.go b/internal/selfupdate/install.go new file mode 100644 index 0000000..acd5a8a --- /dev/null +++ b/internal/selfupdate/install.go @@ -0,0 +1,154 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// installerTimeout bounds `installer -pkg`; it verifies the signature and +// updates the receipt database, so it is not instant. +const installerTimeout = 5 * time.Minute + +// goInstallTimeout bounds `go install`, which may populate a cold module cache. +const goInstallTimeout = 15 * time.Minute + +// backupSuffix is appended to a running Windows executable before it is +// replaced: Windows refuses to overwrite a running .exe but allows renaming it. +const backupSuffix = ".old" + +// ReplaceBinary atomically replaces the executable at target with source. +// +// The new binary is staged in target's own directory (os.Rename cannot cross +// filesystems, and /tmp often is one), made executable, then renamed over the +// target in one step. A failure leaves the old binary untouched and removes +// the staging file. The running process keeps its open inode; the next +// invocation is the new build. +func ReplaceBinary(source, target string) error { + dir := filepath.Dir(target) + + staged, err := os.CreateTemp(dir, "."+filepath.Base(target)+".new-*") + if err != nil { + return fmt.Errorf("staging the new binary next to %s: %w", target, err) + } + stagedPath := staged.Name() + + cleanup := func(cause error) error { + return errors.Join(cause, os.Remove(stagedPath)) + } + + src, err := os.Open(source) + if err != nil { + return cleanup(errors.Join(fmt.Errorf("opening the downloaded binary: %w", err), staged.Close())) + } + + _, copyErr := io.Copy(staged, src) + closeErrs := errors.Join(src.Close(), staged.Close()) + + if copyErr != nil { + return cleanup(errors.Join(fmt.Errorf("writing the new binary: %w", copyErr), closeErrs)) + } + if closeErrs != nil { + return cleanup(closeErrs) + } + + if err := os.Chmod(stagedPath, 0o755); err != nil { + return cleanup(fmt.Errorf("making the new binary executable: %w", err)) + } + + if runtime.GOOS == "windows" { + if err := moveAsideRunning(target); err != nil { + return cleanup(err) + } + } + + if err := os.Rename(stagedPath, target); err != nil { + return cleanup(fmt.Errorf("replacing %s: %w", target, err)) + } + + return nil +} + +// moveAsideRunning renames target to target.old so it can be replaced while +// it is executing (Windows only). The stale copy is removed by CleanStaleBackup +// on the next run. +func moveAsideRunning(target string) error { + backup := target + backupSuffix + _ = os.Remove(backup) + + if err := os.Rename(target, backup); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("moving the running binary aside: %w", err) + } + + return nil +} + +// CleanStaleBackup removes a leftover .old from a previous Windows update. +func CleanStaleBackup(target string) { + _ = os.Remove(target + backupSuffix) +} + +// InstallPkg installs a macOS package with the system installer. It always +// writes PkgInstallDir and needs root; the caller establishes both. +func InstallPkg(ctx context.Context, run Exec, pkgPath string) error { + ctx, cancel := context.WithTimeout(ctx, installerTimeout) + defer cancel() + + res, err := run(ctx, "installer", "-pkg", pkgPath, "-target", "/") + if err != nil { + return fmt.Errorf("running the macOS installer: %w", err) + } + if res.ExitCode != 0 { + return fmt.Errorf("the macOS installer failed: %s", firstLine(res.Text())) + } + + return nil +} + +// GoInstall rebuilds the CLI from source. version "" means "latest". +func GoInstall(ctx context.Context, run Exec, version string) error { + ctx, cancel := context.WithTimeout(ctx, goInstallTimeout) + defer cancel() + + if version == "" { + version = "latest" + } + + res, err := run(ctx, "go", "install", CommandPath+"@"+version) + if err != nil { + return fmt.Errorf("running go install: %w", err) + } + if res.ExitCode != 0 { + return fmt.Errorf("go install failed: %s", firstLine(res.Text())) + } + + return nil +} + +// ConfirmVersion runs the installed binary and returns the version it reports. +// It is the only check that the update actually took effect. +func ConfirmVersion(ctx context.Context, run Exec, binary string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, lookupTimeout) + defer cancel() + + res, err := run(ctx, binary, "-version") + if err != nil { + return "", fmt.Errorf("running the updated binary: %w", err) + } + if res.ExitCode != 0 { + return "", fmt.Errorf("the updated binary did not report its version: %s", firstLine(res.Text())) + } + + fields := strings.Fields(firstLine(res.Stdout)) + if len(fields) == 0 { + return "", errors.New("the updated binary printed nothing for -version") + } + + return fields[len(fields)-1], nil +} diff --git a/internal/selfupdate/install_test.go b/internal/selfupdate/install_test.go new file mode 100644 index 0000000..3d4d836 --- /dev/null +++ b/internal/selfupdate/install_test.go @@ -0,0 +1,128 @@ +package selfupdate + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestReplaceBinaryAtomic(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "machineid") + source := filepath.Join(dir, "new") + _ = os.WriteFile(target, []byte("old"), 0o755) + _ = os.WriteFile(source, []byte("new"), 0o600) + + if err := ReplaceBinary(source, target); err != nil { + t.Fatal(err) + } + + got := mustRead(t, target) + if string(got) != "new" { + t.Errorf("target = %q", got) + } + if info := mustStat(t, target); runtime.GOOS != "windows" && info.Mode().Perm() != 0o755 { + t.Errorf("mode = %o", info.Mode().Perm()) + } + + entries := mustReadDir(t, dir) + for _, e := range entries { + if e.Name() != "machineid" && e.Name() != "new" && e.Name() != "machineid.old" { + t.Errorf("staging file left behind: %s", e.Name()) + } + } +} + +func TestReplaceBinaryFailureLeavesTargetUntouched(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "machineid") + _ = os.WriteFile(target, []byte("old"), 0o755) + + err := ReplaceBinary(filepath.Join(dir, "does-not-exist"), target) + if err == nil { + t.Fatal("expected an error") + } + + got := mustRead(t, target) + if string(got) != "old" { + t.Errorf("target changed to %q", got) + } + entries := mustReadDir(t, dir) + if len(entries) != 1 { + t.Errorf("expected only the target to remain, found %d entries", len(entries)) + } +} + +func TestCleanStaleBackup(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "machineid") + _ = os.WriteFile(target+backupSuffix, []byte("x"), 0o600) + CleanStaleBackup(target) + if _, err := os.Stat(target + backupSuffix); !errors.Is(err, os.ErrNotExist) { + t.Error("backup should be removed") + } + CleanStaleBackup(target) // idempotent +} + +func TestInstallPkg(t *testing.T) { + fe := newFakeExec() + fe.on("installer -pkg /tmp/x.pkg -target /", ExecResult{}) + if err := InstallPkg(context.Background(), fe.run, "/tmp/x.pkg"); err != nil { + t.Fatal(err) + } + + fe.on("installer -pkg /tmp/x.pkg -target /", ExecResult{ExitCode: 1, Stderr: "installer: The install failed"}) + if err := InstallPkg(context.Background(), fe.run, "/tmp/x.pkg"); err == nil { + t.Fatal("expected failure") + } +} + +func TestGoInstall(t *testing.T) { + fe := newFakeExec() + fe.on("go install "+CommandPath+"@v0.3.0", ExecResult{}) + fe.on("go install "+CommandPath+"@latest", ExecResult{}) + + if err := GoInstall(context.Background(), fe.run, "v0.3.0"); err != nil { + t.Fatal(err) + } + if err := GoInstall(context.Background(), fe.run, ""); err != nil { + t.Fatal(err) + } + if !fe.called("go install " + CommandPath + "@latest") { + t.Error("empty version should install @latest") + } +} + +func TestConfirmVersion(t *testing.T) { + fe := newFakeExec() + fe.on("/usr/local/bin/machineid -version", ExecResult{Stdout: "machineid v0.3.0\n"}) + + v, err := ConfirmVersion(context.Background(), fe.run, "/usr/local/bin/machineid") + if err != nil || v != "v0.3.0" { + t.Errorf("ConfirmVersion = %q, %v", v, err) + } + + fe.on("/bad -version", ExecResult{ExitCode: 1, Stderr: "boom"}) + if _, err := ConfirmVersion(context.Background(), fe.run, "/bad"); err == nil { + t.Error("expected error") + } +} + +func TestDefaultExec(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("needs a POSIX shell") + } + res, err := DefaultExec(context.Background(), "sh", "-c", "echo out; echo err >&2; exit 3") + if err != nil { + t.Fatal(err) + } + if res.Stdout != "out" || res.Stderr != "err" || res.ExitCode != 3 { + t.Errorf("res = %+v", res) + } + if _, err := DefaultExec(context.Background(), "definitely-not-a-command-xyz"); err == nil { + t.Error("a missing command should be an error, not an exit code") + } +} diff --git a/internal/selfupdate/lookup.go b/internal/selfupdate/lookup.go new file mode 100644 index 0000000..b01e93b --- /dev/null +++ b/internal/selfupdate/lookup.go @@ -0,0 +1,302 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Timeouts for the two kinds of request. +const ( + lookupTimeout = 20 * time.Second + downloadTimeout = 10 * time.Minute +) + +// Client talks to GitHub's web endpoints for a public repository. +// +// Nothing here uses the REST API: the newest tag is read from the redirect +// that github.com//releases/latest returns, a tag's existence from the +// status of its release page, and assets from releases/download//. +// None of those carry the API's 60-requests-per-hour unauthenticated quota, +// and none need a token, which a public binary could not keep secret anyway. +type Client struct { + // HTTP performs requests. Nil uses a client with the package's timeouts. + HTTP *http.Client + + // BaseURL is the GitHub origin, "https://github.com" by default. + // Tests point it at an in-memory server. + BaseURL string + + // UserAgent identifies this tool to GitHub. + UserAgent string + + // AllowHost decides which hosts a download may be redirected to. + // Nil allows github.com and *.githubusercontent.com. + AllowHost func(host string) bool + + // Repo overrides Repository, for tests. + Repo string +} + +// NewClient returns a client identifying itself with the running version. +func NewClient(version string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: downloadTimeout}, + BaseURL: "https://github.com", + UserAgent: fmt.Sprintf("%s/%s (+https://github.com/%s)", ToolName, version, Repository), + } +} + +func (c *Client) repo() string { + if c.Repo != "" { + return c.Repo + } + + return Repository +} + +func (c *Client) base() string { + if c.BaseURL != "" { + return strings.TrimRight(c.BaseURL, "/") + } + + return "https://github.com" +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + + return http.DefaultClient +} + +func (c *Client) allowHost(host string) bool { + if c.AllowHost != nil { + return c.AllowHost(host) + } + + host = strings.ToLower(host) + if h, _, err := splitHostPort(host); err == nil { + host = h + } + + return host == "github.com" || strings.HasSuffix(host, ".githubusercontent.com") +} + +// splitHostPort tolerates a missing port. +func splitHostPort(hostport string) (string, string, error) { + i := strings.LastIndexByte(hostport, ':') + if i < 0 || strings.Contains(hostport[i:], "]") { + return hostport, "", errors.New("no port") + } + + return hostport[:i], hostport[i+1:], nil +} + +// LatestURL is the page that redirects to the newest release. +func (c *Client) LatestURL() string { + return c.base() + "/" + c.repo() + "/releases/latest" +} + +// TagURL is a release's page. +func (c *Client) TagURL(tag string) string { + return c.base() + "/" + c.repo() + "/releases/tag/" + url.PathEscape(tag) +} + +// AssetURL is the download URL of one asset of one release. +func (c *Client) AssetURL(tag, name string) string { + return c.base() + "/" + c.repo() + "/releases/download/" + url.PathEscape(tag) + "/" + url.PathEscape(name) +} + +// Latest returns the tag GitHub marks as the latest release. +// +// That is deliberately not "the highest tag": which release users should move +// to is a decision the release process makes when it sets the marker. +func (c *Client) Latest(ctx context.Context) (string, error) { + resp, err := c.head(ctx, c.LatestURL()) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if err := c.rateLimited(resp); err != nil { + return "", err + } + + if resp.StatusCode < 300 || resp.StatusCode > 399 { + return "", fmt.Errorf("%s answered HTTP %d instead of redirecting to the latest release", c.LatestURL(), resp.StatusCode) + } + + loc, err := resp.Location() + if err != nil { + return "", fmt.Errorf("%s redirected without a Location header", c.LatestURL()) + } + + tag, ok := tagFromReleasePath(loc.Path) + if !ok { + return "", fmt.Errorf("unexpected redirect target %s", loc) + } + + return tag, nil +} + +// tagFromReleasePath extracts from ".../releases/tag/". +func tagFromReleasePath(p string) (string, bool) { + const marker = "/releases/tag/" + _, after, ok := strings.Cut(p, marker) + if !ok { + return "", false + } + + tag, err := url.PathUnescape(after) + if err != nil || tag == "" || strings.Contains(tag, "/") { + return "", false + } + + return tag, true +} + +// TagExists reports whether a release page exists for tag. +func (c *Client) TagExists(ctx context.Context, tag string) (bool, error) { + return c.exists(ctx, c.TagURL(tag)) +} + +// AssetExists reports whether a release carries the named asset, without +// downloading it. The download URL answers with a redirect when the asset +// exists and 404 when it does not. +func (c *Client) AssetExists(ctx context.Context, tag, name string) (bool, error) { + return c.exists(ctx, c.AssetURL(tag, name)) +} + +func (c *Client) exists(ctx context.Context, u string) (bool, error) { + resp, err := c.head(ctx, u) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if err := c.rateLimited(resp); err != nil { + return false, err + } + + switch { + case resp.StatusCode == http.StatusNotFound: + return false, nil + case resp.StatusCode >= 200 && resp.StatusCode < 400: + return true, nil + default: + return false, fmt.Errorf("%s answered HTTP %d", u, resp.StatusCode) + } +} + +// head performs a HEAD request without following redirects; the caller reads +// the status and Location itself. +func (c *Client) head(ctx context.Context, u string) (*http.Response, error) { + ctx, cancel := context.WithTimeout(ctx, lookupTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.UserAgent) + + client := *c.httpClient() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + resp, err := client.Do(req) + if err != nil { + return nil, &NoConnectivityError{URL: u, Err: err} + } + + return resp, nil +} + +// rateLimited turns a 429 or a rate-limit 403 into a RateLimitedError. +func (c *Client) rateLimited(resp *http.Response) error { + if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusForbidden { + return nil + } + + retryAt := time.Now().Add(time.Hour) + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + retryAt = time.Now().Add(time.Duration(secs) * time.Second) + } else if t, err := http.ParseTime(ra); err == nil { + retryAt = t + } + } else if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { + if secs, err := strconv.ParseInt(reset, 10, 64); err == nil { + retryAt = time.Unix(secs, 0) + } + } + + return &RateLimitedError{Status: resp.StatusCode, RetryAt: retryAt} +} + +// Download fetches one asset of one release into dest, following redirects +// only to allowed hosts and streaming to disk. +func (c *Client) Download(ctx context.Context, tag, name, dest string) error { + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + + u := c.AssetURL(tag, name) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.UserAgent) + + client := *c.httpClient() + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("too many redirects") + } + if !c.allowHost(req.URL.Host) { + return fmt.Errorf("refusing to follow a redirect to %s", req.URL.Host) + } + + return nil + } + + resp, err := client.Do(req) + if err != nil { + return &NoConnectivityError{URL: u, Err: err} + } + defer resp.Body.Close() + + if err := c.rateLimited(resp); err != nil { + return err + } + + if resp.StatusCode == http.StatusNotFound { + return &AssetNotFoundError{Asset: name, Tag: tag} + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("downloading %s: HTTP %d", name, resp.StatusCode) + } + + f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("creating %s: %w", dest, err) + } + + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + + if copyErr != nil { + return errors.Join(fmt.Errorf("downloading %s: %w", name, copyErr), closeErr, os.Remove(dest)) + } + + return closeErr +} diff --git a/internal/selfupdate/lookup_test.go b/internal/selfupdate/lookup_test.go new file mode 100644 index 0000000..93a2b41 --- /dev/null +++ b/internal/selfupdate/lookup_test.go @@ -0,0 +1,127 @@ +package selfupdate + +import ( + "context" + "errors" + "path/filepath" + "testing" +) + +func TestClientLatest(t *testing.T) { + g := newFakeGitHub("v0.3.0") + c := newTestClient(t, g) + + tag, err := c.Latest(context.Background()) + if err != nil || tag != "v0.3.0" { + t.Fatalf("Latest = %q, %v", tag, err) + } + if g.latestHeads.Load() != 1 { + t.Errorf("expected exactly one HEAD, got %d", g.latestHeads.Load()) + } +} + +func TestClientLatestNoRelease(t *testing.T) { + c := newTestClient(t, newFakeGitHub("")) + if _, err := c.Latest(context.Background()); err == nil { + t.Fatal("expected an error when there is no latest release") + } +} + +func TestClientTagAndAssetExists(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addAsset("v0.2.0", "machineid-linux-amd64.zip", []byte("zip")) + c := newTestClient(t, g) + ctx := context.Background() + + for tag, want := range map[string]bool{"v0.3.0": true, "v0.2.0": true, "v9.9.9": false} { + ok, err := c.TagExists(ctx, tag) + if err != nil || ok != want { + t.Errorf("TagExists(%s) = %v, %v; want %v", tag, ok, err, want) + } + } + + ok, err := c.AssetExists(ctx, "v0.2.0", "machineid-linux-amd64.zip") + if err != nil || !ok { + t.Errorf("AssetExists(existing) = %v, %v", ok, err) + } + ok, err = c.AssetExists(ctx, "v0.2.0", "machineid-linux-arm64.zip") + if err != nil || ok { + t.Errorf("AssetExists(missing) = %v, %v", ok, err) + } +} + +func TestClientDownloadFollowsRedirectAndWrites(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addAsset("v0.3.0", "machineid-linux-amd64.sha256", []byte("abc\n")) + c := newTestClient(t, g) + + dest := filepath.Join(t.TempDir(), "sum") + if err := c.Download(context.Background(), "v0.3.0", "machineid-linux-amd64.sha256", dest); err != nil { + t.Fatal(err) + } + got := mustRead(t, dest) + if string(got) != "abc\n" { + t.Errorf("downloaded %q", got) + } +} + +func TestClientDownloadMissingAsset(t *testing.T) { + c := newTestClient(t, newFakeGitHub("v0.3.0")) + err := c.Download(context.Background(), "v0.3.0", "nope.zip", filepath.Join(t.TempDir(), "x")) + if _, ok := errors.AsType[*AssetNotFoundError](err); !ok { + t.Fatalf("error = %v, want AssetNotFoundError", err) + } +} + +func TestClientDownloadRefusesForeignRedirect(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.addAsset("v0.3.0", "a.zip", []byte("zip")) + c := newTestClient(t, g) + c.AllowHost = func(string) bool { return false } + + err := c.Download(context.Background(), "v0.3.0", "a.zip", filepath.Join(t.TempDir(), "x")) + if err == nil { + t.Fatal("expected the redirect to be refused") + } +} + +func TestClientRateLimited(t *testing.T) { + g := newFakeGitHub("v0.3.0") + g.rateLimit = true + g.retryAfter = "120" + c := newTestClient(t, g) + + _, err := c.Latest(context.Background()) + rl, ok := errors.AsType[*RateLimitedError](err) + if !ok { + t.Fatalf("error = %v, want RateLimitedError", err) + } + if rl.RetryAt.IsZero() || RemedyOf(err) == "" { + t.Error("RateLimitedError should carry a retry time and a remedy") + } +} + +func TestDefaultAllowHost(t *testing.T) { + c := &Client{} + for host, want := range map[string]bool{ + "github.com": true, + "github.com:443": true, + "objects.githubusercontent.com": true, + "release-assets.githubusercontent.com": true, + "evil.example.com": false, + "github.com.evil.example.com": false, + } { + if got := c.allowHost(host); got != want { + t.Errorf("allowHost(%q) = %v, want %v", host, got, want) + } + } +} + +func TestTagFromReleasePath(t *testing.T) { + if tag, ok := tagFromReleasePath("/slashdevops/machineid/releases/tag/v0.3.0"); !ok || tag != "v0.3.0" { + t.Errorf("got %q, %v", tag, ok) + } + if _, ok := tagFromReleasePath("/slashdevops/machineid/releases"); ok { + t.Error("no tag should not parse") + } +} diff --git a/internal/selfupdate/prereq.go b/internal/selfupdate/prereq.go new file mode 100644 index 0000000..7f7a9ff --- /dev/null +++ b/internal/selfupdate/prereq.go @@ -0,0 +1,360 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Method is how an update is performed. +type Method string + +const ( + // MethodAuto prefers MethodRelease where an asset exists, else MethodGo. + MethodAuto Method = "auto" + // MethodRelease installs the signed asset from the GitHub release. + MethodRelease Method = "release" + // MethodGo rebuilds from source with `go install`. + MethodGo Method = "go" +) + +// Check is one prerequisite probe and its outcome. +type Check struct { + Err error + Label string + Detail string + Essential bool // no method can work around a failure +} + +// OK reports whether the check passed. +func (c Check) OK() bool { return c.Err == nil } + +// Report is everything the probes learned. +type Report struct { + ReleaseErr error + GoBinErr error + Checks []Check + ExecutablePath string + GoBinDir string + ReleaseAsset Asset + IsRoot bool + HasGo bool + HasCosign bool +} + +// FirstEssentialError returns the first failure no method can work around. +func (r Report) FirstEssentialError() error { + for _, c := range r.Checks { + if c.Essential && c.Err != nil { + return c.Err + } + } + + return nil +} + +// Checker probes the machine. Every touchpoint is a field so tests can fake +// a machine that has none of the tools. +type Checker struct { + Exec Exec + Executable func() (string, error) + LookPath func(string) (string, error) + WritableProbe func(dir string) error + Getuid func() int + GOOS string + GOARCH string + CurrentVersion string +} + +// NewChecker returns a checker for this machine. +func NewChecker(currentVersion string, run Exec) *Checker { + return &Checker{ + Exec: run, + Executable: runningExecutable, + LookPath: exec.LookPath, + WritableProbe: probeWritable, + Getuid: os.Getuid, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + CurrentVersion: currentVersion, + } +} + +// runningExecutable resolves the running binary through symlinks. +func runningExecutable() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + + if resolved, err := filepath.EvalSymlinks(p); err == nil { + p = resolved + } + + return p, nil +} + +// probeWritable checks writability by writing, not by reading mode bits: +// ACLs, read-only mounts and protected directories all let a mode check pass +// where a real write fails. +func probeWritable(dir string) error { + f, err := os.CreateTemp(dir, "."+ToolName+"-probe-*") + if err != nil { + return err + } + name := f.Name() + + return errors.Join(f.Close(), os.Remove(name)) +} + +// Run performs the probes relevant to method. +func (c *Checker) Run(ctx context.Context, method Method) Report { + var r Report + + r.Checks = append(r.Checks, c.checkVersion()) + + exe, execCheck := c.checkExecutable() + r.ExecutablePath = exe + r.Checks = append(r.Checks, execCheck) + + asset, err := AssetFor(c.GOOS, c.GOARCH) + r.ReleaseAsset, r.ReleaseErr = asset, err + if method != MethodGo { + check := Check{Label: "platform supported", Detail: c.GOOS + "/" + c.GOARCH, Essential: method == MethodRelease} + if err != nil { + check.Err = err + } + r.Checks = append(r.Checks, check) + } + + if c.Getuid != nil { + r.IsRoot = c.Getuid() == 0 + } + + if method != MethodRelease { + goCheck, goBin := c.checkGo(ctx) + r.HasGo = goCheck.Err == nil + r.GoBinDir = goBin + goCheck.Essential = method == MethodGo + r.Checks = append(r.Checks, goCheck) + + if r.HasGo && goBin != "" { + binCheck := Check{Label: "go bin directory writable", Detail: goBin} + if err := c.WritableProbe(goBin); err != nil && !r.IsRoot { + binCheck.Err = &NotWritableError{Dir: goBin, Err: err} + r.GoBinErr = binCheck.Err + } + r.Checks = append(r.Checks, binCheck) + } + } + + if method != MethodGo && c.GOOS == "linux" && err == nil { + check := Check{Label: "cosign", Detail: "available, Sigstore bundle will be verified"} + if _, lookErr := c.LookPath("cosign"); lookErr != nil { + check.Detail = "not installed, Sigstore verification will be skipped" + } else { + r.HasCosign = true + } + r.Checks = append(r.Checks, check) + } + + return r +} + +func (c *Checker) checkVersion() Check { + check := Check{Label: "current version", Detail: c.CurrentVersion} + if _, ok := ParseVersion(c.CurrentVersion); !ok { + check.Detail = c.CurrentVersion + " (local build, not a release version)" + } + + return check +} + +func (c *Checker) checkExecutable() (string, Check) { + check := Check{Label: "running binary", Essential: true} + + exe, err := c.Executable() + if err != nil { + check.Err = fmt.Errorf("cannot determine the running binary: %w", err) + + return "", check + } + + check.Detail = exe + + return exe, check +} + +// checkGo probes the Go toolchain and where `go install` would write. +func (c *Checker) checkGo(ctx context.Context) (Check, string) { + check := Check{Label: "go toolchain"} + + res, err := c.Exec(ctx, "go", "version") + if err != nil || res.ExitCode != 0 { + check.Err = &MissingToolError{ + Tool: "go", + Purpose: "needed to rebuild from source with -method go", + Install: "Install Go from https://go.dev/dl/ or use -method release.", + } + + return check, "" + } + check.Detail = firstLine(res.Stdout) + + bin, err := c.goBinDir(ctx) + if err != nil { + check.Err = fmt.Errorf("cannot determine where go install writes: %w", err) + + return check, "" + } + + return check, bin +} + +// goBinDir returns GOBIN, or GOPATH/bin when GOBIN is unset. +func (c *Checker) goBinDir(ctx context.Context) (string, error) { + res, err := c.Exec(ctx, "go", "env", "GOBIN", "GOPATH") + if err != nil { + return "", err + } + if res.ExitCode != 0 { + return "", errors.New(firstLine(res.Text())) + } + + lines := strings.Split(strings.ReplaceAll(res.Stdout, "\r\n", "\n"), "\n") + if len(lines) < 2 { + return "", fmt.Errorf("unexpected go env output %q", res.Stdout) + } + + if gobin := strings.TrimSpace(lines[0]); gobin != "" { + return gobin, nil + } + + gopath := strings.TrimSpace(lines[1]) + if gopath == "" { + return "", errors.New("GOPATH is empty") + } + + // GOPATH can be a list; go install uses the first entry. + if i := strings.IndexByte(gopath, filepath.ListSeparator); i >= 0 { + gopath = gopath[:i] + } + + return filepath.Join(gopath, "bin"), nil +} + +// Resolve picks the method to use. +func (c *Checker) Resolve(method Method, r Report) (Method, error) { + switch method { + case MethodRelease: + if r.ReleaseErr != nil { + return "", r.ReleaseErr + } + + return MethodRelease, nil + + case MethodGo: + if !r.HasGo { + return "", missingGo() + } + + return MethodGo, nil + + default: + if r.ReleaseErr == nil { + return MethodRelease, nil + } + if r.HasGo { + return MethodGo, nil + } + + return "", &UnsupportedPlatformError{GOOS: c.GOOS, GOARCH: c.GOARCH} + } +} + +func missingGo() error { + return &MissingToolError{ + Tool: "go", + Purpose: "needed to rebuild from source with -method go", + Install: "Install Go from https://go.dev/dl/ or use -method release.", + } +} + +// CheckInstallTarget confirms the chosen method installs where the running +// binary lives, and that the process may write there. +// +// Neither `installer -pkg` nor `go install` consults the running binary: the +// package always writes PkgInstallDir and go install always writes GOBIN. An +// update landing elsewhere would report success while the copy on PATH +// stays old, so both directions are guarded. force overrides the guard. +func (c *Checker) CheckInstallTarget(method Method, asset Asset, r Report, force bool) Check { + check := Check{Label: "install target"} + running := filepath.Dir(r.ExecutablePath) + + switch { + case method == MethodGo: + check.Detail = r.GoBinDir + if r.GoBinDir == "" { + check.Err = errors.New("go install destination is unknown") + + return check + } + if !sameDir(running, r.GoBinDir) && !force { + alt := "" + if r.ReleaseErr == nil { + alt = "-method release" + } + check.Err = &WrongInstallLocationError{Running: r.ExecutablePath, Destination: r.GoBinDir, Alternative: alt} + + return check + } + if r.GoBinErr != nil { + check.Err = r.GoBinErr + } + + case asset.Kind == KindPkg: + check.Detail = PkgInstallDir + if !sameDir(running, PkgInstallDir) && !force { + alt := "" + if r.HasGo && sameDir(running, r.GoBinDir) { + alt = "-method go" + } + check.Err = &WrongInstallLocationError{Running: r.ExecutablePath, Destination: PkgInstallDir, Alternative: alt} + + return check + } + if !r.IsRoot { + check.Err = &NeedsRootError{ + Reason: "the macOS installer updates the system receipt database", + Command: ToolName + " update -yes", + } + + return check + } + check.Detail += " (running as root)" + + default: // zip, in place + check.Detail = running + " (in place)" + if err := c.WritableProbe(running); err != nil { + check.Err = &NotWritableError{Dir: running, Err: err} + } + } + + return check +} + +// sameDir compares two directories after cleaning, case-insensitively on +// case-insensitive platforms. +func sameDir(a, b string) bool { + a, b = filepath.Clean(a), filepath.Clean(b) + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + + return a == b +} diff --git a/internal/selfupdate/prereq_test.go b/internal/selfupdate/prereq_test.go new file mode 100644 index 0000000..0d640cf --- /dev/null +++ b/internal/selfupdate/prereq_test.go @@ -0,0 +1,186 @@ +package selfupdate + +import ( + "context" + "errors" + "path/filepath" + "testing" +) + +// newTestChecker returns a checker for a fake machine. exe is the running +// binary; gobin is what `go env` reports; uid 0 means root. +func newTestChecker(goos, exe, gobin string, uid int, fe *fakeExec) *Checker { + fe.on("go version", ExecResult{Stdout: "go version go1.27.1 " + goos + "/arm64"}) + fe.on("go env GOBIN GOPATH", ExecResult{Stdout: "\n" + filepath.Dir(gobin) + "\n"}) + + return &Checker{ + Exec: fe.run, + Executable: func() (string, error) { return exe, nil }, + LookPath: func(string) (string, error) { return "", errors.New("not found") }, + WritableProbe: func(string) error { return nil }, + Getuid: func() int { return uid }, + GOOS: goos, + GOARCH: "arm64", + CurrentVersion: "v0.2.0", + } +} + +func TestCheckerRunLinuxAuto(t *testing.T) { + fe := newFakeExec() + c := newTestChecker("linux", "/usr/local/bin/machineid", "/home/u/go/bin", 1000, fe) + + r := c.Run(context.Background(), MethodAuto) + if err := r.FirstEssentialError(); err != nil { + t.Fatal(err) + } + if r.ReleaseErr != nil || !r.HasGo || r.GoBinDir != "/home/u/go/bin" || r.IsRoot || r.HasCosign { + t.Errorf("report = %+v", r) + } + + m, err := c.Resolve(MethodAuto, r) + if err != nil || m != MethodRelease { + t.Errorf("Resolve(auto) = %s, %v", m, err) + } +} + +func TestCheckerLocalBuildIsNotEssential(t *testing.T) { + fe := newFakeExec() + c := newTestChecker("linux", "/tmp/machineid", "/home/u/go/bin", 1000, fe) + c.CurrentVersion = "devel" + + r := c.Run(context.Background(), MethodAuto) + if err := r.FirstEssentialError(); err != nil { + t.Fatalf("a local build must not stop the run: %v", err) + } +} + +func TestCheckerExecutableFailureIsEssential(t *testing.T) { + c := newTestChecker("linux", "", "/home/u/go/bin", 1000, newFakeExec()) + c.Executable = func() (string, error) { return "", errors.New("no exe") } + + r := c.Run(context.Background(), MethodAuto) + if r.FirstEssentialError() == nil { + t.Fatal("expected an essential failure") + } +} + +func TestCheckerResolveFallsBackToGo(t *testing.T) { + fe := newFakeExec() + c := newTestChecker("windows", `C:\Users\u\go\bin\machineid.exe`, `C:\Users\u\go\bin`, 1000, fe) + + r := c.Run(context.Background(), MethodAuto) + if err := r.FirstEssentialError(); err != nil { + t.Fatalf("unsupported release platform must not be essential under auto: %v", err) + } + m, err := c.Resolve(MethodAuto, r) + if err != nil || m != MethodGo { + t.Errorf("Resolve(auto on windows) = %s, %v", m, err) + } + + if _, err := c.Resolve(MethodRelease, r); err == nil { + t.Error("explicit release on windows should fail") + } +} + +func TestCheckerResolveNothingAvailable(t *testing.T) { + fe := newFakeExec() + fe.fail("go version", errors.New("not found")) + c := newTestChecker("windows", `C:\machineid.exe`, `C:\go\bin`, 1000, fe) + c.Exec = fe.run + + r := c.Run(context.Background(), MethodAuto) + if _, err := c.Resolve(MethodAuto, r); err == nil { + t.Fatal("expected an error when neither method works") + } + if _, err := c.Resolve(MethodGo, r); err == nil || RemedyOf(err) == "" { + t.Errorf("explicit go without a toolchain should carry a remedy: %v", err) + } +} + +func TestCheckInstallTargetPkg(t *testing.T) { + fe := newFakeExec() + asset := mustAsset(t, "darwin", "arm64") + + // In place, root: ok. + c := newTestChecker("darwin", "/usr/local/bin/machineid", "/Users/u/go/bin", 0, fe) + r := c.Run(context.Background(), MethodRelease) + if chk := c.CheckInstallTarget(MethodRelease, asset, r, false); !chk.OK() { + t.Errorf("root in /usr/local/bin: %v", chk.Err) + } + + // In place, not root: needs root with a sudo remedy. + c = newTestChecker("darwin", "/usr/local/bin/machineid", "/Users/u/go/bin", 501, fe) + r = c.Run(context.Background(), MethodRelease) + chk := c.CheckInstallTarget(MethodRelease, asset, r, false) + var needsRoot *NeedsRootError + if !errors.As(chk.Err, &needsRoot) || RemedyOf(chk.Err) == "" { + t.Errorf("non-root: %v", chk.Err) + } + + // Elsewhere (a go install copy): wrong location, remedy names -method go. + c = newTestChecker("darwin", "/Users/u/go/bin/machineid", "/Users/u/go/bin", 0, fe) + r = c.Run(context.Background(), MethodAuto) + chk = c.CheckInstallTarget(MethodRelease, asset, r, false) + var wrong *WrongInstallLocationError + if !errors.As(chk.Err, &wrong) || wrong.Alternative != "-method go" { + t.Errorf("go-installed copy: %v", chk.Err) + } + + // Elsewhere with -force: proceeds (root). + if chk := c.CheckInstallTarget(MethodRelease, asset, r, true); !chk.OK() { + t.Errorf("force: %v", chk.Err) + } +} + +func TestCheckInstallTargetGo(t *testing.T) { + fe := newFakeExec() + c := newTestChecker("linux", "/usr/local/bin/machineid", "/home/u/go/bin", 1000, fe) + r := c.Run(context.Background(), MethodGo) + + chk := c.CheckInstallTarget(MethodGo, Asset{}, r, false) + var wrong *WrongInstallLocationError + if !errors.As(chk.Err, &wrong) || wrong.Alternative != "-method release" { + t.Errorf("go method for a /usr/local/bin copy: %v", chk.Err) + } + + c = newTestChecker("linux", "/home/u/go/bin/machineid", "/home/u/go/bin", 1000, fe) + r = c.Run(context.Background(), MethodGo) + if chk := c.CheckInstallTarget(MethodGo, Asset{}, r, false); !chk.OK() { + t.Errorf("go method in GOBIN: %v", chk.Err) + } + + c.WritableProbe = func(string) error { return errors.New("read-only") } + r = c.Run(context.Background(), MethodGo) + chk = c.CheckInstallTarget(MethodGo, Asset{}, r, false) + if _, ok := errors.AsType[*NotWritableError](chk.Err); !ok { + t.Errorf("unwritable GOBIN: %v", chk.Err) + } +} + +func TestCheckInstallTargetZipInPlace(t *testing.T) { + fe := newFakeExec() + asset := mustAsset(t, "linux", "amd64") + c := newTestChecker("linux", "/opt/tools/machineid", "/home/u/go/bin", 1000, fe) + r := c.Run(context.Background(), MethodRelease) + + if chk := c.CheckInstallTarget(MethodRelease, asset, r, false); !chk.OK() { + t.Errorf("writable dir: %v", chk.Err) + } + + c.WritableProbe = func(dir string) error { return errors.New("permission denied") } + chk := c.CheckInstallTarget(MethodRelease, asset, r, false) + var nw *NotWritableError + if !errors.As(chk.Err, &nw) || RemedyOf(chk.Err) == "" { + t.Errorf("unwritable dir: %v", chk.Err) + } +} + +func TestGoBinDirPrefersGOBIN(t *testing.T) { + fe := newFakeExec() + fe.on("go env GOBIN GOPATH", ExecResult{Stdout: "/custom/bin\n/home/u/go\n"}) + c := &Checker{Exec: fe.run} + dir, err := c.goBinDir(context.Background()) + if err != nil || dir != "/custom/bin" { + t.Errorf("got %q, %v", dir, err) + } +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go new file mode 100644 index 0000000..cc0708f --- /dev/null +++ b/internal/selfupdate/updater.go @@ -0,0 +1,438 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "time" +) + +// Options are the choices one update run was given. +type Options struct { + CurrentVersion string + Method Method + Version string // exact tag; "" means latest + Check bool + Refresh bool + Force bool + AssumeYes bool + RequireSignature bool +} + +func (o Options) method() Method { + if o.Method == "" { + return MethodAuto + } + + return o.Method +} + +// Result describes what an update did. +type Result struct { + Method Method + From string + To string + Tag string + Changed bool +} + +// Plan is what an update intends to do, decided before anything is downloaded. +type Plan struct { + Source string // where the tag came from: "live, 4 of 5 checks left this hour" / "cached 12m ago" + Tag string + Method Method + Asset Asset + UpToDate bool +} + +// Updater runs an update end to end. The command is a thin adapter over it. +type Updater struct { + Checker *Checker + Client *Client + Budget *Budget + Exec Exec + + // LookPath finds external tools; nil uses exec.LookPath. + LookPath func(string) (string, error) + + // Out receives the progress narrative; nil discards it. + Out io.Writer + + // Confirm asks the user to proceed; nil means proceed (use AssumeYes to be explicit). + Confirm func(prompt string) (bool, error) + + // TempDir creates the staging directory; nil uses os.MkdirTemp. + TempDir func() (string, error) + + // Logger receives debug detail; nil disables it. + Logger *slog.Logger +} + +// Run performs the update. Cheap checks come first and nothing is downloaded +// until the plan is certain to land where the running binary lives. +func (u *Updater) Run(ctx context.Context, opts Options) (Result, error) { + result := Result{From: opts.CurrentVersion} + + report := u.Checker.Run(ctx, opts.method()) + u.printChecks(report.Checks) + + if err := report.FirstEssentialError(); err != nil { + return result, &PrerequisiteError{Err: err} + } + + if report.ExecutablePath != "" { + CleanStaleBackup(report.ExecutablePath) + } + + method, err := u.Checker.Resolve(opts.method(), report) + if err != nil { + return result, &PrerequisiteError{Err: err} + } + result.Method = method + + plan, err := u.plan(ctx, method, report, opts) + if err != nil { + return result, &PrerequisiteError{Err: err} + } + result.Tag = plan.Tag + + u.printChecks([]Check{{Label: "latest release", Detail: plan.Tag + " (" + plan.Source + ")"}}) + + target := u.Checker.CheckInstallTarget(method, plan.Asset, report, opts.Force) + u.printChecks([]Check{target}) + if !target.OK() { + return result, &PrerequisiteError{Err: target.Err} + } + + if plan.UpToDate && !opts.Force { + u.printf("\nβœ… %s %s is already the latest version\n", ToolName, opts.CurrentVersion) + + return result, nil + } + + u.printf("\nβ†’ %s\n", describePlan(plan, opts.CurrentVersion)) + + if opts.Check { + u.printf(" (-check: nothing was changed)\n") + + return result, nil + } + + if !opts.AssumeYes && u.Confirm != nil { + proceed, err := u.Confirm(fmt.Sprintf("Update %s now?", ToolName)) + if err != nil { + return result, err + } + if !proceed { + u.printf(" cancelled\n") + + return result, nil + } + } + + if err := u.install(ctx, plan, report, opts); err != nil { + return result, err + } + + result.Changed = true + result.To = u.confirmInstalled(ctx, plan, report) + + return result, nil +} + +// plan resolves the tag (through the cache and budget) and names the asset. +func (u *Updater) plan(ctx context.Context, method Method, report Report, opts Options) (Plan, error) { + tag, source, err := u.resolveTag(ctx, opts) + if err != nil { + return Plan{}, err + } + + plan := Plan{Tag: tag, Source: source, Method: method} + + if opts.Version != "" { + plan.UpToDate = SameVersion(opts.CurrentVersion, tag) + } else { + upgrade, err := IsUpgrade(opts.CurrentVersion, tag) + if err != nil { + return Plan{}, err + } + plan.UpToDate = !upgrade + } + + if method == MethodGo { + return plan, nil + } + + plan.Asset = u.chooseReleaseAsset(ctx, report, opts, tag) + + exists, err := u.Client.AssetExists(ctx, tag, plan.Asset.Name) + if err != nil { + return Plan{}, err + } + if !exists { + return Plan{}, &AssetNotFoundError{Asset: plan.Asset.Name, Tag: tag} + } + + return plan, nil +} + +// chooseReleaseAsset picks the package on macOS only when the running binary +// lives where the package installs; otherwise the universal zip updates the +// binary in place when the release carries it. +func (u *Updater) chooseReleaseAsset(ctx context.Context, report Report, opts Options, tag string) Asset { + asset := report.ReleaseAsset + if asset.Kind != KindPkg || opts.Force || sameDir(filepath.Dir(report.ExecutablePath), PkgInstallDir) { + return asset + } + + zipAsset := DarwinZipAsset() + if ok, err := u.Client.AssetExists(ctx, tag, zipAsset.Name); err == nil && ok { + return zipAsset + } + + return asset +} + +// resolveTag answers "which tag?" from the cache when fresh, otherwise live +// within the hourly budget. +func (u *Updater) resolveTag(ctx context.Context, opts Options) (tag, source string, err error) { + if opts.Version != "" { + if ok, next := u.Budget.Allow(); !ok { + return "", "", &BudgetExhaustedError{Limit: u.Budget.Limit, Window: Window, NextAt: next} + } + + exists, err := u.Client.TagExists(ctx, opts.Version) + u.record("") + if err != nil { + return "", "", u.backoff(err) + } + if !exists { + return "", "", &ReleaseNotFoundError{Tag: opts.Version} + } + + return opts.Version, "requested", nil + } + + if !opts.Refresh { + if tag, age, ok := u.Budget.Cached(); ok { + return tag, "cached " + age.Round(time.Minute).String() + " ago", nil + } + } + + ok, next := u.Budget.Allow() + if !ok { + if opts.Check { + if tag, at, ok := u.Budget.CachedAny(); ok { + return tag, fmt.Sprintf("cached at %s, next live check at %s", at.Local().Format(time.Kitchen), next.Local().Format(time.Kitchen)), nil + } + } + + return "", "", &BudgetExhaustedError{Limit: u.Budget.Limit, Window: Window, NextAt: next} + } + + tag, err = u.Client.Latest(ctx) + if err != nil { + u.record("") + + return "", "", u.backoff(err) + } + u.record(tag) + + if u.Budget.Limit <= 0 { + return tag, "live", nil + } + + return tag, fmt.Sprintf("live, %d of %d checks left this hour", u.Budget.Remaining(), u.Budget.Limit), nil +} + +func (u *Updater) record(tag string) { + if err := u.Budget.RecordLookup(tag); err != nil { + u.logDebug("could not persist update state", "error", err) + } +} + +// backoff persists a GitHub rate-limit answer so later runs honour it. +func (u *Updater) backoff(err error) error { + if rl, ok := errors.AsType[*RateLimitedError](err); ok { + if saveErr := u.Budget.RecordBackoff(rl.RetryAt); saveErr != nil { + u.logDebug("could not persist backoff", "error", saveErr) + } + } + + return err +} + +// install carries out the plan. +func (u *Updater) install(ctx context.Context, plan Plan, report Report, opts Options) error { + if plan.Method == MethodGo { + u.printf(" building from source with go install (this can take a minute)…\n") + + if err := GoInstall(ctx, u.Exec, opts.Version); err != nil { + return err + } + u.printf(" βœ“ installed into %s\n", report.GoBinDir) + + return nil + } + + dir, cleanup, err := u.stagingDir() + if err != nil { + return err + } + defer cleanup() + + u.printf(" downloading %s…\n", plan.Asset.Name) + + archive, bundle, err := Fetch(ctx, u.Client, plan.Tag, plan.Asset, dir) + if err != nil { + return err + } + u.printf(" βœ“ SHA-256 verified\n") + + if plan.Asset.Kind == KindPkg { + if err := u.requireSignature(VerifyPkg(ctx, u.Exec, archive), plan.Asset.Name, opts); err != nil { + return err + } + if err := InstallPkg(ctx, u.Exec, archive); err != nil { + return err + } + u.printf(" βœ“ installed to %s\n", PkgInstallDir) + + return nil + } + + binary, err := ExtractBinary(archive, plan.Asset.InnerName, dir) + if err != nil { + return err + } + + var v Verification + if u.Checker.GOOS == "darwin" { + v = VerifyCodesign(ctx, u.Exec, binary) + } else { + v = VerifySigstore(ctx, u.Exec, u.lookPath(), archive, bundle) + } + if err := u.requireSignature(v, plan.Asset.Name, opts); err != nil { + return err + } + + if err := ReplaceBinary(binary, report.ExecutablePath); err != nil { + return err + } + u.printf(" βœ“ installed to %s\n", report.ExecutablePath) + + return nil +} + +// requireSignature reports a verification result and turns it into an error +// when it failed, or when it was skipped and the caller demanded a signature. +func (u *Updater) requireSignature(v Verification, asset string, opts Options) error { + switch v.Status { + case VerifyPassed: + u.printf(" βœ“ %s: %s\n", v.Tool, v.Detail) + + return nil + case VerifyFailed: + return &SignatureError{Asset: asset, Tool: v.Tool, Detail: v.Detail} + default: + if opts.RequireSignature { + return &SignatureError{Asset: asset, Tool: v.Tool, Detail: v.Detail + " (-require-signature)"} + } + u.printf(" ! signature not verified: %s\n", v.Detail) + + return nil + } +} + +// confirmInstalled runs the installed binary and reports its version. A +// failure here is not an update failure, but a version that did not move is +// the one sign a fixed-destination install landed somewhere else. +func (u *Updater) confirmInstalled(ctx context.Context, plan Plan, report Report) string { + path := report.ExecutablePath + if plan.Method == MethodGo && report.GoBinDir != "" { + path = filepath.Join(report.GoBinDir, ToolName+exeSuffix()) + } + + installed, err := ConfirmVersion(ctx, u.Exec, path) + if err != nil { + u.printf(" ! could not confirm the installed version: %v\n", err) + + return "" + } + + if plan.Tag != "" && !SameVersion(installed, plan.Tag) { + u.printf(" ! %s reports %s, not the %s just installed\n", path, installed, plan.Tag) + } + + return installed +} + +func exeSuffix() string { + if os.PathSeparator == '\\' { + return ".exe" + } + + return "" +} + +func (u *Updater) stagingDir() (string, func(), error) { + maker := u.TempDir + if maker == nil { + maker = func() (string, error) { return os.MkdirTemp("", ToolName+"-update-*") } + } + + dir, err := maker() + if err != nil { + return "", func() {}, fmt.Errorf("creating a staging directory: %w", err) + } + + return dir, func() { _ = os.RemoveAll(dir) }, nil +} + +func (u *Updater) lookPath() func(string) (string, error) { + if u.LookPath != nil { + return u.LookPath + } + + return exec.LookPath +} + +func (u *Updater) printChecks(checks []Check) { + for _, c := range checks { + if c.OK() { + u.printf(" βœ“ %-26s %s\n", c.Label, c.Detail) + + continue + } + u.printf(" βœ— %-26s %v\n", c.Label, c.Err) + } +} + +func (u *Updater) printf(format string, args ...any) { + if u.Out != nil { + fmt.Fprintf(u.Out, format, args...) + } +} + +func (u *Updater) logDebug(msg string, args ...any) { + if u.Logger != nil { + u.Logger.Debug(msg, args...) + } +} + +// describePlan renders what is about to happen. +func describePlan(plan Plan, current string) string { + switch { + case plan.Method == MethodGo: + return fmt.Sprintf("Updating %s %s β†’ %s from source with go install", ToolName, current, plan.Tag) + case plan.Asset.Kind == KindPkg: + return fmt.Sprintf("Updating %s %s β†’ %s using the signed macOS package", ToolName, current, plan.Tag) + default: + return fmt.Sprintf("Updating %s %s β†’ %s using the release archive %s", ToolName, current, plan.Tag, plan.Asset.Name) + } +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go new file mode 100644 index 0000000..e8b658e --- /dev/null +++ b/internal/selfupdate/updater_test.go @@ -0,0 +1,367 @@ +package selfupdate + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// testRig is a fully faked machine + GitHub for end-to-end runs. +type testRig struct { + gh *fakeGitHub + exec *fakeExec + budget *Budget + now *time.Time + out bytes.Buffer + target string // the running binary on disk + u *Updater +} + +func newRig(t *testing.T, goos, latest string) *testRig { + t.Helper() + + dir := t.TempDir() + target := filepath.Join(dir, "machineid") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatal(err) + } + + rig := &testRig{gh: newFakeGitHub(latest), exec: newFakeExec(), target: target} + rig.budget, rig.now = newTestBudget(t, DefaultLimit) + + checker := newTestChecker(goos, target, filepath.Join(dir, "gobin"), 1000, rig.exec) + rig.exec.on(target+" -version", ExecResult{Stdout: "machineid " + latest}) + + rig.u = &Updater{ + Checker: checker, + Client: newTestClient(t, rig.gh), + Budget: rig.budget, + Exec: rig.exec.run, + LookPath: func(string) (string, error) { return "", errors.New("not found") }, + Out: &rig.out, + TempDir: func() (string, error) { return os.MkdirTemp(dir, "stage-*") }, + } + + return rig +} + +func (r *testRig) publishLinux(tag string, content []byte) { + r.gh.addArchive(tag, "machineid-linux-arm64.zip", "machineid-linux-arm64.sha256", "machineid", content) +} + +func TestUpdaterLinuxZipEndToEnd(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new-binary")) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if err != nil { + t.Fatalf("Run: %v\n%s", err, rig.out.String()) + } + if !res.Changed || res.Method != MethodRelease || res.Tag != "v0.3.0" || res.To != "v0.3.0" { + t.Errorf("result = %+v", res) + } + + got := mustRead(t, rig.target) + if string(got) != "new-binary" { + t.Errorf("target = %q", got) + } + + out := rig.out.String() + for _, want := range []string{"βœ“ SHA-256 verified", "! signature not verified: no Sigstore bundle is published", "live, 4 of 5 checks left", "βœ“ installed to " + rig.target} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestUpdaterAlreadyCurrent(t *testing.T) { + rig := newRig(t, "linux", "v0.2.0") + rig.publishLinux("v0.2.0", []byte("same")) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if err != nil || res.Changed { + t.Fatalf("res = %+v, err = %v", res, err) + } + if !strings.Contains(rig.out.String(), "already the latest version") { + t.Error(rig.out.String()) + } + if got := mustRead(t, rig.target); string(got) != "old-binary" { + t.Error("target must not change") + } +} + +func TestUpdaterCheckChangesNothing(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", Check: true}) + if err != nil || res.Changed { + t.Fatalf("res = %+v, err = %v", res, err) + } + if got := mustRead(t, rig.target); string(got) != "old-binary" { + t.Error("-check must not change the target") + } + if !strings.Contains(rig.out.String(), "-check: nothing was changed") { + t.Error(rig.out.String()) + } +} + +func TestUpdaterDeclinedPrompt(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + asked := false + rig.u.Confirm = func(string) (bool, error) { asked = true; return false, nil } + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0"}) + if err != nil || res.Changed || !asked { + t.Fatalf("res = %+v, err = %v, asked = %v", res, err, asked) + } + if got := mustRead(t, rig.target); string(got) != "old-binary" { + t.Error("declining must not change the target") + } +} + +func TestUpdaterUsesCacheThenBudget(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + ctx := context.Background() + + // First -check: live. + if _, err := rig.u.Run(ctx, Options{CurrentVersion: "v0.2.0", Check: true}); err != nil { + t.Fatal(err) + } + if rig.gh.latestHeads.Load() != 1 { + t.Fatalf("expected 1 live lookup, got %d", rig.gh.latestHeads.Load()) + } + + // Second -check within the hour: cached, no request. + rig.out.Reset() + *rig.now = rig.now.Add(10 * time.Minute) + if _, err := rig.u.Run(ctx, Options{CurrentVersion: "v0.2.0", Check: true}); err != nil { + t.Fatal(err) + } + if rig.gh.latestHeads.Load() != 1 { + t.Errorf("cached run made a request") + } + if !strings.Contains(rig.out.String(), "cached 10m0s ago") { + t.Error(rig.out.String()) + } + + // -refresh forces live lookups until the budget is spent. + for range DefaultLimit - 1 { + if _, err := rig.u.Run(ctx, Options{CurrentVersion: "v0.2.0", Check: true, Refresh: true}); err != nil { + t.Fatal(err) + } + } + if int(rig.gh.latestHeads.Load()) != DefaultLimit { + t.Errorf("expected %d live lookups, got %d", DefaultLimit, rig.gh.latestHeads.Load()) + } + + // Budget spent: -check still answers from the stale cache… + rig.out.Reset() + if _, err := rig.u.Run(ctx, Options{CurrentVersion: "v0.2.0", Check: true, Refresh: true}); err != nil { + t.Fatalf("-check over budget should serve the cache: %v", err) + } + if !strings.Contains(rig.out.String(), "next live check at") { + t.Error(rig.out.String()) + } + + // …but a real update with -refresh refuses with a prerequisite error. + _, err := rig.u.Run(ctx, Options{CurrentVersion: "v0.2.0", Refresh: true, AssumeYes: true}) + var exhausted *BudgetExhaustedError + if !errors.As(err, &exhausted) || !IsPrerequisite(err) { + t.Fatalf("err = %v", err) + } + if int(rig.gh.latestHeads.Load()) != DefaultLimit { + t.Error("a refused lookup must not hit the network") + } +} + +func TestUpdaterExplicitVersionDowngrades(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.1.0", []byte("older")) + rig.exec.on(rig.target+" -version", ExecResult{Stdout: "machineid v0.1.0"}) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", Version: "v0.1.0", AssumeYes: true}) + if err != nil { + t.Fatalf("%v\n%s", err, rig.out.String()) + } + if !res.Changed || res.Tag != "v0.1.0" { + t.Errorf("res = %+v", res) + } + if got := mustRead(t, rig.target); string(got) != "older" { + t.Errorf("target = %q", got) + } +} + +func TestUpdaterExplicitVersionMissing(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", Version: "v9.9.9", AssumeYes: true}) + var nf *ReleaseNotFoundError + if !errors.As(err, &nf) || !IsPrerequisite(err) { + t.Fatalf("err = %v", err) + } +} + +func TestUpdaterLocalBuildNeedsExplicitVersion(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "devel", AssumeYes: true}) + var nc *NotComparableError + if !errors.As(err, &nc) || !IsPrerequisite(err) { + t.Fatalf("err = %v", err) + } +} + +func TestUpdaterChecksumMismatchIsNotPrerequisite(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.gh.addAsset("v0.3.0", "machineid-linux-arm64.zip", makeZip("machineid", []byte("new"))) + rig.gh.addAsset("v0.3.0", "machineid-linux-arm64.sha256", []byte(strings.Repeat("0", 64))) + + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if _, ok := errors.AsType[*ChecksumMismatchError](err); !ok { + t.Fatalf("err = %v", err) + } + if IsPrerequisite(err) { + t.Error("a checksum failure happens after the download and is not a prerequisite failure") + } + if got := mustRead(t, rig.target); string(got) != "old-binary" { + t.Error("target must be untouched") + } +} + +func TestUpdaterRequireSignatureWithoutCosign(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true, RequireSignature: true}) + if _, ok := errors.AsType[*SignatureError](err); !ok { + t.Fatalf("err = %v", err) + } + if got := mustRead(t, rig.target); string(got) != "old-binary" { + t.Error("target must be untouched") + } +} + +func TestUpdaterSigstoreVerifiedWhenCosignPresent(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.publishLinux("v0.3.0", []byte("new")) + rig.gh.addAsset("v0.3.0", "machineid-linux-arm64.sigstore.json", []byte("{}")) + rig.u.LookPath = func(string) (string, error) { return "/usr/bin/cosign", nil } + rig.exec.on("cosign verify-blob", ExecResult{Stdout: "Verified OK"}) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true, RequireSignature: true}) + if err != nil || !res.Changed { + t.Fatalf("res = %+v, err = %v\n%s", res, err, rig.out.String()) + } + if !rig.exec.called("cosign verify-blob") || !strings.Contains(rig.out.String(), "βœ“ cosign") { + t.Error(rig.out.String()) + } +} + +func TestUpdaterDarwinPkgPath(t *testing.T) { + rig := newRig(t, "darwin", "v0.3.0") + // The running binary is the package's fixed location, as root. + rig.u.Checker.Executable = func() (string, error) { return "/usr/local/bin/machineid", nil } + rig.u.Checker.Getuid = func() int { return 0 } + pkg := []byte("pkg-bytes") + rig.gh.addAsset("v0.3.0", "machineid-darwin-universal.pkg", pkg) + rig.gh.addAsset("v0.3.0", "machineid-darwin-universal.sha256", []byte(sha256Hex(pkg))) + rig.exec.on("pkgutil --check-signature", ExecResult{Stdout: "1. Developer ID Installer: SlashDevOps"}) + rig.exec.on("installer -pkg", ExecResult{}) + rig.exec.on("/usr/local/bin/machineid -version", ExecResult{Stdout: "machineid v0.3.0"}) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if err != nil { + t.Fatalf("%v\n%s", err, rig.out.String()) + } + if !res.Changed || res.To != "v0.3.0" { + t.Errorf("res = %+v", res) + } + if !rig.exec.called("installer -pkg") || !strings.Contains(rig.out.String(), "signed macOS package") { + t.Error(rig.out.String()) + } +} + +func TestUpdaterDarwinZipWhenNotInUsrLocalBin(t *testing.T) { + rig := newRig(t, "darwin", "v0.3.0") + // Running from a temp dir; the release carries the universal zip. + z := DarwinZipAsset() + rig.gh.addArchive("v0.3.0", z.Name, z.ChecksumName, z.InnerName, []byte("new-mac-binary")) + rig.exec.on("codesign --verify", ExecResult{}) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if err != nil { + t.Fatalf("%v\n%s", err, rig.out.String()) + } + if !res.Changed { + t.Errorf("res = %+v", res) + } + if got := mustRead(t, rig.target); string(got) != "new-mac-binary" { + t.Errorf("target = %q", got) + } + if rig.exec.called("installer -pkg") { + t.Error("the package must not be used for a binary outside /usr/local/bin") + } +} + +func TestUpdaterDarwinNoZipRefusesOutsidePkgDir(t *testing.T) { + rig := newRig(t, "darwin", "v0.3.0") + rig.gh.addAsset("v0.3.0", "machineid-darwin-universal.pkg", []byte("pkg")) + + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + var wrong *WrongInstallLocationError + if !errors.As(err, &wrong) || !IsPrerequisite(err) { + t.Fatalf("err = %v\n%s", err, rig.out.String()) + } +} + +func TestUpdaterGoMethod(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + gobin := filepath.Dir(rig.target) + rig.exec.on("go env GOBIN GOPATH", ExecResult{Stdout: gobin + "\n/x\n"}) + rig.exec.on("go install "+CommandPath+"@latest", ExecResult{}) + + res, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", Method: MethodGo, AssumeYes: true}) + if err != nil { + t.Fatalf("%v\n%s", err, rig.out.String()) + } + if !res.Changed || res.Method != MethodGo || !rig.exec.called("go install "+CommandPath+"@latest") { + t.Errorf("res = %+v\n%s", res, rig.out.String()) + } + if rig.gh.latestHeads.Load() != 1 { + t.Error("the go method still resolves the tag to compare versions") + } +} + +func TestUpdaterRateLimitPersistsBackoff(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") + rig.gh.rateLimit = true + rig.gh.retryAfter = "600" + + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if _, ok := errors.AsType[*RateLimitedError](err); !ok { + t.Fatalf("err = %v", err) + } + + rig.gh.rateLimit = false + _, err = rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + if _, ok := errors.AsType[*BudgetExhaustedError](err); !ok { + t.Fatalf("the backoff should be honoured on the next run: %v", err) + } +} + +func TestUpdaterAssetMissingFromRelease(t *testing.T) { + rig := newRig(t, "linux", "v0.3.0") // release exists, no linux asset + _, err := rig.u.Run(context.Background(), Options{CurrentVersion: "v0.2.0", AssumeYes: true}) + var nf *AssetNotFoundError + if !errors.As(err, &nf) || !IsPrerequisite(err) { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go new file mode 100644 index 0000000..d215c50 --- /dev/null +++ b/internal/selfupdate/verify.go @@ -0,0 +1,191 @@ +package selfupdate + +import ( + "bytes" + "context" + "errors" + "os/exec" + "strings" + "time" +) + +// execWaitDelay bounds how long a killed command may hold its pipes open. +const execWaitDelay = time.Second + +// ExecResult is what an external command produced. +type ExecResult struct { + Stdout string + Stderr string + ExitCode int +} + +// Text returns stdout when there is any, else stderr. +func (r ExecResult) Text() string { + if strings.TrimSpace(r.Stdout) != "" { + return r.Stdout + } + + return r.Stderr +} + +// Exec runs an external command. A non-zero exit is reported through +// ExecResult.ExitCode; err is set only when the command could not run at +// all (not found, context cancelled). Tests inject a fake. +type Exec func(ctx context.Context, name string, args ...string) (ExecResult, error) + +// DefaultExec runs commands with os/exec. Arguments are passed as a slice; +// nothing goes through a shell. +func DefaultExec(ctx context.Context, name string, args ...string) (ExecResult, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.WaitDelay = execWaitDelay + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + res := ExecResult{Stdout: strings.TrimSpace(stdout.String()), Stderr: strings.TrimSpace(stderr.String())} + + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + res.ExitCode = exitErr.ExitCode() + + return res, nil + } + + return res, err + } + + return res, nil +} + +// VerifyStatus is the outcome of a signature check. +type VerifyStatus int + +const ( + // VerifySkipped means the check could not run (tool absent, no bundle). + VerifySkipped VerifyStatus = iota + // VerifyPassed means the signature is valid. + VerifyPassed + // VerifyFailed means the check ran and the artefact is not trusted. + VerifyFailed +) + +// Verification is the result of one signature check. +type Verification struct { + Tool string + Detail string + Status VerifyStatus +} + +// Sigstore identity constraints for release artefacts signed by the release +// workflow of this repository. +const ( + sigstoreIssuer = "https://token.actions.githubusercontent.com" + sigstoreIdentityRegex = `^https://github\.com/slashdevops/machineid/` +) + +// VerifySigstore checks a Linux archive against its Sigstore bundle with +// cosign. Without cosign on PATH, or without a bundle, the check is skipped +// and the caller decides whether that is acceptable. +func VerifySigstore(ctx context.Context, run Exec, lookPath func(string) (string, error), archive, bundle string) Verification { + v := Verification{Tool: "cosign"} + + if bundle == "" { + v.Detail = "no Sigstore bundle is published for this asset" + + return v + } + + if _, err := lookPath("cosign"); err != nil { + v.Detail = "cosign is not installed" + + return v + } + + res, err := run(ctx, "cosign", "verify-blob", + "--bundle", bundle, + "--certificate-identity-regexp", sigstoreIdentityRegex, + "--certificate-oidc-issuer", sigstoreIssuer, + archive) + if err != nil { + v.Status = VerifyFailed + v.Detail = err.Error() + + return v + } + + if res.ExitCode != 0 { + v.Status = VerifyFailed + v.Detail = firstLine(res.Text()) + + return v + } + + v.Status = VerifyPassed + v.Detail = "Sigstore bundle verified against the release workflow identity" + + return v +} + +// VerifyPkg checks a macOS package's Developer ID signature with pkgutil. +func VerifyPkg(ctx context.Context, run Exec, pkgPath string) Verification { + v := Verification{Tool: "pkgutil"} + + res, err := run(ctx, "pkgutil", "--check-signature", pkgPath) + if err != nil { + v.Detail = "pkgutil is not available: " + err.Error() + + return v + } + + if res.ExitCode != 0 || !strings.Contains(res.Text(), "Developer ID Installer") { + v.Status = VerifyFailed + v.Detail = firstLine(res.Text()) + if v.Detail == "" { + v.Detail = "the package is not signed with a Developer ID Installer certificate" + } + + return v + } + + v.Status = VerifyPassed + v.Detail = signerLine(res.Text()) + + return v +} + +// VerifyCodesign checks a macOS binary's code signature. +func VerifyCodesign(ctx context.Context, run Exec, binary string) Verification { + v := Verification{Tool: "codesign"} + + res, err := run(ctx, "codesign", "--verify", "--strict", "--verbose=2", binary) + if err != nil { + v.Detail = "codesign is not available: " + err.Error() + + return v + } + + if res.ExitCode != 0 { + v.Status = VerifyFailed + v.Detail = firstLine(res.Text()) + + return v + } + + v.Status = VerifyPassed + v.Detail = "code signature valid" + + return v +} + +// signerLine extracts the "Developer ID Installer: …" line from pkgutil output. +func signerLine(out string) string { + for line := range strings.SplitSeq(out, "\n") { + if t := strings.TrimSpace(line); strings.Contains(t, "Developer ID Installer") { + return strings.TrimPrefix(t, "1. ") + } + } + + return "signed by a Developer ID Installer certificate" +} diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go new file mode 100644 index 0000000..55bcaa3 --- /dev/null +++ b/internal/selfupdate/verify_test.go @@ -0,0 +1,64 @@ +package selfupdate + +import ( + "context" + "errors" + "testing" +) + +func TestVerifySigstore(t *testing.T) { + ctx := context.Background() + found := func(string) (string, error) { return "/usr/bin/cosign", nil } + missing := func(string) (string, error) { return "", errors.New("not found") } + + if v := VerifySigstore(ctx, newFakeExec().run, found, "a.zip", ""); v.Status != VerifySkipped { + t.Errorf("no bundle: %+v", v) + } + if v := VerifySigstore(ctx, newFakeExec().run, missing, "a.zip", "a.json"); v.Status != VerifySkipped { + t.Errorf("no cosign: %+v", v) + } + + fe := newFakeExec() + fe.on("cosign verify-blob --bundle a.json --certificate-identity-regexp "+sigstoreIdentityRegex+" --certificate-oidc-issuer "+sigstoreIssuer+" a.zip", ExecResult{Stdout: "Verified OK"}) + if v := VerifySigstore(ctx, fe.run, found, "a.zip", "a.json"); v.Status != VerifyPassed { + t.Errorf("valid: %+v", v) + } + + fe.on("cosign verify-blob --bundle a.json --certificate-identity-regexp "+sigstoreIdentityRegex+" --certificate-oidc-issuer "+sigstoreIssuer+" a.zip", ExecResult{ExitCode: 1, Stderr: "Error: none of the expected identities matched"}) + if v := VerifySigstore(ctx, fe.run, found, "a.zip", "a.json"); v.Status != VerifyFailed || v.Detail == "" { + t.Errorf("invalid: %+v", v) + } +} + +func TestVerifyPkg(t *testing.T) { + ctx := context.Background() + fe := newFakeExec() + fe.on("pkgutil --check-signature x.pkg", ExecResult{Stdout: "Package \"x.pkg\":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. Developer ID Installer: SlashDevOps (TEAM)\n"}) + v := VerifyPkg(ctx, fe.run, "x.pkg") + if v.Status != VerifyPassed || v.Detail != "Developer ID Installer: SlashDevOps (TEAM)" { + t.Errorf("%+v", v) + } + + fe.on("pkgutil --check-signature x.pkg", ExecResult{Stdout: "Status: no signature"}) + if v := VerifyPkg(ctx, fe.run, "x.pkg"); v.Status != VerifyFailed { + t.Errorf("unsigned: %+v", v) + } + + fe.fail("pkgutil", errors.New("not found")) + if v := VerifyPkg(ctx, fe.run, "x.pkg"); v.Status != VerifySkipped { + t.Errorf("missing tool: %+v", v) + } +} + +func TestVerifyCodesign(t *testing.T) { + ctx := context.Background() + fe := newFakeExec() + fe.on("codesign --verify --strict --verbose=2 bin", ExecResult{}) + if v := VerifyCodesign(ctx, fe.run, "bin"); v.Status != VerifyPassed { + t.Errorf("%+v", v) + } + fe.on("codesign --verify --strict --verbose=2 bin", ExecResult{ExitCode: 1, Stderr: "bin: code object is not signed at all"}) + if v := VerifyCodesign(ctx, fe.run, "bin"); v.Status != VerifyFailed { + t.Errorf("%+v", v) + } +} diff --git a/internal/selfupdate/version.go b/internal/selfupdate/version.go new file mode 100644 index 0000000..4c02487 --- /dev/null +++ b/internal/selfupdate/version.go @@ -0,0 +1,209 @@ +package selfupdate + +import ( + "strconv" + "strings" +) + +// Version is a parsed semantic version. Build metadata is discarded on parse +// because it does not participate in ordering (SemVer Β§10). +type Version struct { + Pre []string + Major int + Minor int + Patch int +} + +// ParseVersion parses a release tag or version string. +// +// A leading "v" is optional. Minor and patch may be omitted and default to +// zero, so "v1" and "v1.0.0" are the same version. A pre-release suffix +// ("-rc.1") is kept; build metadata ("+abc") is dropped. Anything else, such +// as the "devel" or branch names a local build reports, is not a version and +// yields ok == false. +func ParseVersion(s string) (Version, bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if s == "" { + return Version{}, false + } + + if i := strings.IndexByte(s, '+'); i >= 0 { + s = s[:i] + } + + core, pre, hasPre := strings.Cut(s, "-") + + var v Version + + parts := strings.Split(core, ".") + if len(parts) == 0 || len(parts) > 3 { + return Version{}, false + } + + nums := make([]int, 3) + for i, part := range parts { + n, ok := parseNumericIdentifier(part) + if !ok { + return Version{}, false + } + nums[i] = n + } + v.Major, v.Minor, v.Patch = nums[0], nums[1], nums[2] + + if hasPre { + if pre == "" { + return Version{}, false + } + for id := range strings.SplitSeq(pre, ".") { + if id == "" || !isIdentifier(id) { + return Version{}, false + } + v.Pre = append(v.Pre, id) + } + } + + return v, true +} + +// parseNumericIdentifier parses a non-negative decimal without a sign. +func parseNumericIdentifier(s string) (int, bool) { + if s == "" { + return 0, false + } + for _, r := range s { + if r < '0' || r > '9' { + return 0, false + } + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + + return n, true +} + +// isIdentifier reports whether s is [0-9A-Za-z-]+. +func isIdentifier(s string) bool { + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '-': + default: + return false + } + } + + return s != "" +} + +// String renders the canonical form: "v1.2.3" or "v1.2.3-rc.1". +func (v Version) String() string { + s := "v" + strconv.Itoa(v.Major) + "." + strconv.Itoa(v.Minor) + "." + strconv.Itoa(v.Patch) + if len(v.Pre) > 0 { + s += "-" + strings.Join(v.Pre, ".") + } + + return s +} + +// Compare orders two versions per SemVer Β§11: -1 if a < b, 0 if equal, +1 if a > b. +func Compare(a, b Version) int { + switch { + case a.Major != b.Major: + return cmpInt(a.Major, b.Major) + case a.Minor != b.Minor: + return cmpInt(a.Minor, b.Minor) + case a.Patch != b.Patch: + return cmpInt(a.Patch, b.Patch) + } + + // A version without pre-release is higher than the same version with one. + switch { + case len(a.Pre) == 0 && len(b.Pre) == 0: + return 0 + case len(a.Pre) == 0: + return 1 + case len(b.Pre) == 0: + return -1 + } + + for i := 0; i < len(a.Pre) && i < len(b.Pre); i++ { + if c := comparePreIdentifier(a.Pre[i], b.Pre[i]); c != 0 { + return c + } + } + + return cmpInt(len(a.Pre), len(b.Pre)) +} + +// comparePreIdentifier orders two pre-release identifiers: numeric ones +// numerically, alphanumeric ones lexically, and numeric below alphanumeric. +func comparePreIdentifier(a, b string) int { + an, aNum := parseNumericIdentifier(a) + bn, bNum := parseNumericIdentifier(b) + + switch { + case aNum && bNum: + return cmpInt(an, bn) + case aNum: + return -1 + case bNum: + return 1 + default: + return strings.Compare(a, b) + } +} + +func cmpInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +// Canonical normalises a version string to its "v"-prefixed canonical form. +// ok is false when s is not a version. +func Canonical(s string) (string, bool) { + v, ok := ParseVersion(s) + if !ok { + return "", false + } + + return v.String(), true +} + +// IsUpgrade reports whether candidate is strictly newer than current. +// +// A current version that cannot be parsed (a local build reporting "devel" or +// a branch name) cannot be ordered and returns [NotComparableError], whose +// remedy is to name a tag explicitly. +func IsUpgrade(current, candidate string) (bool, error) { + cur, ok := ParseVersion(current) + if !ok { + return false, &NotComparableError{Version: current} + } + + cand, ok := ParseVersion(candidate) + if !ok { + return false, &NotComparableError{Version: candidate} + } + + return Compare(cand, cur) > 0, nil +} + +// SameVersion reports whether two version strings denote the same version. +// Strings that are not versions compare by exact text. +func SameVersion(a, b string) bool { + va, okA := ParseVersion(a) + vb, okB := ParseVersion(b) + if okA && okB { + return Compare(va, vb) == 0 + } + + return strings.TrimSpace(a) == strings.TrimSpace(b) +} diff --git a/internal/selfupdate/version_test.go b/internal/selfupdate/version_test.go new file mode 100644 index 0000000..c767704 --- /dev/null +++ b/internal/selfupdate/version_test.go @@ -0,0 +1,116 @@ +package selfupdate + +import ( + "errors" + "testing" +) + +func TestParseVersion(t *testing.T) { + tests := []struct { + in string + want string + ok bool + }{ + {"v1.2.3", "v1.2.3", true}, + {"1.2.3", "v1.2.3", true}, + {"v1", "v1.0.0", true}, + {"v1.2", "v1.2.0", true}, + {"v0.2.0-rc.1", "v0.2.0-rc.1", true}, + {"v0.2.0-rc.1+build.5", "v0.2.0-rc.1", true}, + {"v0.2.0+meta", "v0.2.0", true}, + {" v0.1.3 ", "v0.1.3", true}, + {"0.0.0", "v0.0.0", true}, + {"devel", "", false}, + {"main", "", false}, + {"feat/DCT-101", "", false}, + {"", "", false}, + {"v", "", false}, + {"v1.2.3.4", "", false}, + {"v1.-2.3", "", false}, + {"v1.2.3-", "", false}, + {"v1.2.3-rc..1", "", false}, + {"v1.2.3-rc_1", "", false}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, ok := Canonical(tt.in) + if ok != tt.ok || got != tt.want { + t.Errorf("Canonical(%q) = %q, %v; want %q, %v", tt.in, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestCompare(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"v1.0.0", "v1.0.0", 0}, + {"v1.0.0", "1.0.0", 0}, + {"v1.0.0", "v1.0.1", -1}, + {"v1.1.0", "v1.0.9", 1}, + {"v2.0.0", "v1.99.99", 1}, + {"v1.0.0-rc.1", "v1.0.0", -1}, + {"v1.0.0", "v1.0.0-rc.1", 1}, + {"v1.0.0-rc.9", "v1.0.0-rc.10", -1}, + {"v1.0.0-alpha", "v1.0.0-beta", -1}, + {"v1.0.0-alpha", "v1.0.0-alpha.1", -1}, + {"v1.0.0-1", "v1.0.0-alpha", -1}, + {"v1.0.0-rc.1+a", "v1.0.0-rc.1+b", 0}, + } + + for _, tt := range tests { + t.Run(tt.a+"_"+tt.b, func(t *testing.T) { + va, _ := ParseVersion(tt.a) + vb, _ := ParseVersion(tt.b) + if got := Compare(va, vb); got != tt.want { + t.Errorf("Compare(%s, %s) = %d, want %d", tt.a, tt.b, got, tt.want) + } + if got := Compare(vb, va); got != -tt.want { + t.Errorf("Compare(%s, %s) = %d, want %d", tt.b, tt.a, got, -tt.want) + } + }) + } +} + +func TestIsUpgrade(t *testing.T) { + up, err := IsUpgrade("v0.1.3", "v0.2.0") + if err != nil || !up { + t.Errorf("IsUpgrade(v0.1.3, v0.2.0) = %v, %v", up, err) + } + + up, err = IsUpgrade("v0.2.0", "v0.2.0") + if err != nil || up { + t.Errorf("IsUpgrade(same) = %v, %v", up, err) + } + + up, err = IsUpgrade("0.0.0", "v0.1.0") + if err != nil || !up { + t.Errorf("IsUpgrade(0.0.0, v0.1.0) = %v, %v; an un-injected build is older than everything", up, err) + } + + _, err = IsUpgrade("devel", "v0.2.0") + if _, ok := errors.AsType[*NotComparableError](err); !ok { + t.Fatalf("IsUpgrade(devel) error = %v, want NotComparableError", err) + } + if RemedyOf(err) == "" { + t.Error("NotComparableError should carry a remedy") + } +} + +func TestSameVersion(t *testing.T) { + if !SameVersion("v0.2.0", "0.2.0") { + t.Error("v-prefix should not matter") + } + if SameVersion("v0.2.0", "v0.2.1") { + t.Error("different patch") + } + if !SameVersion("devel", "devel") { + t.Error("non-versions compare by text") + } + if SameVersion("devel", "v0.2.0") { + t.Error("non-version vs version") + } +} From 26ed6baca96f4d731be0deaf21c92859ad691205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 19 Sep 2026 20:06:01 +0200 Subject: [PATCH 2/2] test: make selfupdate tests platform-neutral; docs: full user guide for machineid update - Skip the POSIX file-mode and executable-bit assertions on Windows. - The fake go env returns GOBIN verbatim so filepath.Join cannot rewrite separators on Windows; a new test covers the GOPATH/bin fallback. - docs/updating.md: quick start, walkthrough, flags, exit codes, per-platform table, install-target guard, cache and hourly budget with state-file paths, verification, script/cron/CI recipes, privacy, troubleshooting keyed by error text, FAQ. README links to it. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 +- docs/updating.md | 364 +++++++++++++++++++++++++++ internal/selfupdate/budget_test.go | 4 +- internal/selfupdate/download_test.go | 3 +- internal/selfupdate/prereq_test.go | 17 +- 5 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 docs/updating.md diff --git a/README.md b/README.md index b97225a..890567b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ unzip machineid.zip && sudo install -m 0755 machineid /usr/local/bin/machineid **πŸͺŸ Windows**: use `go install` above or build from source. Pre-built Windows binaries are not published yet. -How to verify a download: [macOS signing and notarization](docs/macos-signing.md) and [Linux Sigstore verification](docs/linux-signing.md). +How to verify a download: [macOS signing and notarization](docs/macos-signing.md) and [Linux Sigstore verification](docs/linux-signing.md). Already installed? See [Updating the CLI](#️-updating-the-cli). #### From source @@ -131,10 +131,12 @@ make build ## ⬆️ Updating the CLI -Once installed, the CLI updates itself: +Once installed, the CLI updates itself. Full guide with per-platform details, script recipes and a troubleshooting table: **[docs/updating.md](docs/updating.md)**. ```bash -machineid update +machineid update # check, show the plan, ask, install +machineid update -check # what would happen, nothing changes +machineid update -yes # non-interactive (sudo on macOS for the .pkg) ``` It looks up the newest release, shows a checklist and the plan, asks for confirmation, downloads the asset for your platform, verifies its SHA-256 and signature, and replaces the binary you are running. Nothing is downloaded until every check has passed. Root is never requested; where it is needed (the macOS package) the exact `sudo` command is printed. diff --git a/docs/updating.md b/docs/updating.md new file mode 100644 index 0000000..a8b4748 --- /dev/null +++ b/docs/updating.md @@ -0,0 +1,364 @@ +# ⬆️ Updating the `machineid` CLI + +`machineid update` replaces the binary you are running with the newest published release. This guide is for people who **use** the tool. If you want to know how it is built, read the package documentation in [`internal/selfupdate`](../internal/selfupdate/doc.go). + +> **This is a feature of the command-line tool only.** If you use `machineid` as a Go library in your own program, nothing here applies to you: the library has no update code and never makes a network request. + +--- + +## Contents + +- [Quick start](#quick-start) +- [What happens when you run it](#what-happens-when-you-run-it) +- [Flags](#flags) +- [Exit codes](#exit-codes) +- [Per-platform behaviour](#per-platform-behaviour) +- [Where it installs, and why that matters](#where-it-installs-and-why-that-matters) +- [Checking for updates without installing](#checking-for-updates-without-installing) +- [Installing a specific version, or going back](#installing-a-specific-version-or-going-back) +- [Network use, caching and the hourly limit](#network-use-caching-and-the-hourly-limit) +- [Verification: checksums and signatures](#verification-checksums-and-signatures) +- [Using it from scripts, cron and CI](#using-it-from-scripts-cron-and-ci) +- [Privacy](#privacy) +- [Troubleshooting](#troubleshooting) +- [FAQ](#faq) + +--- + +## Quick start + +```bash +machineid update +``` + +That is all most people need. It shows a checklist, tells you what it is about to do, asks `[y/N]`, and only then downloads anything. + +To see what would happen without changing anything: + +```bash +machineid update -check +``` + +To update without being asked (scripts, CI): + +```bash +machineid update -yes +``` + +On macOS, if you installed with the `.pkg`, the installer needs root: + +```bash +sudo machineid update -yes +``` + +`machineid` never asks for your password itself. If root is needed, it stops before downloading and prints the exact command to run. + +--- + +## What happens when you run it + +```text +$ machineid update +Checking for updates… + βœ“ current version v0.2.0 + βœ“ running binary /usr/local/bin/machineid + βœ“ platform supported darwin/arm64 + βœ“ latest release v0.3.0 (live, 4 of 5 checks left this hour) + βœ“ install target /usr/local/bin (running as root) + +β†’ Updating machineid v0.2.0 β†’ v0.3.0 using the signed macOS package + +Update machineid now? [y/N] y + downloading machineid-darwin-universal.pkg… + βœ“ SHA-256 verified + βœ“ pkgutil: Developer ID Installer: SlashDevOps + βœ“ installed to /usr/local/bin + +βœ… Updated to v0.3.0 +``` + +In order: + +1. **Checklist.** Each line is one thing that has to be true. A failing line shows `βœ—` and the reason. +2. **Latest release.** Looked up on github.com, or taken from the local cache when it is under an hour old. The note in brackets tells you which. +3. **Install target.** Confirms the update will land on the binary you are running, and that you may write there. +4. **The plan.** One line saying from which version, to which version, and how. +5. **Confirmation.** `y` proceeds, anything else cancels. Skip with `-yes`. +6. **Download and verify.** The asset and its checksum are downloaded; the SHA-256 must match. Then the signature is checked (see [Verification](#verification-checksums-and-signatures)). +7. **Install.** In place, or through the macOS installer, or with `go install`. +8. **Confirm.** The new binary is run with `-version` and the answer is compared with what was installed. + +If anything fails before step 6, **nothing has been downloaded and nothing has changed**. If the checksum or signature fails in step 6, the old binary is untouched. + +--- + +## Flags + +| Flag | Meaning | +|------|---------| +| `-check` | Report what would happen and exit. Nothing is downloaded. Uses the cached answer when it is under an hour old. | +| `-refresh` | Look up the latest release now instead of using the cache. Counts against the hourly limit. | +| `-version TAG` | Install exactly this release, e.g. `-version v0.2.0`. Works for older versions too. | +| `-method auto\|release\|go` | How to install. `auto` (default) uses `release` where a signed asset exists for your platform and `go` otherwise. See [Per-platform behaviour](#per-platform-behaviour). | +| `-force` | Two things: install to the method's location even if this binary lives elsewhere, and reinstall a version that is already installed. | +| `-yes` | Do not ask for confirmation. Required when stdin is not a terminal. | +| `-require-signature` | Fail if the signature could not be verified. Without it, a missing verification tool only prints a warning. | +| `-verbose`, `-debug` | Log to stderr, like the rest of the CLI. `-debug` shows every command run and every HTTP request made. | +| `-h` | Help. | + +Flags take one or two dashes (`-check` and `--check` are the same), like every other `machineid` flag. + +--- + +## Exit codes + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Updated, or already on the latest version, or `-check`, or you declined. | Nothing. | +| `1` | A prerequisite was not met. **Nothing was attempted and nothing changed.** | Read the remedy printed under the error, fix it, run again. | +| `2` | Invalid arguments. | Run `machineid update -h`. | +| `3` | The update was attempted and failed (download, checksum, signature, install). The old binary is left untouched on checksum or signature failure. | Read the message. Retrying is safe. | + +A script can rely on `1` meaning "safe to retry later, nothing happened" and `3` meaning "look at this". + +--- + +## Per-platform behaviour + +| Platform | Default method | Asset | Installs to | Needs root? | +|----------|----------------|-------|-------------|-------------| +| 🐧 Linux amd64 / arm64 | `release` | `machineid-linux-.zip` | **In place**, wherever the running binary is | Only if that directory is not writable by you | +| 🍎 macOS, binary in `/usr/local/bin` | `release` | `machineid-darwin-universal.pkg` (signed, notarized) | `/usr/local/bin` via the system installer | **Yes** (`sudo`), the installer updates the receipt database | +| 🍎 macOS, binary anywhere else (e.g. `go install`) | `release` | `machineid-darwin-universal.zip` (same signed binary) | **In place** | Only if that directory is not writable by you | +| πŸͺŸ Windows | `go` | none published | `GOBIN` via `go install` | No | +| Any, with `-method go` | `go` | source | `GOBIN` (`go env GOBIN`, else `GOPATH/bin`) | No | + +`-method go` needs a Go toolchain on PATH. It builds the release tag from source, so the resulting binary reports the correct version but does not carry the Apple signature or the release build metadata that `-version-long` shows. + +--- + +## Where it installs, and why that matters + +Two of the install methods have a **fixed destination** that ignores where your binary actually is: + +- the macOS `.pkg` always writes `/usr/local/bin/machineid`; +- `go install` always writes `$GOBIN/machineid`. + +If you are running `machineid` from somewhere else, one of those methods would "succeed" while the copy you actually use stays old. `machineid update` refuses that case: + +```text + βœ— install target this method installs to /usr/local/bin, but the binary you are running is /Users/me/go/bin/machineid + +Error: this method installs to /usr/local/bin, but the binary you are running is /Users/me/go/bin/machineid + +Use machineid update -method go to update the copy you are running, or -force to install to /usr/local/bin anyway. +``` + +The remedy names the method that *does* target your copy when there is one. `-force` overrides the guard when you really want the other location populated, for example to put a fresh copy in `/usr/local/bin` from a locally built binary. + +On Linux, and on macOS outside `/usr/local/bin`, there is no fixed destination: the binary is replaced **in place** with an atomic rename, so the only requirement is that its directory is writable. A running process keeps working; the *next* invocation is the new version. + +--- + +## Checking for updates without installing + +```bash +machineid update -check +``` + +```text +Checking for updates… + βœ“ current version v0.2.0 + βœ“ running binary /usr/local/bin/machineid + βœ“ platform supported linux/amd64 + βœ“ latest release v0.3.0 (cached 12m0s ago) + βœ“ install target /usr/local/bin (in place) + +β†’ Updating machineid v0.2.0 β†’ v0.3.0 using the release archive machineid-linux-amd64.zip + (-check: nothing was changed) +``` + +`-check` exits `0` whether or not an update is available. To act on the result in a script, compare versions yourself (see [Using it from scripts](#using-it-from-scripts-cron-and-ci)) or just run `machineid update -yes`, which is a no-op when already current. + +`-check` prefers the cached answer. Add `-refresh` to force a live lookup. + +--- + +## Installing a specific version, or going back + +```bash +machineid update -version v0.2.0 +``` + +An explicit tag is installed whether it is newer or older than what you have, so this is also how to **downgrade**. The tag must exist on the [releases page](https://github.com/slashdevops/machineid/releases) and carry an asset for your platform. + +If you built `machineid` yourself, its version is something like `devel` or a branch name, which cannot be compared with a release. The updater says so and asks you to name a tag: + +```text +Error: the running version "devel" is not a release version, so it cannot be compared with a release + +Name the release to install explicitly, e.g. machineid update -version v0.3.0 +``` + +--- + +## Network use, caching and the hourly limit + +**Only `machineid update` uses the network.** Generating an ID, `-validate`, `-version`, everything else: no requests, ever. There is no background check and no "a new version is available" notice. + +When it does run, the update needs to know the newest release. It asks `https://github.com/slashdevops/machineid/releases/latest`, which answers with a redirect to the newest tag. This is an ordinary page on github.com, not the GitHub API, so it needs no token and is not subject to the API's rate limit. + +To be a good citizen anyway: + +| Rule | Value | +|------|-------| +| The latest-release answer is cached for | **1 hour** | +| Live lookups allowed per user, per rolling hour | **5** | +| Override | `MACHINEID_UPDATE_BUDGET=` (`0` disables the limit, please don't) | +| Downloads | not counted; they only happen after you confirm a newer version | + +What "over the limit" looks like: + +- `machineid update -check` still works and prints the cached answer with the time of the next allowed live lookup. +- `machineid update` (or `-refresh`) refuses with exit `1` and that same time in the remedy. Nothing is downloaded. + +If GitHub itself asks to back off (HTTP 429), the wait it requested is honoured on later runs. + +**Where the state lives.** One small JSON file, readable only by you: + +| OS | Path | +|----|------| +| macOS | `~/Library/Caches/machineid/update-state.json` | +| Linux | `$XDG_CACHE_HOME/machineid/update-state.json`, usually `~/.cache/machineid/update-state.json` | +| Windows | `%LocalAppData%\machineid\update-state.json` | + +It holds the last answer, its timestamp, the timestamps of recent lookups and any backoff. Delete it whenever you like; a missing, unreadable or unwritable file never prevents an update, it only means the limit is not enforced for that run. + +**Proxies.** Standard `HTTPS_PROXY` / `NO_PROXY` environment variables are respected. `github.com` and `*.githubusercontent.com` must be reachable over HTTPS; downloads refuse to follow a redirect anywhere else. + +--- + +## Verification: checksums and signatures + +Every download is checked in two layers. + +**1. SHA-256, always.** The release publishes a `.sha256` next to every asset. The download must match it or nothing is installed. This catches corruption and truncation. + +**2. Signature, when possible.** This proves the asset was produced by this project's release workflow. + +| Platform | How | Tool needed | If the tool is missing | +|----------|-----|-------------|------------------------| +| Linux | Sigstore keyless bundle (`*.sigstore.json`) checked against the release workflow identity `https://github.com/slashdevops/machineid/.github/workflows/release.yml` and issuer `token.actions.githubusercontent.com` | [`cosign`](https://docs.sigstore.dev/cosign/system_config/installation/) | Warning printed, install continues on the strength of SHA-256 + TLS | +| macOS `.pkg` | Apple Developer ID Installer certificate and notarization | `pkgutil` (always present) | n/a | +| macOS `.zip` | Apple code signature on the binary | `codesign` (always present) | n/a | + +To make a skipped signature check a hard failure: + +```bash +machineid update -require-signature +``` + +Recommended on Linux fleets where `cosign` is installed. A signature that **fails** is always fatal, with or without the flag. + +To verify a download by hand, see [macOS signing](macos-signing.md) and [Linux signing](linux-signing.md). + +--- + +## Using it from scripts, cron and CI + +**Non-interactive update:** + +```bash +machineid update -yes +case $? in + 0) echo "up to date" ;; + 1) echo "cannot update yet (see message above); nothing changed" ;; + 3) echo "update failed" ;; +esac +``` + +When stdin is not a terminal and `-yes` is absent, the prompt **declines** rather than blocking, and exits `0` without installing. Always pass `-yes` in automation. + +**Nightly check, install only when a new version exists:** + +```bash +# cron: 0 3 * * * /usr/local/bin/machineid update -yes >> /var/log/machineid-update.log 2>&1 +``` + +`update -yes` is a no-op when already current, so this is safe to run as often as you like; the hourly limit keeps it polite even at high frequency. + +**Pin a version in CI:** + +```bash +machineid update -yes -version v0.3.0 -require-signature +``` + +**macOS with the `.pkg`:** run under `sudo`, or install once with `go install` and let the updater manage that copy in place. + +**Detect "update available" without installing:** + +```bash +current=$(machineid -version | awk '{print $2}') +latest=$(curl -sI https://github.com/slashdevops/machineid/releases/latest | awk -F/ '/^location:/ {print $NF}' | tr -d '\r') +[ "$current" != "$latest" ] && echo "update available: $current -> $latest" +``` + +--- + +## Privacy + +- The only request is to github.com, and only when you run `machineid update`. +- The request carries a `User-Agent` of `machineid/ (+https://github.com/slashdevops/machineid)` and nothing else: no machine ID, no hostname, no telemetry. +- Nothing is written outside the state file above and the binary being replaced. + +--- + +## Troubleshooting + +Every error prints a **remedy** underneath it. The common ones: + +| You see | Meaning | Do this | +|---------|---------|---------| +| `cannot reach https://github.com/...` | No network, or a proxy blocks github.com | Check connectivity / `HTTPS_PROXY`. Exit 1, nothing changed. | +| `the local limit of 5 update checks per 1h0m0s is reached` | You ran it more than five times this hour with live lookups | Use `-check` (cached) or wait until the time shown. | +| `github.com answered HTTP 429 (rate limited)` | GitHub asked to back off | Wait until the time shown; later runs honour it automatically. | +| `this method installs to /usr/local/bin, but the binary you are running is …` | Fixed-destination mismatch | Use the method named in the remedy, or `-force`. See [Where it installs](#where-it-installs-and-why-that-matters). | +| `root privileges are required: the macOS installer updates the system receipt database` | `.pkg` needs root | `sudo machineid update -yes` | +| `/opt/tools is not writable` | In-place replacement needs write access to the directory | Run with enough privileges, or `chown` the directory. | +| `the running version "devel" is not a release version` | Locally built binary | `machineid update -version vX.Y.Z` | +| `release "v9.9.9" was not found` | Typo, or the tag was never published | Check the releases page. | +| `release v0.3.0 does not carry machineid-linux-arm64.zip` | That release was published without your platform's asset | Pick another `-version`, or `-method go`. | +| `does not match its published SHA-256` | Corrupt or tampered download; nothing installed | Retry. If it persists, open an issue. | +| `signature verification of … with cosign failed` | The bundle does not verify | Do **not** install. Retry; if it persists, open an issue. | +| `signature verification … cosign is not installed (-require-signature)` | You demanded a signature but have no `cosign` | Install cosign, or drop the flag. | +| `no release asset is published for windows/amd64` | No Windows binaries yet | `machineid update -method go` (needs Go). | +| `go is not installed` | `-method go` without a toolchain | Install Go, or use `-method release`. | +| `stdin is not a terminal; pass -yes to update without confirmation` | Piped or scripted run without `-yes` | Add `-yes`. | +| `… reports v0.2.0, not the v0.3.0 just installed` | The install landed somewhere other than the binary on your PATH | Check `which machineid`; you probably have two copies. | + +`-debug` prints every command and every request, which is the fastest way to see what a failing run actually did. + +--- + +## FAQ + +**Does `machineid` check for updates on its own?** +No. Never. Only when you run `machineid update`. + +**Does updating change my machine IDs?** +No. IDs depend on the hardware and on the version's collection logic. Release notes call out explicitly if a release changes how any component is collected; the updater itself does not alter anything but the binary. + +**Can I update while `machineid` is running elsewhere?** +Yes. On Linux and macOS the running process keeps its old file open and finishes normally; new invocations use the new binary. On Windows the running `.exe` is renamed to `machineid.exe.old` and cleaned up on the next run. + +**I installed with Homebrew / a package manager.** +Let that manager update it. `machineid update` will refuse to overwrite a location it does not own only if the directory is not writable; if it is writable, it will happily replace the file and your package manager will not know. Prefer one mechanism. + +**Why does the macOS `.pkg` need root when `/usr/local/bin` is writable by me?** +Because `installer` also records the package in the system receipt database (`pkgutil --pkgs`), which is root-only. If you would rather not use `sudo`, install once with `go install` or by unzipping `machineid-darwin-universal.zip`; those copies update in place without root. + +**Can I point it at a mirror or an internal GitHub?** +Not today. The repository and host are fixed in the binary so the signature identity can be pinned. Open an issue if you need this. + +**What about air-gapped machines?** +Download the asset and its `.sha256` on a connected machine, verify by hand (see the signing docs), and replace the binary. `machineid update` needs github.com. diff --git a/internal/selfupdate/budget_test.go b/internal/selfupdate/budget_test.go index ef9fcb9..c22c61c 100644 --- a/internal/selfupdate/budget_test.go +++ b/internal/selfupdate/budget_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" "time" ) @@ -92,7 +93,8 @@ func TestBudgetPersistsAcrossInstances(t *testing.T) { if err != nil { t.Fatal(err) } - if perm := info.Mode().Perm(); perm != 0o600 { + // Windows has no POSIX permission bits to assert on. + if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm != 0o600 { t.Errorf("state file mode = %o, want 600", perm) } } diff --git a/internal/selfupdate/download_test.go b/internal/selfupdate/download_test.go index 97d7328..bf075ab 100644 --- a/internal/selfupdate/download_test.go +++ b/internal/selfupdate/download_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -104,7 +105,7 @@ func TestExtractBinary(t *testing.T) { t.Errorf("extracted %q", got) } info := mustStat(t, out) - if info.Mode().Perm()&0o100 == 0 { + if runtime.GOOS != "windows" && info.Mode().Perm()&0o100 == 0 { t.Error("extracted binary should be executable") } } diff --git a/internal/selfupdate/prereq_test.go b/internal/selfupdate/prereq_test.go index 0d640cf..da62cc2 100644 --- a/internal/selfupdate/prereq_test.go +++ b/internal/selfupdate/prereq_test.go @@ -11,7 +11,7 @@ import ( // binary; gobin is what `go env` reports; uid 0 means root. func newTestChecker(goos, exe, gobin string, uid int, fe *fakeExec) *Checker { fe.on("go version", ExecResult{Stdout: "go version go1.27.1 " + goos + "/arm64"}) - fe.on("go env GOBIN GOPATH", ExecResult{Stdout: "\n" + filepath.Dir(gobin) + "\n"}) + fe.on("go env GOBIN GOPATH", ExecResult{Stdout: gobin + "\n" + filepath.Dir(gobin) + "\n"}) return &Checker{ Exec: fe.run, @@ -184,3 +184,18 @@ func TestGoBinDirPrefersGOBIN(t *testing.T) { t.Errorf("got %q, %v", dir, err) } } + +func TestGoBinDirFallsBackToGOPATH(t *testing.T) { + fe := newFakeExec() + fe.on("go env GOBIN GOPATH", ExecResult{Stdout: "\n/home/u/go\n"}) + c := &Checker{Exec: fe.run} + dir, err := c.goBinDir(context.Background()) + if err != nil || dir != filepath.Join("/home/u/go", "bin") { + t.Errorf("got %q, %v", dir, err) + } + + fe.on("go env GOBIN GOPATH", ExecResult{Stdout: "\n\n"}) + if _, err := c.goBinDir(context.Background()); err == nil { + t.Error("empty GOPATH should be an error") + } +}