2026-09-18 12:03:35 +02:00
|
|
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
|
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
|
|
|
|
|
|
|
|
package updater
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestCompareVersions(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
a, b string
|
|
|
|
|
want int
|
|
|
|
|
}{
|
|
|
|
|
{"0.6.0", "0.6.0", 0},
|
|
|
|
|
{"0.7.0", "0.6.0", 1},
|
|
|
|
|
{"0.6.0", "0.7.0", -1},
|
|
|
|
|
{"1.0.0", "0.9.9", 1},
|
|
|
|
|
{"0.6.1", "0.6", 1},
|
|
|
|
|
{"0.6", "0.6.0", 0},
|
|
|
|
|
{"0.10.0", "0.9.0", 1},
|
|
|
|
|
{"1.2.3-rc1", "1.2.3", 0}, // suffix ignored per part
|
2026-09-29 10:53:52 +02:00
|
|
|
{"v1.0.0", "1.0.0", 0}, // the recorded version carries the v a tag does
|
|
|
|
|
{"1.0.0", "v1.0.0", 0},
|
|
|
|
|
{"v1.1.0", "v1.0.0", 1},
|
|
|
|
|
{"1.28.0", "1.9.0", 1}, // parts compare numerically, not by character
|
|
|
|
|
{"1.9.0", "1.28.0", -1},
|
|
|
|
|
{"1.28.0", "1.128.0", -1},
|
|
|
|
|
{"v1.28.0", "1.28.0", 0},
|
2026-09-18 12:03:35 +02:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
if got := CompareVersions(tc.a, tc.b); got != tc.want {
|
|
|
|
|
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckLatestParsesTag(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.2.3"})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
old := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = old }()
|
|
|
|
|
|
|
|
|
|
latest, err := CheckLatest()
|
|
|
|
|
if err != nil || latest != "1.2.3" {
|
|
|
|
|
t.Fatalf("CheckLatest = %q, %v", latest, err)
|
|
|
|
|
}
|
|
|
|
|
if got := UpdateAvailable("1.2.2"); got != "1.2.3" {
|
|
|
|
|
t.Fatalf("UpdateAvailable = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := UpdateAvailable("1.2.3"); got != "" {
|
|
|
|
|
t.Fatalf("UpdateAvailable(current) = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := UpdateAvailable("2.0.0"); got != "" {
|
|
|
|
|
t.Fatalf("UpdateAvailable(newer local) = %q", got)
|
|
|
|
|
}
|
2026-09-29 10:53:52 +02:00
|
|
|
// The recorded version carries the "v" the release tag does: an
|
|
|
|
|
// installation running v1.2.3 must not be offered 1.2.3.
|
|
|
|
|
if got := UpdateAvailable("v1.2.3"); got != "" {
|
|
|
|
|
t.Fatalf("UpdateAvailable(v-prefixed current) = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := UpdateAvailable("v1.2.2"); got != "1.2.3" {
|
|
|
|
|
t.Fatalf("UpdateAvailable(v-prefixed older) = %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The production bug: an installation running v1.0.0 was offered an
|
|
|
|
|
// update to 1.0.0, because the recorded version and the tag compared
|
|
|
|
|
// unequal through the prefix. SelfUpdate must refuse in place, and it
|
|
|
|
|
// must not download anything on the way.
|
|
|
|
|
func TestSelfUpdateRefusesTheSameVersionThroughThePrefix(t *testing.T) {
|
|
|
|
|
var downloads int
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if strings.Contains(r.URL.Path, "/releases/download/") {
|
|
|
|
|
downloads++
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
old := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = old }()
|
|
|
|
|
|
|
|
|
|
if _, err := SelfUpdate("v9.9.9"); err == nil || !strings.Contains(err.Error(), "already running") {
|
|
|
|
|
t.Fatalf("SelfUpdate(v-prefixed current) = %v, want the latest-release refusal", err)
|
|
|
|
|
}
|
|
|
|
|
if downloads != 0 {
|
|
|
|
|
t.Fatalf("%d download requests fired for a same-version update", downloads)
|
|
|
|
|
}
|
2026-09-18 12:03:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckLatestErrors(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
old := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = old }()
|
|
|
|
|
if _, err := CheckLatest(); err == nil {
|
|
|
|
|
t.Fatal("want error on HTTP 500")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSelfUpdateURLsIncludeRepoPath(t *testing.T) {
|
|
|
|
|
var paths []string
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
paths = append(paths, r.URL.Path)
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
|
|
|
|
_, _ = w.Write([]byte("0000000000000000000000000000000000000000000000000000000000000000 volumen-9.9.9-" + goos() + "-" + goarch() + "\n"))
|
|
|
|
|
default:
|
|
|
|
|
_, _ = w.Write([]byte("binary"))
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
old := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = old }()
|
|
|
|
|
|
|
|
|
|
// SelfUpdate downloads and then fails on the checksum; the point of
|
|
|
|
|
// this test is the request paths, not the outcome.
|
|
|
|
|
_, _ = SelfUpdate("0.0.1")
|
|
|
|
|
|
|
|
|
|
want := RepoSlug + "/releases/download/v9.9.9/"
|
|
|
|
|
downloads := 0
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
if !strings.Contains(path, "/releases/download/") {
|
|
|
|
|
continue // the /api/... release check uses its own path shape
|
|
|
|
|
}
|
|
|
|
|
downloads++
|
|
|
|
|
if !strings.Contains(path, want) {
|
|
|
|
|
t.Fatalf("download path %q does not contain %q", path, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if downloads < 2 {
|
|
|
|
|
t.Fatalf("expected checksums and binary downloads, saw %d download requests", downloads)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The self-update tail replaces the running binary, so it is exercised
|
|
|
|
|
// through the two seams rather than by actually exec-ing anything.
|
|
|
|
|
func TestSelfUpdateStagesAndExecs(t *testing.T) {
|
|
|
|
|
binary := []byte("#!/bin/sh\necho replaced\n")
|
|
|
|
|
sum := sha256.Sum256(binary)
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
|
|
|
|
fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", hex.EncodeToString(sum[:]), goos(), goarch())
|
|
|
|
|
default:
|
|
|
|
|
_, _ = w.Write(binary)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
oldBase := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = oldBase }()
|
|
|
|
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
exe := filepath.Join(dir, "volumen")
|
|
|
|
|
if err := os.WriteFile(exe, []byte("old binary"), 0o755); err != nil {
|
|
|
|
|
t.Fatalf("write: %v", err)
|
|
|
|
|
}
|
|
|
|
|
oldExecutable, oldExec := executable, syscallExec
|
|
|
|
|
executable = func() (string, error) { return exe, nil }
|
|
|
|
|
var gotPath string
|
|
|
|
|
var gotArgv []string
|
|
|
|
|
syscallExec = func(path string, argv []string) error {
|
|
|
|
|
gotPath, gotArgv = path, argv
|
|
|
|
|
return errors.New("stop here")
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { executable, syscallExec = oldExecutable, oldExec })
|
|
|
|
|
|
|
|
|
|
_, err := SelfUpdate("0.0.1")
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "stop here") {
|
|
|
|
|
t.Fatalf("SelfUpdate = %v, want the seam's error", err)
|
|
|
|
|
}
|
|
|
|
|
if gotPath != exe {
|
|
|
|
|
t.Fatalf("exec path = %q, want %q", gotPath, exe)
|
|
|
|
|
}
|
|
|
|
|
if len(gotArgv) == 0 || gotArgv[0] != exe {
|
|
|
|
|
t.Fatalf("argv = %v", gotArgv)
|
|
|
|
|
}
|
|
|
|
|
staged, err := os.ReadFile(exe)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("read the replaced binary: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if string(staged) != string(binary) {
|
|
|
|
|
t.Fatalf("the binary was not replaced: %q", staged)
|
|
|
|
|
}
|
|
|
|
|
info, err := os.Stat(exe)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("stat: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if info.Mode().Perm() != 0o755 {
|
|
|
|
|
t.Fatalf("mode = %v, want 0755", info.Mode().Perm())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSelfUpdateRejectsAChecksumMismatch(t *testing.T) {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
|
|
|
|
fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", strings.Repeat("0", 64), goos(), goarch())
|
|
|
|
|
default:
|
|
|
|
|
_, _ = w.Write([]byte("binary"))
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
old := ReleaseBase
|
|
|
|
|
ReleaseBase = srv.URL
|
|
|
|
|
defer func() { ReleaseBase = old }()
|
|
|
|
|
|
|
|
|
|
exe := filepath.Join(t.TempDir(), "volumen")
|
|
|
|
|
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
|
|
|
|
|
t.Fatalf("write: %v", err)
|
|
|
|
|
}
|
|
|
|
|
oldExecutable := executable
|
|
|
|
|
executable = func() (string, error) { return exe, nil }
|
|
|
|
|
t.Cleanup(func() { executable = oldExecutable })
|
|
|
|
|
|
|
|
|
|
if _, err := SelfUpdate("0.0.1"); err == nil || !strings.Contains(err.Error(), "checksum") {
|
|
|
|
|
t.Fatalf("SelfUpdate = %v, want a checksum failure", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCompareVersionsClampsLongComponents(t *testing.T) {
|
|
|
|
|
// A component wider than an int must compare, not overflow.
|
|
|
|
|
huge := strings.Repeat("9", 30)
|
|
|
|
|
if CompareVersions(huge, "1.0.0") <= 0 {
|
|
|
|
|
t.Fatal("a very large component did not compare as newer")
|
|
|
|
|
}
|
|
|
|
|
if CompareVersions("1.0.0", huge) >= 0 {
|
|
|
|
|
t.Fatal("a very large component did not compare as newer")
|
|
|
|
|
}
|
|
|
|
|
}
|