From 94f5ec969dd43143a977ad5466460285693e26fc Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Wed, 12 Aug 2026 17:03:43 +0200 Subject: [PATCH] [mike@mwxm4] --- .gitignore | 1 + README.md | 47 +++- build.go | 2 +- build.sh | 97 +++++++++ gbld.go | 110 +++------- gbld_test.go | 114 +++++++--- go.mod | 4 - go.sum | 18 -- selfupdate.go | 585 ++++++++++++++++++++++++++++++++++++++++++++++++++ tools.go | 77 +------ version.txt | 1 + 11 files changed, 847 insertions(+), 209 deletions(-) create mode 100755 build.sh create mode 100644 selfupdate.go create mode 100644 version.txt diff --git a/.gitignore b/.gitignore index 5057663..533e5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ bin/* tmp/* gbld +.mgshrc diff --git a/README.md b/README.md index e1bb58a..3e9ef59 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ - ๐Ÿงน **Auto-Import**: Runs `goimports` on save (optional via `-i`) to keep your imports clean โ€” without touching the rest of your formatting. - ๐Ÿ–ฅ๏ธ **Interactive Menu**: Uses a select menu (`gbld.menu.json`) if run without arguments, letting you define and run custom build tasks. - ๐Ÿš€ **Cross-Compilation**: Easily builds binaries for multiple target operating systems (macOS, Linux, Windows) and architectures (amd64, arm64) using a single command. -- โ˜๏ธ **Remote Uploads**: Integrated command to upload build artifacts to a deployment server (`gozilla`). -- ๐Ÿ” **Verified Self-Update**: Downloads updates over HTTPS and refuses to install anything that does not match its published SHA-256 checksum. +- ๐Ÿ” **Verified Self-Update**: Installs new versions from the Gitea releases over HTTPS and refuses anything that does not match its published SHA-256 checksum. --- @@ -53,15 +52,16 @@ gbld [flags] [target_name] | `-o` | `` | Space-separated arguments to pass to the compiled binary on execution. | | `-c` | `` | Command to execute immediately after a successful build (before execution). | | `-a` | None | Builds binaries for all supported platforms (Windows, macOS, Linux for AMD64/ARM64). | -| `-u` | None | Uploads built binaries via SSH/SCP to the `gozilla` server. | | `-v` | None | Displays the current version of `gbld`. | | `-h` | None | Displays help and usage information. | +| `--check-update` | None | Looks for a newer release and reports what it finds โ€” installs nothing. | +| `--update` | None | Downloads the newest release and replaces the running binary with it. | ### Environment | Variable | Description | | :--- | :--- | -| `GBLD_NO_UPDATE` | Set to any value to skip the update check at startup (useful offline or in CI). | +| `GBLD_NO_UPDATE` | Set to any value to silence the background update check (useful offline or in CI). `--update` and `--check-update` still work. | --- @@ -85,7 +85,7 @@ To make running common tasks easier, you can place a `gbld.menu.json` file in yo When compiling, `gbld` parses `build.go` to find a pattern matching `var build = ""`. It increments this number by 1 and updates the file automatically, leaving the rest of the line (including trailing comments) untouched. This is useful for stamping build numbers/versions inside your binary. If there is no `build.go`, this step is silently skipped. ### Target Version -`-a` and `-u` name their artifacts after the first `var version = "x.y.z"` found in the `*.go` files of the project. Ordinary builds do not need this variable. +`-a` names its artifacts after the first `var version = "x.y.z"` found in the `*.go` files of the project. Ordinary builds do not need this variable. ### Automatic Backups Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace (the directory is created on demand): @@ -116,22 +116,49 @@ With `-1`, `gbld` is usable from scripts and CI: --- -## Cross-Compilation and Upload +## Cross-Compilation ```bash gbld -a # build bin/___ for all 6 targets -gbld -u # upload them to gozilla ``` `-a` writes Windows artifacts with an `.exe` suffix and additionally produces: - `bin/checksums.txt` โ€” SHA-256 of every artifact, in the format used by `shasum -a 256 -c` -- `bin/version.txt` โ€” the published version +- `bin/version.txt` โ€” the version that was built -If any target fails to build, `version.txt` is **not** written and `gbld` exits with `1`, so a half-published version can never be announced. `-u` refuses build names containing anything but `A-Za-z0-9._-` and uploads the artifacts together with `checksums.txt` and `version.txt`. +If any target fails to build, `version.txt` is **not** written and `gbld` exits with `1`, so a half-published version can never be announced. + +> [!NOTE] +> `-a` is the path for the programs `gbld` builds. `gbld` itself is released with `build.sh` (see below), which uses a different naming โ€” both write `bin/checksums.txt`, so whichever ran last is the one that file describes. + +--- + +## Releasing gbld itself + +```bash +./build.sh # steps the patch version, builds every platform +VERSION=1.22.0 ./build.sh # a minor or major step is named outright +PLATFORMS="linux/amd64" ./build.sh +``` + +`build.sh` writes into `bin/` exactly what a release needs: + +- `gbld--` โ€” the binaries, `.exe` for Windows, built with `-trimpath -s -w` and the version injected via `-ldflags` +- `checksums.txt` โ€” their SHA-256, in the format of `shasum -a 256` + +`version.txt` holds the version just built and is stepped by `0.0.1` on every run. Upload **all** of these files to a Gitea release whose tag is the bare version number (`1.21.1`, a leading `v` is allowed) โ€” that is the layout `--update` expects. + +> [!IMPORTANT] +> `gbld -a` names its artifacts from `var version` in the sources, `build.sh` from `version.txt`, and nothing writes to a `.go` file. For gbld's own releases `build.sh` is the way โ€” `var version` only shows up in a bare `go build`. ### Self-Update -On startup `gbld` fetches `version.txt` from the update server (3 s timeout โ€” an unreachable server never blocks a build). If a newer version is published, it asks before downloading, then verifies the download against the SHA-256 from `checksums.txt` before replacing itself. **If no matching checksum is published, the update is refused.** Publish new versions with `gbld -a` followed by `gbld -u`. + +`selfupdate.go` holds the whole mechanism and is meant to be copied into other programs: adjust the block at its head, hang the two options into the flags, done. It needs nothing but the standard library. + +Once a day, in the background, `gbld` asks the Gitea for the newest release and notes the answer in the cache directory. The next run reads that note and prints one line if something newer exists โ€” the foreground never touches the network, so an unreachable server can never delay a build. The hint appears only on a terminal: in a pipe, a script or under cron, `gbld` stays quiet, as it does with `GBLD_NO_UPDATE` set. + +`--update` fetches the asset for the running platform, weighs it against the SHA-256 from the release's `checksums.txt`, and only then replaces the running binary โ€” after calling the fresh one once with `-v` to be sure it runs at all. **A release without a matching checksum is refused**, and a download that does not match is deleted, not installed. The certificate of the Gitea is verified like any other. --- diff --git a/build.go b/build.go index 983c9eb..78f40d3 100644 --- a/build.go +++ b/build.go @@ -1,3 +1,3 @@ package main -var build = "368" +var build = "369" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..a9ffed7 --- /dev/null +++ b/build.sh @@ -0,0 +1,97 @@ +#!/bin/sh +# Build gbld for the usual platforms into ./bin, auto-incrementing the patch +# version by 0.0.1 on every build. +# +# version.txt holds the currently built version. Each run increments the patch +# component, then builds every platform with that one version injected via +# -ldflags, and writes it back. So version.txt always reflects the version of +# the binaries just built, and all of them carry the same one. +# +# What ends up in ./bin is exactly what a release wants: +# +# gbld-- the binaries, '.exe' for windows +# checksums.txt their sha256, in the format of `shasum -a 256` +# +# selfupdate.go looks for both. Upload all of them to a Gitea release whose tag +# is the bare version number, and --update finds them; without checksums.txt it +# refuses to install anything, so do not leave it behind. +# +# This is not the naming of gbld's own -a, which builds +# "___" โ€” that path is for the programs gbld builds, +# and it reads 'var version' out of the sources rather than version.txt. +# Both write bin/checksums.txt, each for its own set of names. +# +# Override the platform list to build just one: +# PLATFORMS="linux/amd64" ./build.sh +# +# A plain run only ever steps the patch. A minor or major step is taken by +# naming the version outright, which is then written back like any other: +# VERSION=1.22.0 ./build.sh +# +# build.go carries a separate build counter that shows up in -v next to the +# version; nothing here touches it โ€” gbld itself steps that one on every +# compile it does. +# +# -s -w drops the symbol table and DWARF info, -trimpath keeps build paths out +# of the binary; together they roughly halve it. Neither affects a panic trace. +set -e +cd "$(dirname "$0")" + +PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"} + +if [ -n "$VERSION" ]; then + NV="$VERSION" +else + V=$(cat version.txt 2>/dev/null || echo 1.21.0) + + # split MAJOR.MINOR.PATCH and increment PATCH (no carry: 1.21.9 -> 1.21.10) + MAJOR=${V%%.*} + REST=${V#*.} + MINOR=${REST%%.*} + PATCH=${REST#*.} + PATCH=$((PATCH + 1)) + NV="$MAJOR.$MINOR.$PATCH" +fi + +# Linux brings sha256sum, macOS brings shasum; both write " ". +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + else + shasum -a 256 "$@" + fi +} + +mkdir -p bin +HOST="$(go env GOOS)/$(go env GOARCH)" +BUILT="" + +for p in $PLATFORMS; do + os=${p%/*} + arch=${p#*/} + name="gbld-$os-$arch" + [ "$os" = "windows" ] && name="$name.exe" + + # CGO_ENABLED=0 throughout: it makes the cross builds work without a + # toolchain per target and the binaries static. gbld only needs the + # filesystem, the network and a shell, so nothing is lost by dropping cgo. + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "-s -w -X main.version=$NV" -o "bin/$name" . + + BUILT="$BUILT $name" + + if [ "$p" = "$HOST" ]; then + ln -sf "$name" bin/gbld # the one for this machine + echo " bin/$name -> bin/gbld" + else + echo " bin/$name" + fi +done + +# Only what this run built: a checksum for a file from an earlier version would +# be a checksum for a binary nobody is publishing. +(cd bin && sha256 $BUILT > checksums.txt) +echo " bin/checksums.txt" + +echo "$NV" > version.txt +echo "built gbld v$NV" diff --git a/gbld.go b/gbld.go index 6873abb..63a8874 100644 --- a/gbld.go +++ b/gbld.go @@ -24,11 +24,12 @@ import ( "github.com/fsnotify/fsnotify" ) -var version = "1.21.0" - -var UPDATEURL = "https://gozilla.fhi.mpg.de/gbld" -var UPLOADHOST = "root@gozilla" -var UPLOADPATH = "/db/www" +// version is a var, not a const, so build.sh can put the built number in via +// -ldflags "-X main.version=โ€ฆ". The value here only ever shows up in a bare +// `go build`; the version a release carries is the one in version.txt. Note +// that -a below names its artifacts from this line and not from version.txt โ€” +// for gbld's own releases build.sh is the way, not -a. +var version = "1.22.0" var oses = []string{"darwin", "linux", "windows"} var arches = []string{"arm64", "amd64"} @@ -49,7 +50,6 @@ var r3 = regexp.MustCompile(`\.go$`) // go sour var r4 = regexp.MustCompile(`(?m)^(\s*var\s+build\s*=\s*")([^"]*)(")`) // build counter var r5 = regexp.MustCompile(`(?im)^\s*var\s+version\s*=\s*"([^"]*)"`) // target version var r6 = regexp.MustCompile(`(?s)import\s*\((.*?)\)`) // import block -var r7 = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // safe build name type opts struct { // ------------------------------------------------------------------------- command line options buildonly bool @@ -80,7 +80,11 @@ func main() { // =============================================================== opt_a := flag.Bool("a", false, "") opt_v := flag.Bool("v", false, "") opt_h := flag.Bool("h", false, "") - opt_u := flag.Bool("u", false, "") + + opt_update := flag.Bool("update", false, "") // selfupdate.go + opt_checkupdate := flag.Bool("check-update", false, "") + opt_refresh := flag.Bool(strings.TrimPrefix(updateRefreshFlag, "--"), false, "") // background run, not in the + // help, name tied to the const flag.Usage = func() { P() @@ -88,7 +92,7 @@ func main() { // =============================================================== P() P(Cw("Usage:")) P(Cy(" gbld [-b] [-1] [-i] [-x] [-c ] [-o ] [build name]")) - P(Cy(" gbld [-a] [-u]")) + P(Cy(" gbld [-a]")) P(Cw(" -b build only")) P(Cw(" -1 run only once, no watching (exit code: build error or exit code of the build)")) P(Cw(" -i run goimports before build")) @@ -98,7 +102,8 @@ func main() { // =============================================================== P(Cw(" -v show version")) P(Cw(" -h show help")) P(Cw(" -a build for all platforms")) - P(Cw(" -u upload builds to " + UPLOADHOST)) + P(Cw(" --check-update look for a newer release")) + P(Cw(" --update download and install the newest release")) P() P(Cw(" ยท if no build name is supplied the current directory name will be used")) P() @@ -120,18 +125,14 @@ func main() { // =============================================================== o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x, postcmd: *opt_c, runargs: *opt_o} - if *opt_v { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท show version - info() - os.Exit(0) - } - - if *opt_h { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท show help - flag.Usage() - os.Exit(0) - } - - if os.Getenv("GBLD_NO_UPDATE") == "" { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท check for a newer gbld version - checkforupdate(UPDATEURL) + // These run before the toolchain is looked for: none of them needs 'go' or // no go, + // 'goimports', and selfupdate.go probes a fresh download with 'gbld -v', // no goimports + // which therefore has to work on a machine that has neither. + if (*opt_refresh) { selfUpdate.refresh(); return // update options + } else if (*opt_update) { runupdate(selfUpdate.install); return + } else if (*opt_checkupdate) { runupdate(selfUpdate.check); return + } else if (*opt_v) { info(); return + } else if (*opt_h) { flag.Usage(); return } GO = lookup("go") // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท check for the required toolchain @@ -157,14 +158,6 @@ func main() { // =============================================================== if *opt_a { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท build for other platforms info() buildall(name) - } - - if *opt_u { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท upload builds to gozilla - info() - uploadall(name) - } - - if *opt_a || *opt_u { os.Exit(0) } @@ -274,6 +267,14 @@ func buildAndRun(name, file string, o opts) bool { // ------------------------- PN("\033[2J\033[1;1H") } info() + + // Costs nothing: the hint comes from the note in the cache, the asking + // happens once a day at most and in the background. It stands after the + // screen is cleared, or every rebuild would wipe it away again. + if hint := selfUpdate.daily(); hint != "" { + P(Cy(hint)) + } + P(Cc("--- start compiling " + file + " ---")) stopRunning() @@ -478,58 +479,11 @@ func buildall(name string) { // ------------------------------------------------ PO("all builds done", targetversion) } -func uploadall(name string) { // ------------------------------------------------------- upload builds to gozilla - if !r7.MatchString(name) { // the name ends up in a remote shell command - PE("invalid build name", name) - os.Exit(1) - } - - targetversion, err := gettargetversion() - if err != nil { +func runupdate(task func(io.Writer) error) { // ----------------------------------- run a task from selfupdate.go + if err := task(os.Stdout); err != nil { PE(err.Error()) os.Exit(1) } - - files := []string{} - for _, osys := range oses { - for _, arch := range arches { - ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch)) - if fileExists(ofile) { - files = append(files, ofile) - } - } - } - if len(files) == 0 { - PE("no builds found for version "+targetversion, "run 'gbld -a' first") - os.Exit(1) - } - for _, f := range []string{"bin/checksums.txt", "bin/version.txt"} { - if !fileExists(f) { - PE("missing "+f, "run 'gbld -a' first") - os.Exit(1) - } - files = append(files, f) - } - - P(Ccb("upload builds to "+UPLOADHOST), Cwb(targetversion)) - dst := UPLOADPATH + "/" + name - - if err := run("ssh", UPLOADHOST, SF("mkdir -p '%s' && rm -f '%s'/*_*_*", dst, dst)); err != nil { - os.Exit(1) - } - if err := run(append([]string{"scp"}, append(files, UPLOADHOST+":"+dst)...)...); err != nil { - os.Exit(1) - } - PO("upload done", targetversion) -} - -func run(args ...string) error { // ------------------------------------------------- run a command, report failure - P(Cy(strings.Join(args, " "))) - out, err := exec.Command(args[0], args[1:]...).CombinedOutput() - if err != nil { - PE(args[0]+" failed", strings.TrimSpace(string(out))) - } - return err } func artifact(name, version, osys, arch string) string { // ------------------------------ name of a build artifact diff --git a/gbld_test.go b/gbld_test.go index 3a492fa..d526e64 100644 --- a/gbld_test.go +++ b/gbld_test.go @@ -2,6 +2,9 @@ package main import ( + "context" + "crypto/sha256" + "encoding/hex" "net/http" "net/http/httptest" "os" @@ -142,65 +145,126 @@ func TestFileExists(t *testing.T) { // --------------------------------- must no } } -func TestGetchecksum(t *testing.T) { // ------------------------------------- checksums are read and validated - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/checksums.txt" { - http.NotFound(w, r) - return - } - w.Write([]byte("aabb gbld_1.0.0_linux_amd64\n" + - "0f0f gbld_1.0.0_windows_amd64.exe\n" + - "zzzz broken_sum\n")) - })) - defer srv.Close() +func TestUpdateFindSum(t *testing.T) { // ----------------------------------- checksums are read and validated + good := strings.Repeat("ab", 32) // 32 bytes, as a sha256 has + body := []byte(good + " gbld-linux-amd64\n" + + good + " *gbld-windows-amd64.exe\n" + // binary mode marks the name with '*' + "zzzz broken_sum\n") - client := &http.Client{Timeout: 5 * time.Second} - - sum, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_linux_amd64") + sum, err := updateFindSum(body, "gbld-linux-amd64") if err != nil { t.Fatal(err) } - if len(sum) != 2 || sum[0] != 0xaa || sum[1] != 0xbb { + if hex.EncodeToString(sum) != good { t.Errorf("wrong checksum decoded: %x", sum) } - if _, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_darwin_arm64"); err == nil { + if _, err := updateFindSum(body, "gbld-windows-amd64.exe"); err != nil { + t.Errorf("the '*' of binary mode belongs to the format, not to the name: %v", err) + } + if _, err := updateFindSum(body, "gbld-darwin-arm64"); err == nil { t.Error("a missing entry must be an error - never update without a checksum") } - if _, err := getchecksum(client, srv.URL+"/checksums.txt", "broken_sum"); err == nil { + if _, err := updateFindSum(body, "broken_sum"); err == nil { t.Error("a malformed checksum must be an error") } - if _, err := getchecksum(client, srv.URL+"/missing.txt", "gbld_1.0.0_linux_amd64"); err == nil { - t.Error("a missing checksums.txt must be an error") + if _, err := updateFindSum([]byte(strings.Repeat("ab", 16)+" gbld-linux-amd64\n"), "gbld-linux-amd64"); err == nil { + t.Error("a hash of the wrong length must be an error") } } -func TestDoupdateRejectsWrongChecksum(t *testing.T) { // ------------- a manipulated download is never applied +func TestUpdateChecksum(t *testing.T) { // ------------------------- the checksum comes out of the release itself + want := strings.Repeat("cd", 32) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("this is not the binary you asked for")) + w.Write([]byte(want + " gbld-linux-amd64\n")) })) defer srv.Close() - err := doupdate(&http.Client{Timeout: 5 * time.Second}, srv.URL+"/gbld", []byte{0xde, 0xad, 0xbe, 0xef}) + rel := updateRelease{TagName: "v1.2.3", Assets: []updateAsset{ + {Name: "gbld-linux-amd64", URL: srv.URL + "/gbld-linux-amd64"}, + {Name: updateChecksums, URL: srv.URL + "/" + updateChecksums}, + }} + + sum, err := selfUpdate.checksum(rel, "gbld-linux-amd64") + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(sum) != want { + t.Errorf("wrong checksum: %x", sum) + } + + bare := updateRelease{TagName: "v1.2.3", Assets: rel.Assets[:1]} // no checksums.txt published + if _, err := selfUpdate.checksum(bare, "gbld-linux-amd64"); err == nil { + t.Error("a release without checksums must be refused") + } +} + +func TestUpdateDownloadRejectsWrongChecksum(t *testing.T) { // ------ a manipulated download is never installed + body := []byte("this is not the binary you asked for") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(body) + })) + defer srv.Close() + + dir := t.TempDir() + exe := filepath.Join(dir, "gbld") + a := &updateAsset{Name: "gbld-linux-amd64", Size: int64(len(body)), URL: srv.URL + "/gbld-linux-amd64"} + + _, err := selfUpdate.download(a, exe, 0755, []byte{0xde, 0xad, 0xbe, 0xef}) if err == nil { - t.Fatal("update with a wrong checksum was accepted") + t.Fatal("a download with a wrong checksum was accepted") } if !strings.Contains(strings.ToLower(err.Error()), "checksum") { t.Errorf("expected a checksum error, got: %v", err) } + + left, _ := filepath.Glob(filepath.Join(dir, "*")) + if len(left) != 0 { + t.Errorf("the rejected download was left behind: %v", left) + } + + sum := sha256.Sum256(body) // the same bytes, now with the checksum that fits + tmp, err := selfUpdate.download(a, exe, 0755, sum[:]) + if err != nil { + t.Fatalf("a matching checksum was refused: %v", err) + } + os.Remove(tmp) } -func TestFetchStatus(t *testing.T) { // -------------------------------------- http errors must not be swallowed +func TestUpdateGetStatus(t *testing.T) { // ---------------------------------- http errors must not be swallowed srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", http.StatusInternalServerError) })) defer srv.Close() - if _, err := fetch(&http.Client{Timeout: 5 * time.Second}, srv.URL, 1<<20); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, err := updateGet(ctx, srv.URL); err == nil { t.Error("http 500 was reported as success") } } +func TestUpdateCompare(t *testing.T) { // ------------------- versions are numbers, not strings: 1.21.10 > 1.21.9 + for _, c := range []struct { + a, b string + want int + }{ + {"1.21.10", "1.21.9", 1}, + {"v1.21.0", "1.21.0", 0}, + {"1.21", "1.21.0", 0}, + {"1.21.0-rc1", "1.21.0", -1}, // not finished yet + {"1.9.0", "1.10.0", -1}, + } { + if got := updateCompare(c.a, c.b); got != c.want { + t.Errorf("updateCompare(%q,%q) = %d, want %d", c.a, c.b, got, c.want) + } + if got := updateCompare(c.b, c.a); got != -c.want { + t.Errorf("updateCompare(%q,%q) = %d, want %d", c.b, c.a, got, -c.want) + } + } +} + func TestToolbox(t *testing.T) { // ------------------------------------------------ toolbox edge cases if got := Shortstr("abcdef", 4); got != "ab.." { t.Errorf("Shortstr: got %q", got) diff --git a/go.mod b/go.mod index 8a3d022..d32789f 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,15 @@ go 1.26.1 require ( github.com/AlecAivazis/survey/v2 v2.3.7 - github.com/Masterminds/semver/v3 v3.5.0 github.com/eknkc/basex v1.0.1 github.com/fatih/color v1.19.0 github.com/fsnotify/fsnotify v1.10.1 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d - github.com/minio/selfupdate v0.6.0 github.com/spf13/viper v1.21.0 github.com/tidwall/gjson v1.19.0 ) require ( - aead.dev/minisign v0.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.15 // indirect @@ -29,7 +26,6 @@ require ( github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum index 686e84a..b4a4f5c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,5 @@ -aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= -aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= -aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= @@ -41,8 +36,6 @@ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= -github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -75,38 +68,27 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= diff --git a/selfupdate.go b/selfupdate.go new file mode 100644 index 0000000..2536dac --- /dev/null +++ b/selfupdate.go @@ -0,0 +1,585 @@ +// selfupdate.go โ€” updating oneself from the releases of a Gitea instance. +// +// The file is meant to be copied: take it into another program, adjust the +// configuration block below, hang `--update` and `--check-update` into the +// options โ€” done. It needs nothing but the standard library, and apart from +// that block it brings no names that do not begin with "selfUpdate" or +// "update". +// +// It assumes the layout build.sh produces: one release per version, whose tag +// is the bare number (1.21.4, a leading "v" is allowed), holding one asset +// "--" each โ€” that is, exactly the files from ./bin. Under +// /api/v1/repos///releases/latest Gitea hands out the newest +// release that is neither a draft nor a prerelease; GitHub speaks the same +// route with different field names and is therefore not covered. +// +// Two things this copy does differently from the one in dns, both deliberate: +// +// - The release has to publish a "checksums.txt" โ€” the file build.sh writes, +// in the format of `shasum -a 256`. What is downloaded is weighed against +// the sha256 named there for exactly this asset, and no checksum means no +// update. gbld had that property before it fetched from a gitea, and it +// does not give it up. +// - The TLS certificate is verified, as it is everywhere else. dns turns +// that off for machines with an outdated trust store; if you copy this +// file to such a machine, that is the line to revisit โ€” knowing that +// anyone in the network path can then hand out both the binary and the +// checksum that matches it. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// ------------------------------------------------------------ Configuration + +var selfUpdate = selfUpdater{ + repo: "https://git.fhi.mpg.de/mike/gbld", + asset: "gbld", + current: version, // from gbld.go, set by -ldflags + verify: []string{"-v"}, + every: 24 * time.Hour, + quietEnv: "GBLD_NO_UPDATE", +} + +// updateChecksums is the asset holding the sha256 of every other asset, in the +// format of `shasum -a 256`. A release without it is a release this program +// refuses to install: what cannot be weighed is not put in place. +const updateChecksums = "checksums.txt" + +type selfUpdater struct { + repo string // repo URL as in the browser: https://host/owner/repo + asset string // base name of the assets, "--" is added + current string // the running version + verify []string // trial run of the download; empty skips it + every time.Duration // how often to look on its own; 0 turns that off + quietEnv string // this environment variable set: keep quiet as well +} + +// updateRefreshFlag is the option the program calls itself with, in the +// background. It is deliberately absent from the help. +const updateRefreshFlag = "--update-refresh" + +// ------------------------------------------------------------ Looking by itself + +// daily is the hook for the ordinary run of the program. It costs nothing: in +// the foreground the network is never touched. What comes back is the line +// pointing at a new version โ€” or "", when there is nothing to say; what it +// looks like is up to the caller. Should the note be older than `every`, daily +// starts a background run on the side, whose answer the next call will find +// waiting. +func (u selfUpdater) daily() string { + if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() { + return "" + } + st := u.loadState() // no file: the zero value, hence due at once + + if time.Since(st.Checked) >= u.every { + // The timestamp moves on before the asking, not after: otherwise two + // simultaneous runs start two queries, and a server that is not in the + // mood would get a new one on every call. If the note does not stay + // put, nothing is asked either โ€” else an unwritable cache directory + // would mean one process per call. + st.Checked = time.Now() + if u.saveState(st) == nil { + u.spawnRefresh() + } + } + + if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 { + return "" + } + return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset) +} + +// refresh is the background run: ask, write it down, stay quiet. The writing +// down is done by latest; if the query fails, the old state remains. +func (u selfUpdater) refresh() { + _, _ = u.latest() +} + +// spawnRefresh calls this program once more, only to ask, and does not wait. +// Without a Wait the child is adopted by init when this process ends โ€” it thus +// outlives the call, and the call's output stays untouched by it. +func (u selfUpdater) spawnRefresh() { + exe, err := os.Executable() + if err != nil { + return + } + cmd := exec.Command(exe, updateRefreshFlag) + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null + if cmd.Start() == nil { + cmd.Process.Release() + } +} + +// The hint is meant for the person sitting there. Running in a pipe, in a +// script or under cron, the program neither asks nor says anything. +func updateOnTerminal() bool { + st, err := os.Stderr.Stat() + return err == nil && st.Mode()&os.ModeCharDevice != 0 +} + +// --------------------------------------------------------------------- Note + +// updateState is what is left between two calls: when the last question was +// asked and what came of it. +type updateState struct { + Checked time.Time `json:"checked"` + Latest string `json:"latest"` +} + +// The note lives in the cache directory, not in the configuration: if it gets +// lost, the only cost is asking once too early. +func (u selfUpdater) statePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, u.asset, "update.json"), nil +} + +func (u selfUpdater) loadState() updateState { + var st updateState + path, err := u.statePath() + if err != nil { + return st + } + b, err := os.ReadFile(path) + if err != nil { + return st + } + json.Unmarshal(b, &st) // a broken file counts as none + return st +} + +func (u selfUpdater) saveState(st updateState) error { + path, err := u.statePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.Marshal(st) + if err != nil { + return err + } + // By way of a file alongside, so that a simultaneous run never comes upon + // half a JSON. + tmp := path + ".new" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// ------------------------------------------------------------------ The work + +// check only looks and touches nothing. +func (u selfUpdater) check(w io.Writer) error { + rel, err := u.latest() + if err != nil { + return err + } + if updateCompare(rel.TagName, u.current) <= 0 { + fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) + return nil + } + fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n", + u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset) + return nil +} + +// install fetches the newest release and replaces the running file with it. +func (u selfUpdater) install(w io.Writer) error { + rel, err := u.latest() + if err != nil { + return err + } + if updateCompare(rel.TagName, u.current) <= 0 { + fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) + return nil + } + + want := u.assetName() + src := rel.asset(want) + if src == nil { + names := make([]string, len(rel.Assets)) + for i, a := range rel.Assets { + names[i] = a.Name + } + return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", ")) + } + + // Before a byte is fetched: without a checksum to weigh it against there is + // nothing to install, and saying so now saves the download. + sum, err := u.checksum(rel, want) + if err != nil { + return err + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the running binary: %w", err) + } + // An installed gbld is often a symlink into ./bin. What should be replaced is + // the file behind it, not the link. + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + mode := os.FileMode(0o755) + if st, err := os.Stat(exe); err == nil { + mode = st.Mode().Perm() + } + + fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size)) + tmp, err := u.download(src, exe, mode, sum) + if err != nil { + return err + } + defer os.Remove(tmp) // only bites when the renaming below falls through + + if err := u.probe(tmp, rel.TagName); err != nil { + return err + } + if err := updateReplace(tmp, exe); err != nil { + return err + } + + fmt.Fprintf(w, "%s %s โ†’ %s, at %s\n", u.asset, u.current, rel.TagName, exe) + return nil +} + +// assetName is what this build of this program is called in a release. Windows +// carries the suffix its loader insists on; everywhere else the bare name. +func (u selfUpdater) assetName() string { + name := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +// checksum fetches the release's checksums.txt and picks out the line for this +// asset. Every way out of here that is not a sha256 is an error: a release +// without checksums, one that does not mention this asset, a line that is not +// a hash โ€” none of them ends in an installed binary. +func (u selfUpdater) checksum(rel updateRelease, name string) ([]byte, error) { + src := rel.asset(updateChecksums) + if src == nil { + return nil, fmt.Errorf("release %s publishes no %s, refusing to install %s unchecked", + rel.TagName, updateChecksums, name) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := updateGet(ctx, src.URL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // a list of hashes, not a payload + if err != nil { + return nil, fmt.Errorf("cannot read %s of release %s: %w", updateChecksums, rel.TagName, err) + } + return updateFindSum(body, name) +} + +// updateFindSum reads the format of `shasum -a 256`: hash, blanks, name. The +// asterisk some tools put in front of the name in binary mode is not part of it. +func updateFindSum(body []byte, name string) ([]byte, error) { + for _, line := range strings.Split(string(body), "\n") { + f := strings.Fields(line) + if len(f) != 2 || strings.TrimPrefix(f[1], "*") != name { + continue + } + sum, err := hex.DecodeString(f[0]) + if err != nil || len(sum) != sha256.Size { + return nil, fmt.Errorf("%s holds no usable sha256 for %s", updateChecksums, name) + } + return sum, nil + } + return nil, fmt.Errorf("%s names no checksum for %s", updateChecksums, name) +} + +func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode, sum []byte) (string, error) { + // The new file comes into being next to the old one: same filesystem, so + // the renaming at the end is one atomic step and not half a copy. It also + // comes into being before the first byte โ€” a missing write permission ought + // to show up before a few megabytes have gone down the wire. + dir := filepath.Dir(exe) + f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new") + if err != nil { + var pe *os.PathError // the path is in the message already + if errors.As(err, &pe) { + err = pe.Err + } + return "", fmt.Errorf("cannot write to %s: %w", dir, err) + } + tmp := f.Name() + + resp, err := updateGet(context.Background(), a.URL) + if err != nil { + f.Close() + os.Remove(tmp) + return "", err + } + defer resp.Body.Close() + + // The hash grows while the file is written: the bytes are weighed on their + // way past, not read back afterwards. + var h hash.Hash = sha256.New() + n, err := io.Copy(io.MultiWriter(f, h), resp.Body) + if cerr := f.Close(); err == nil { + err = cerr + } + if err == nil && a.Size > 0 && n != a.Size { + err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL) + } + if err == nil && !bytes.Equal(h.Sum(nil), sum) { + err = fmt.Errorf("%s does not match its published checksum: got %s, expected %s", + a.Name, hex.EncodeToString(h.Sum(nil)), hex.EncodeToString(sum)) + } + if err == nil { + err = os.Chmod(tmp, mode) + } + if err != nil { + os.Remove(tmp) + return "", err + } + return tmp, nil +} + +// probe calls the freshly fetched runner once. That catches a file that is +// truncated, built for the wrong platform, or not executable in the first +// place, before it replaces the running one. +func (u selfUpdater) probe(path, tag string) error { + if len(u.verify) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput() + if err != nil { + return fmt.Errorf("the downloaded binary does not run: %w", err) + } + if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) { + return fmt.Errorf("the downloaded binary reports %q, expected %s", + strings.TrimSpace(string(out)), tag) + } + return nil +} + +// updateReplace swaps the running file for the new one. +func updateReplace(tmp, exe string) error { + if err := os.Rename(tmp, exe); err == nil { + return nil + } + // Unix overwrites the file of a running program without complaint, Windows + // does not: there the old one has to be got out of the way first. Deleting + // it becomes possible when this process ends at the earliest โ€” so the + // tidying up is allowed to fail. + old := exe + ".old" + os.Remove(old) + if err := os.Rename(exe, old); err != nil { + return fmt.Errorf("cannot replace %s: %w", exe, err) + } + if err := os.Rename(tmp, exe); err != nil { + os.Rename(old, exe) // back to how it was + return fmt.Errorf("cannot replace %s: %w", exe, err) + } + os.Remove(old) + return nil +} + +// -------------------------------------------------------------------- Gitea + +type updateRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []updateAsset `json:"assets"` +} + +type updateAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"browser_download_url"` +} + +// asset picks one file out of a release by name, nil when it is not there. +func (r updateRelease) asset(name string) *updateAsset { + for i := range r.Assets { + if r.Assets[i].Name == name { + return &r.Assets[i] + } + } + return nil +} + +func (u selfUpdater) latest() (updateRelease, error) { + base, err := u.apiBase() + if err != nil { + return updateRelease{}, err + } + // The question is a small one; if it hangs, it does not hang for long. The + // generous time limit of updateClient is meant for the download. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := updateGet(ctx, base+"/releases/latest") + if err != nil { + return updateRelease{}, err + } + defer resp.Body.Close() + + var rel updateRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err) + } + if rel.TagName == "" { + return updateRelease{}, fmt.Errorf("%s has no releases", u.repo) + } + + // Every question that succeeds fills the note โ€” no matter whether it came + // from --update, from --check-update or from the background run. + u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName}) + return rel, nil +} + +// apiBase turns https://host/owner/repo into the API root of the repo. +func (u selfUpdater) apiBase() (string, error) { + bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo) + + ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git")) + if err != nil || ref.Host == "" { + return "", bad + } + parts := strings.Split(strings.Trim(ref.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", bad + } + return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil +} + +// One time limit for all of it: the look costs a few hundred milliseconds, the +// download a few megabytes โ€” both may hang, but not forever. +// +// Nothing is turned off here: the certificate of the gitea is verified like any +// other. Together with the checksum from the release, that is what stands +// between this program and a binary someone else picked โ€” the probe below is +// none of it, a hostile binary prints whatever version string is asked of it. +var updateClient = &http.Client{ + Timeout: 5 * time.Minute, +} + +func updateGet(ctx context.Context, target string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")") + + resp, err := updateClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", target, resp.Status) + } + return resp, nil +} + +// ------------------------------------------------------------------ Numbers + +// updateCompare compares two versions component by component, numerically, so +// that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not +// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number +// makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one +// of strings.Compare: -1, 0, 1. +func updateCompare(a, b string) int { + as := strings.Split(strings.TrimPrefix(a, "v"), ".") + bs := strings.Split(strings.TrimPrefix(b, "v"), ".") + + for i := 0; i < len(as) || i < len(bs); i++ { + x, y := "0", "0" + if i < len(as) { + x = as[i] + } + if i < len(bs) { + y = bs[i] + } + if c := updateComparePart(x, y); c != 0 { + return c + } + } + return 0 +} + +func updateComparePart(a, b string) int { + na, ra := updateSplitNum(a) + nb, rb := updateSplitNum(b) + switch { + case na != nb: + if na < nb { + return -1 + } + return 1 + case ra == rb: + return 0 + case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet + return 1 + case rb == "": + return -1 + } + return strings.Compare(ra, rb) +} + +// updateSplitNum separates "10-rc1" into 10 and "-rc1". +func updateSplitNum(s string) (int, string) { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + n, _ := strconv.Atoi(s[:i]) + return n, s[i:] +} + +// updateSize is deliberately a small formatting of its own and not a helper +// from the toolbox โ€” the file is meant to stand on its own. +func updateSize(b int64) string { + const k = 1024 + switch { + case b > k*k: + return fmt.Sprintf("%.1f MB", float64(b)/k/k) + case b > k: + return fmt.Sprintf("%.1f KB", float64(b)/k) + default: + return fmt.Sprintf("%d B", b) + } +} diff --git a/tools.go b/tools.go index c98aed1..44d3bed 100644 --- a/tools.go +++ b/tools.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "database/sql" "encoding/base64" - "encoding/hex" "flag" "fmt" "io" @@ -15,96 +14,28 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strconv" "strings" - "time" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" - "github.com/Masterminds/semver/v3" "github.com/eknkc/basex" "github.com/fatih/color" "github.com/mgutz/ansi" - "github.com/minio/selfupdate" "github.com/spf13/viper" "github.com/tidwall/gjson" ) var tbversion = "0.7.0" -var HTTPTIMEOUT = 3 * time.Second // version.txt / checksums.txt - must never stall a build -var DOWNLOADTIMEOUT = 10 * time.Minute // the binary itself, http.Client.Timeout covers the whole body - var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w=" var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") -func checkforupdate(URL string) { // ----------------------------------------------------- check for new version - prg := prgname() - client := &http.Client{Timeout: HTTPTIMEOUT} - - body, err := fetch(client, URL+"/version.txt", 1<<20) // update server unreachable: never block the build - if err != nil { return } - - lversion := strings.TrimSpace(string(body)) // exactly as published - it names the artifact - sv_version, err := semver.NewVersion(version) - if err != nil { return } - sv_lversion, err := semver.NewVersion(lversion) - if err != nil { return } - if !sv_lversion.GreaterThan(sv_version) { return } - - if !Yesno(SF("new '%s' version found (%s -> %s), update now?",prg,sv_version,lversion),true,false) { - return - } - - target := SF("%s_%s_%s_%s",prg,lversion,runtime.GOOS,runtime.GOARCH) - if runtime.GOOS == "windows" { target += ".exe" } - - sum, err := getchecksum(client, URL+"/checksums.txt", target) // no checksum -> no update - if err != nil { - PE("update aborted", err.Error()) - os.Exit(1) - } - - if err := doupdate(&http.Client{Timeout: DOWNLOADTIMEOUT}, URL+"/"+target, sum); err != nil { - PE("update failed", err.Error()) - os.Exit(1) - } - PO("update successful","please run your last command again") - os.Exit(0) -} - -func doupdate(client *http.Client, url string, checksum []byte) error { // --------------------------- do update - resp, err := client.Get(url) - if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return SE("server returned status: %v", resp.Status) } - return selfupdate.Apply(resp.Body, selfupdate.Options{Checksum: checksum}) -} - -func getchecksum(client *http.Client, url string, name string) ([]byte, error) { // -- sha256 of a published file - body, err := fetch(client, url, 1<<20) - if err != nil { return nil, SE("no checksums published (%v)", err) } - - for _, line := range strings.Split(string(body), "\n") { - f := strings.Fields(line) - if len(f) == 2 && f[1] == name { - sum, err := hex.DecodeString(f[0]) - if err != nil { return nil, SE("bad checksum for %s", name) } - return sum, nil - } - } - return nil, SE("no checksum for %s", name) -} - -func fetch(client *http.Client, url string, max int64) ([]byte, error) { // ------------------- fetch a small file - resp, err := client.Get(url) - if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return nil, SE("server returned status: %v", resp.Status) } - return io.ReadAll(io.LimitReader(resp.Body, max)) -} +// Updating oneself lives in selfupdate.go โ€” one file per program, configured at +// its head, driven by the --update and --check-update options. It fetches from +// the releases of the gitea and weighs every download against the checksum +// published beside it. func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access match:=0; diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..57807d6 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.22.0