Hello world 👋
Release / Build and publish (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 23:58:25 +02:00
commit 6fab41703e
111 files changed
+22988

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
// 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"
"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 strings.TrimSpace(notes)
}
// 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))
}
}
}
+125
View File
@@ -0,0 +1,125 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestNewer(t *testing.T) {
for _, c := range []struct {
current, latest string
want bool
}{
{"0.1.1", "v0.2.0", true},
{"v0.2.0", "v0.2.0", false},
{"0.3.0", "v0.2.0", false},
{"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.1", false}, // go install of a later commit
{"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.2", true},
{"dev-2ba762c", "v0.2.0", false},
{"dev", "v0.2.0", false},
{"", "v0.2.0", false},
} {
if got := Newer(c.current, c.latest); got != c.want {
t.Errorf("Newer(%q, %q) = %v", c.current, c.latest, got)
}
}
}
func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got)
}
}
func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised")
}
if m := Managed("/Users/x/.local/bin/lazysecrets"); m != "" {
t.Errorf("~/.local/bin is ours: %q", m)
}
}
// server publishes v0.2.0 with a "binary" (a script) printing its version.
func server(t *testing.T, tamper bool) *httptest.Server {
t.Helper()
name := fmt.Sprintf("lazysecrets_0.2.0_%s_%s", runtime.GOOS, runtime.GOARCH)
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
tw := tar.NewWriter(gz)
script := []byte("#!/bin/sh\necho lazysecrets 0.2.0\n")
tw.WriteHeader(&tar.Header{Name: name + "/lazysecrets", Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg})
tw.Write(script)
tw.Close()
gz.Close()
sum := sha256.Sum256(archive.Bytes())
if tamper {
sum[0] ^= 0xff
}
mux := http.NewServeMux()
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
w.Write(archive.Bytes())
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestInstall(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, false)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err)
}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
if err := c.Install(context.Background(), r, exe); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
t.Errorf("not replaced: %q", b)
}
if left, _ := filepath.Glob(filepath.Join(filepath.Dir(exe), ".lazysecrets-update-*")); len(left) > 0 {
t.Errorf("left behind: %v", left)
}
}
func TestInstallRefusesATamperedArchive(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, true)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
if err == nil || !strings.Contains(err.Error(), "doesn't match checksums.txt") {
t.Fatalf("err %v", err)
}
if b, _ := os.ReadFile(exe); string(b) != "old" {
t.Error("replaced anyway")
}
}