Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
46d6d37dc2
130 files changed
+26739
No files matched your search
@@ -0,0 +1,254 @@
|
||||
// Package update finds newer lazysecrets releases and installs one in
|
||||
// place of the running binary: the archive for this OS and CPU, checked
|
||||
// against the release's checksums.txt, tried (--version), then renamed
|
||||
// over the binary. A running lazysecrets keeps its old binary until it
|
||||
// restarts.
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Where releases are published.
|
||||
const (
|
||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
||||
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
||||
)
|
||||
|
||||
// maxDownload bounds what's read from the server.
|
||||
const maxDownload = 200 << 20
|
||||
|
||||
// Release is a published release.
|
||||
type Release struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"` // the changelog section, then install notes
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Notes is the release's changelog: its body without the install notes
|
||||
// every release ends with.
|
||||
func (r Release) Notes() string {
|
||||
notes, _, _ := strings.Cut(r.Body, "## Install")
|
||||
return PlainLinks(strings.TrimSpace(notes))
|
||||
}
|
||||
|
||||
// PlainLinks shows Markdown links as their text, for a terminal.
|
||||
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
||||
|
||||
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
|
||||
// and a terminal shows them as their text.
|
||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||
|
||||
// Client talks to the release server.
|
||||
type Client struct {
|
||||
API string // the repository's API, DefaultAPI when empty
|
||||
Repo string // the repository's web address, DefaultRepo when empty
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
||||
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Latest is the newest release (pre-releases aside).
|
||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var r Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
||||
}
|
||||
if !semver.IsValid(r.Tag) {
|
||||
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Newer reports whether latest is newer than current. A build that isn't
|
||||
// a version (dev-…) is never offered one.
|
||||
func Newer(current, latest string) bool {
|
||||
current, latest = canonical(current), canonical(latest)
|
||||
return semver.IsValid(current) && semver.IsValid(latest) && semver.Compare(latest, current) > 0
|
||||
}
|
||||
|
||||
func canonical(v string) string {
|
||||
if v != "" && !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Managed says which package manager owns exe, if one does: it updates
|
||||
// lazysecrets there, not lazysecrets itself.
|
||||
func Managed(exe string) string {
|
||||
switch {
|
||||
case strings.Contains(exe, "/Cellar/") || strings.HasPrefix(exe, "/opt/homebrew/") || strings.Contains(exe, "/linuxbrew/"):
|
||||
return "Homebrew (brew upgrade lazysecrets)"
|
||||
case strings.HasPrefix(exe, "/nix/store/"):
|
||||
return "Nix"
|
||||
case strings.HasPrefix(exe, "/usr/bin/") || strings.HasPrefix(exe, "/bin/"):
|
||||
return "your system's package manager"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Executable is the running binary's real path.
|
||||
func Executable() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(exe)
|
||||
}
|
||||
|
||||
// Install replaces the binary at exe with r's, for this OS and CPU.
|
||||
func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
if m := Managed(exe); m != "" {
|
||||
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
|
||||
}
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
||||
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
version := strings.TrimPrefix(r.Tag, "v")
|
||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
||||
|
||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, err := checksum(sums, name+".tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
|
||||
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
|
||||
}
|
||||
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Next to the binary, so the rename that replaces it is atomic.
|
||||
dir := filepath.Dir(exe)
|
||||
tmp, err := os.CreateTemp(dir, ".lazysecrets-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't write next to %s (%w): reinstall it with install.sh, or update as the user who owns it", exe, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // gone after the rename; cleans up on failure
|
||||
if _, err := tmp.Write(bin); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// It must run, and be the version asked for, before it replaces
|
||||
// anything.
|
||||
out, err := exec.CommandContext(ctx, tmp.Name(), "--version").Output()
|
||||
if err != nil || !strings.Contains(string(out), version) {
|
||||
return fmt.Errorf("the downloaded %s doesn't run as expected (%q, %v)", r.Tag, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), exe); err != nil {
|
||||
return fmt.Errorf("replacing %s: %w", exe, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > limit {
|
||||
return nil, fmt.Errorf("%s: larger than expected", url)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// checksum finds file's sha256 in a checksums.txt.
|
||||
func checksum(sums []byte, file string) (string, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(sums))
|
||||
for sc.Scan() {
|
||||
if f := strings.Fields(sc.Text()); len(f) == 2 && f[1] == file {
|
||||
return f[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s isn't in checksums.txt", file)
|
||||
}
|
||||
|
||||
// extract reads one file from a .tar.gz.
|
||||
func extract(archive []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("%s isn't in the archive", name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Name == name && h.Typeflag == tar.TypeReg {
|
||||
return io.ReadAll(io.LimitReader(tr, maxDownload))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user