// This file implements pigo's binary self-replacement for `pigo update` (issue // #466). Given the current build version it discovers the latest release (via // version.go), downloads the matching goreleaser archive for the running // GOOS/GOARCH, verifies its SHA256 against the release's checksums.txt, and // atomically replaces the running executable. // // The archive naming mirrors .goreleaser.yaml and install.sh exactly, so this // stays a single source of truth with the release tooling. Replacement is // atomic: the new binary is written to a temp file in the target's directory // and os.Rename'd over the current executable, so a failure mid-download never // leaves a truncated binary in place. package selfupdate import ( "archive/tar" "archive/zip" "bufio" "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" ) // checksumsFile is the goreleaser checksums artifact name (see .goreleaser.yaml). const checksumsFile = "checksums.txt" // Updater performs a self-replacement. Its fields are seams for testing; // NewUpdater fills them with production defaults. type Updater struct { HTTPClient *http.Client // Repo is "owner/name"; ReleaseBaseURL overrides the download host in tests. Repo string ReleaseBaseURL string // e.g. https://github.com/smallnest/pigo/releases/download GOOS, GOARCH string // ExecPath is the executable to replace; defaults to os.Executable(). ExecPath string } // Run performs `pigo update` (self-update pigo). It discovers the latest // release, compares it to current, and replaces the running binary when a // newer release exists. When current is a source build ("dev"), it cannot // compare and proceeds to install the latest. Returns a process exit code. func Run(ctx context.Context, current string, out, errOut io.Writer) int { tag, err := LatestTag(ctx, nil, Repo) if err != nil { fmt.Fprintf(errOut, "pigo: failed to check for updates: %v\n", err) return 1 } if avail, comparable := UpdateAvailable(current, tag); comparable && !avail { fmt.Fprintf(out, "already up to date at %s\n", current) return 0 } u, err := NewUpdater() if err != nil { fmt.Fprintf(errOut, "pigo: %v\n", err) return 1 } if err := u.Apply(ctx, tag, out); err != nil { fmt.Fprintf(errOut, "pigo: %v\n", err) return 1 } fmt.Fprintf(out, "updated to %s\n", tag) return 0 } // NewUpdater returns an Updater configured for the running process. func NewUpdater() (*Updater, error) { exe, err := os.Executable() if err != nil { return nil, fmt.Errorf("selfupdate: locate executable: %w", err) } // Resolve symlinks so we replace the real file, not a symlink. if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } return &Updater{ HTTPClient: &http.Client{Timeout: 60 * time.Second}, Repo: Repo, ReleaseBaseURL: "https://github.com/" + Repo + "/releases/download", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, ExecPath: exe, }, nil } // archiveName builds the goreleaser archive filename for a release version // (without leading "v") on the updater's platform. It mirrors the // name_template and format_overrides in .goreleaser.yaml. func (u *Updater) archiveName(versionNoV string) string { osName := map[string]string{"darwin": "Darwin", "linux": "Linux", "windows": "Windows"}[u.GOOS] if osName == "" { osName = u.GOOS } arch := map[string]string{"amd64": "x86_64", "386": "i386"}[u.GOARCH] if arch == "" { arch = u.GOARCH // arm64 and others pass through } ext := "tar.gz" if u.GOOS == "windows" { ext = "zip" } return fmt.Sprintf("pigo_%s_%s_%s.%s", versionNoV, osName, arch, ext) } // binaryName is the executable name inside the archive. func (u *Updater) binaryName() string { if u.GOOS == "windows" { return "pigo.exe" } return "pigo" } // Apply downloads the release identified by tag, verifies its checksum, and // atomically replaces the target executable. tag is like "v0.4.0". func (u *Updater) Apply(ctx context.Context, tag string, out io.Writer) error { versionNoV := strings.TrimPrefix(strings.TrimSpace(tag), "v") archive := u.archiveName(versionNoV) base := fmt.Sprintf("%s/%s", strings.TrimRight(u.ReleaseBaseURL, "/"), tag) fmt.Fprintf(out, "downloading %s ...\n", archive) archiveBytes, err := u.download(ctx, base+"/"+archive) if err != nil { return fmt.Errorf("selfupdate: download archive: %w", err) } sums, err := u.download(ctx, base+"/"+checksumsFile) if err != nil { return fmt.Errorf("selfupdate: download checksums: %w", err) } want, err := checksumFor(sums, archive) if err != nil { return err } got := sha256.Sum256(archiveBytes) if hex.EncodeToString(got[:]) != want { return fmt.Errorf("selfupdate: checksum mismatch for %s (archive corrupt or tampered)", archive) } binary, err := extractBinary(archiveBytes, u.binaryName(), u.GOOS == "windows") if err != nil { return err } if err := u.replace(binary); err != nil { return err } return nil } // download fetches url and returns the full body. A non-200 status is an error. func (u *Updater) download(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := u.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } return io.ReadAll(resp.Body) } // replace atomically swaps the target executable with newBin: it writes a temp // file in the target's directory, sets it executable, and renames it over the // target. Writing to the same directory keeps the rename atomic (same // filesystem). A permission error on the directory yields an actionable message. func (u *Updater) replace(newBin []byte) error { dir := filepath.Dir(u.ExecPath) tmp, err := os.CreateTemp(dir, ".pigo-update-*") if err != nil { return fmt.Errorf("selfupdate: cannot write to %s: %w (try running with sudo, or install pigo to a writable directory)", dir, err) } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op after successful rename if _, err := tmp.Write(newBin); err != nil { tmp.Close() return fmt.Errorf("selfupdate: write new binary: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("selfupdate: close new binary: %w", err) } if err := os.Chmod(tmpName, 0o755); err != nil { return fmt.Errorf("selfupdate: chmod new binary: %w", err) } if err := os.Rename(tmpName, u.ExecPath); err != nil { return fmt.Errorf("selfupdate: replace %s: %w", u.ExecPath, err) } return nil } // checksumFor finds the hex SHA256 for archive in a goreleaser checksums.txt // body (lines of " "). func checksumFor(sums []byte, archive string) (string, error) { sc := bufio.NewScanner(bytes.NewReader(sums)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) == 2 && fields[1] == archive { return fields[0], nil } } return "", fmt.Errorf("selfupdate: %s not found in checksums.txt", archive) } // extractBinary pulls the named binary out of an archive (tar.gz, or zip when // isZip). It returns the binary bytes. func extractBinary(archive []byte, name string, isZip bool) ([]byte, error) { if isZip { return extractFromZip(archive, name) } return extractFromTarGz(archive, name) } func extractFromTarGz(archive []byte, name string) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(archive)) if err != nil { return nil, fmt.Errorf("selfupdate: gzip reader: %w", err) } defer gz.Close() tr := tar.NewReader(gz) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("selfupdate: read tar: %w", err) } if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg { return io.ReadAll(tr) } } return nil, fmt.Errorf("selfupdate: %s not found in archive", name) } func extractFromZip(archive []byte, name string) ([]byte, error) { zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) if err != nil { return nil, fmt.Errorf("selfupdate: zip reader: %w", err) } for _, f := range zr.File { if filepath.Base(f.Name) == name { rc, err := f.Open() if err != nil { return nil, fmt.Errorf("selfupdate: open %s in zip: %w", name, err) } defer rc.Close() return io.ReadAll(rc) } } return nil, fmt.Errorf("selfupdate: %s not found in archive", name) }