feat(desktop): let a bad release be rolled back from inside the app

A release that turns out to be bad currently leaves the user hunting for an
old installer. The version panel lists what has been published and installs
any of them — the same code path forward or back, because a rollback that took
its own path would be the less-tested one. The chosen version is pinned before
the download starts: if the machine dies mid-install, the next launch must not
helpfully update past what the user picked.

The update core moves out of the old shell into desktop/internal/update so
both shells share it. Beyond deduplication that finally makes it compile on
every platform — updater_mac.go could not even be type-checked from Windows,
so the macOS half was only ever verified by CI.

Publishing writes a per-tag index alongside the artifacts, so the catalog the
panel reads is produced by the release itself rather than maintained by hand.

Two things the transport learned the hard way, now covered by tests: a 404 is
not transient and must not burn the full retry budget, and a resumed download
has to verify the range it was actually given — a middle box that answers
`bytes 0-` to a range request will otherwise append the whole file to what is
already on disk.
This commit is contained in:
yhh committed 2026-08-14 05:03:02 -07:00
1 parent e27b4a7541
commit 1db3c9082b
53 files changed
+3853 -1646

No files matched your search

+25
View File
@@ -990,6 +990,31 @@ jobs:
exit 0
fi
# The rollback index records which versions were ever public and where
# each one's immutable manifest lives, so going back verifies through
# the same path a forward update does. It is written here rather than
# beside the pointer advance on purpose: a rerun whose pointer decision
# is "skip" (the candidate is already current) must still be able to
# repair a missing index entry. Preview/canary keep their immutable
# record but never enter it — rolling back means the stable line.
if [ "$CHANNEL" != "preview" ]; then
index_current=/tmp/reasonix-versions-current.json
index_next=/tmp/reasonix-versions-next.json
index_source=-
if download_optional "versions.json" "$index_current"; then
index_source="$index_current"
else
status=$?
if [ "$status" -ne 3 ]; then
exit "$status"
fi
fi
bash release-control/scripts/update-versions-index.sh "$index_source" "$VERSION" "$TAG" "$CHANNEL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$index_next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$index_next" >/dev/null
aws s3 cp "$index_next" "s3://${R2_BUCKET}/versions.json" --endpoint-url "$ENDPOINT" --content-type "application/json; charset=utf-8" --cache-control "public, max-age=300, stale-if-error=86400"
echo "rollback index -> $VERSION"
fi
validate_current_pointer() {
local current_channel="$1"
local current_version="$2"
+2
View File
@@ -48,5 +48,7 @@
/frontend/package.json.md5
/next-app.exe
/next-app.exe~
/next.exe
/next.exe~
/reasonix-studio.exe
/reasonix-studio.exe~
+3 -1
View File
@@ -24,10 +24,12 @@ import (
"path/filepath"
"strings"
"unsafe"
"reasonix/desktop/internal/update"
)
func repairDesktopIconIntegration() error {
currentApp, err := currentMacAppBundle()
currentApp, err := update.CurrentMacAppBundle()
if err != nil {
// Developer binaries and tests are not installed app bundles.
return nil
+38
View File
@@ -0,0 +1,38 @@
package update
import (
"context"
"fmt"
)
// VersionedInstaller publishes a release into its own directory and swaps the
// pointer last, so an install that dies partway leaves the running version
// untouched. A rollback lands the same way — going back is publishing an older
// directory, not undoing a newer one — so nothing here asks the direction.
type VersionedInstaller struct {
Layout Layout
Staging string // update cache dir; the Windows helper copy is placed here
Current string // running version, recorded in the macOS handoff transaction
}
// MacSelfUpdate reports whether this macOS build was Developer ID signed and
// notarized. Only the host knows, so the host sets it at startup; false keeps
// the manual download path rather than writing a bundle Gatekeeper will reject.
var MacSelfUpdate bool
// Install applies a verified artifact. It returns only if the handover failed:
// on success the caller is expected to shut down so the new build can take over.
func (v VersionedInstaller) Install(ctx context.Context, c Cached) error {
if v.Layout.Root == "" || v.Layout.Executable == "" {
return fmt.Errorf("update: cannot resolve where this build is installed")
}
if c.Kind == KindDeb {
// A deb belongs to dpkg, and writing its files behind the package
// manager leaves the two disagreeing about what is installed.
return fmt.Errorf("update: a %s artifact installs through the system package manager", c.Kind)
}
if c.Path == "" || c.Version == "" {
return fmt.Errorf("update: cached artifact is incomplete")
}
return v.apply(ctx, c)
}
@@ -1,8 +1,9 @@
//go:build darwin
package main
package update
import (
"context"
"encoding/json"
"flag"
"fmt"
@@ -52,7 +53,7 @@ var (
return unix.RenameatxNp(unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_EXCL)
}
macHandoffLogPath = func() string {
cacheDir, err := updateCacheDir()
cacheDir, err := CacheDir()
if err != nil {
return ""
}
@@ -60,11 +61,18 @@ var (
}
)
func applyMac(zipPath, targetVersion string) error {
if !macSelfUpdateAllowed() {
// apply swaps the whole app bundle. macOS has no versioned directory layout —
// the bundle is the unit — so the equivalent of a pointer swap is a detached
// child that holds the repair lock across the rename while this process exits.
func (v VersionedInstaller) apply(_ context.Context, c Cached) error {
return applyMac(c.Path, c.Version, v.Current)
}
func applyMac(zipPath, targetVersion, current string) error {
if !MacSelfUpdate {
return fmt.Errorf("macOS automatic update is not enabled for this build")
}
currentApp, err := currentMacAppBundle()
currentApp, err := CurrentMacAppBundle()
if err != nil {
return err
}
@@ -98,7 +106,7 @@ func applyMac(zipPath, targetVersion string) error {
return err
}
tx, err := repair.PrepareAppBundleUpdateHandoff(
version,
current,
targetVersion,
currentApp,
backupApp,
@@ -235,7 +243,10 @@ func reportMacHandoffStartupFailure(cfg macUpdateHandoffConfig, phase string, er
// maybeRunMacUpdateHandoff handles the detached self-update child before Wails
// or single-instance setup runs.
func maybeRunMacUpdateHandoff(args []string) (handled bool, exitCode int) {
// MaybeRunMacHandoff runs the detached bundle swap when this process was
// re-executed as the handoff child, and reports that it handled the run. Every
// host's main must call it before startup, or the child would boot a second UI.
func MaybeRunMacHandoff(args []string) (handled bool, exitCode int) {
if len(args) == 0 || args[0] != macUpdateHandoffArg {
return false, 0
}
@@ -705,7 +716,10 @@ func macProcessAlive(pid int) bool {
return err == nil
}
func currentMacAppBundle() (string, error) {
// CurrentMacAppBundle resolves the .app this executable runs from. It is
// exported because icon repair needs the same answer, and two ways of finding
// the bundle is two ways of being wrong about which one is being replaced.
func CurrentMacAppBundle() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
@@ -0,0 +1,8 @@
//go:build !darwin
package update
// MaybeRunMacHandoff is a no-op off macOS. Every host's main calls it before
// anything else so a re-executed handoff child never reaches normal startup;
// keeping the stub means no host has to know which platform it is compiled for.
func MaybeRunMacHandoff([]string) (handled bool, exitCode int) { return false, 0 }
@@ -1,6 +1,6 @@
//go:build darwin
package main
package update
import (
"fmt"
+65
View File
@@ -0,0 +1,65 @@
//go:build linux
package update
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
)
// apply publishes the tarball into a new version directory and swaps the
// pointer last. Unlike Windows, Linux can replace files under a running
// process, so the install completes here and the caller relaunches.
func (v VersionedInstaller) apply(_ context.Context, c Cached) error {
targz, err := os.ReadFile(c.Path)
if err != nil {
return err
}
release, err := ExtractReleaseUnit(targz)
if err != nil {
return err
}
root := v.Layout.Root
if _, err := installlayout.ReadCurrent(root); err != nil {
return fmt.Errorf("update: resolve active Linux layout: %w", err)
}
target := strings.TrimSpace(c.Version)
if !strings.HasPrefix(target, "v") {
target = "v" + target
}
if err := installlayout.ValidateVersionName(target); err != nil {
return err
}
staging, err := os.MkdirTemp(root, ".reasonix-linux-update-*")
if err != nil {
return fmt.Errorf("update: create Linux version staging: %w", err)
}
defer os.RemoveAll(staging)
desktopPath := filepath.Join(staging, installlayout.DesktopBinaryName())
cliPath := filepath.Join(staging, installlayout.CLIBinaryName())
if err := os.WriteFile(desktopPath, release["reasonix-desktop"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux desktop: %w", err)
}
if err := os.WriteFile(cliPath, release["reasonix"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux CLI: %w", err)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: target,
RequestID: "linux-" + target,
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: desktopPath, Mode: 0o700},
{Name: installlayout.CLIBinaryName(), Path: cliPath, Mode: 0o700},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
return fmt.Errorf("update: activate Linux version: %w", err)
}
_ = installlayout.RetainPreviousVersions(root, 0)
return nil
}
+121
View File
@@ -0,0 +1,121 @@
//go:build linux
package update
import (
"archive/tar"
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
)
// The tarball still carries reasonix-guard for v1.18-v1.19 updaters reading the
// same archive. A v1.20+ install must extract it and leave it nowhere on disk,
// or the next launch has two entry points disagreeing about which is current.
func TestVersionedInstallActivatesWithoutPersistingGuard(t *testing.T) {
root := t.TempDir()
source := t.TempDir()
for _, name := range []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()} {
if err := os.WriteFile(filepath.Join(source, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: "v1.20.0",
RequestID: "seed-linux",
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: filepath.Join(source, installlayout.DesktopBinaryName())},
{Name: installlayout.CLIBinaryName(), Path: filepath.Join(source, installlayout.CLIBinaryName())},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
t.Fatal(err)
}
var headers []tar.Header
var bodies [][]byte
for _, name := range releaseUnitMembers {
body := []byte("new-" + name)
headers = append(headers, regularMember(name, body))
bodies = append(bodies, body)
}
artifact := filepath.Join(t.TempDir(), "release.tar.gz")
if err := os.WriteFile(artifact, makeArchive(t, headers, bodies), 0o600); err != nil {
t.Fatal(err)
}
inst := VersionedInstaller{Layout: Layout{Root: root, Executable: filepath.Join(root, "reasonix-desktop")}}
// The version arrives without its leading v, the way a manifest can spell it.
if err := inst.Install(context.Background(), Cached{Version: "1.20.1", Path: artifact, Kind: KindTarball}); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(activeDesktop)
if err != nil || string(data) != "new-reasonix-desktop" {
t.Fatalf("active desktop=%q err=%v", data, err)
}
for _, guardPath := range []string{
filepath.Join(root, "reasonix-guard"),
filepath.Join(root, "versions", "v1.20.1", "reasonix-guard"),
} {
if _, err := os.Lstat(guardPath); !os.IsNotExist(err) {
t.Fatalf("Guard persisted at %s: %v", guardPath, err)
}
}
}
// Rolling back is publishing an older version directory. Nothing on this path
// may ask whether the target is ahead of what is running.
func TestVersionedInstallGoesBackwards(t *testing.T) {
root := t.TempDir()
source := t.TempDir()
for _, name := range []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()} {
if err := os.WriteFile(filepath.Join(source, name), []byte("v2-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: "v2.1.0",
RequestID: "seed",
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: filepath.Join(source, installlayout.DesktopBinaryName())},
{Name: installlayout.CLIBinaryName(), Path: filepath.Join(source, installlayout.CLIBinaryName())},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
t.Fatal(err)
}
var headers []tar.Header
var bodies [][]byte
for _, name := range releaseUnitMembers {
body := []byte("v2.0.0-" + name)
headers = append(headers, regularMember(name, body))
bodies = append(bodies, body)
}
artifact := filepath.Join(t.TempDir(), "old.tar.gz")
if err := os.WriteFile(artifact, makeArchive(t, headers, bodies), 0o600); err != nil {
t.Fatal(err)
}
inst := VersionedInstaller{Layout: Layout{Root: root, Executable: filepath.Join(root, "reasonix-desktop")}}
if err := inst.Install(context.Background(), Cached{Version: "v2.0.0", Path: artifact, Kind: KindTarball}); err != nil {
t.Fatalf("rollback install: %v", err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v2.0.0" {
t.Fatalf("pointer=%+v err=%v, want the older version to be current", ptr, err)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !linux && !darwin
package update
import (
"context"
"fmt"
"runtime"
)
func (v VersionedInstaller) apply(_ context.Context, _ Cached) error {
return fmt.Errorf("update: self-update is unsupported on %s", runtime.GOOS)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build windows
package update
import (
"context"
"fmt"
)
// apply hands the verified installer to the update helper. Windows cannot
// replace a running executable, so the install happens after this process
// exits — the helper is what survives to observe whether it worked.
func (v VersionedInstaller) apply(_ context.Context, c Cached) error {
if !v.Layout.Versioned() {
return fmt.Errorf("update: %s predates the versioned layout; reinstall from the download page", v.Layout.Root)
}
return WindowsHandoff{
InstallerPath: c.Path,
InstallerSHA256: c.SHA256,
InstallDir: v.Layout.Root,
RelaunchPath: v.Layout.Launcher,
StagingDir: v.Staging,
}.StartVersioned(c.Version)
}
+244
View File
@@ -0,0 +1,244 @@
package update
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
)
// Artifact kinds. Which one the cache holds is part of its identity: a .deb and
// a tarball of the same version install through different paths, so one can
// never stand in for the other.
const (
KindTarball = "tarball"
KindDeb = "deb"
)
// legacyChannel is written into the metadata file and never read back. A build
// from before the version hub rejects metadata without it, so a rollback onto
// one keeps its cache instead of downloading the artifact a second time.
const legacyChannel = "stable"
// NormalizeKind canonicalizes a stored or requested kind. Empty is a portable
// cache written before the field existed, so it reads back as a tarball.
func NormalizeKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case KindDeb:
return KindDeb
case KindTarball, "":
return KindTarball
default:
return kind
}
}
// Cached is a verified artifact on disk, ready to install. It names the version
// it holds so an installer can refuse one it was not asked for — a rollback
// installs a version that is deliberately not the newest.
type Cached struct {
Version string
Path string
Size int64
SHA256 string
Kind string // KindTarball | KindDeb
SignaturePath string // required for KindDeb
}
// Cache holds one verified artifact at a time in Dir, beside the metadata that
// says what it is. One slot is deliberate: an update and a rollback both
// download before they install, and the later download supersedes the earlier.
type Cache struct{ Dir string }
// CacheDirFn resolves where downloads are kept. It is a variable so tests can
// redirect the whole updater away from the user's real cache.
var CacheDirFn = defaultCacheDir
// CacheDir is the directory every shell keeps its one pending artifact in,
// created if it is missing. Both shells resolve it the same way so a download
// made by one is the download the other finds.
func CacheDir() (string, error) {
dir, err := CacheDirFn()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
func defaultCacheDir() (string, error) {
if cd := config.CacheDir(); cd != "" {
return filepath.Join(cd, "updates"), nil
}
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
}
return filepath.Join(base, "Reasonix", "updates"), nil
}
// record is the metadata file. It stays separate from Cached because it carries
// what only the file needs: the platform the artifact was fetched for, when, and
// the channel that exists purely for the pre-hub reader described above.
type record struct {
Version string `json:"version"`
Channel string `json:"channel"`
Platform string `json:"platform"`
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
DownloadedAt string `json:"downloadedAt"`
ArtifactKind string `json:"artifactKind,omitempty"`
SignaturePath string `json:"signaturePath,omitempty"`
}
// Save verifies data against the asset digest and stores it as the cached
// artifact for version. A deb is refused without its detached signature: the
// system helper re-verifies it at install time and cannot do so from nothing.
func (c Cache) Save(version string, asset Asset, data []byte, kind string, signature []byte) (Cached, error) {
if err := CheckSHA256(data, asset.SHA256); err != nil {
return Cached{}, err
}
kind = NormalizeKind(kind)
if kind == KindDeb && len(signature) == 0 {
return Cached{}, fmt.Errorf("update: deb cache requires a signature")
}
rec := record{
Version: version,
Channel: legacyChannel,
Platform: CurrentPlatform(),
Path: filepath.Join(c.Dir, artifactName(asset, version)),
Size: int64(len(data)),
SHA256: asset.SHA256,
DownloadedAt: time.Now().UTC().Format(time.RFC3339),
ArtifactKind: kind,
}
if err := fileutil.AtomicWriteFile(rec.Path, data, 0o600); err != nil {
return Cached{}, err
}
if kind == KindDeb {
rec.SignaturePath = rec.Path + ".minisig"
if err := fileutil.AtomicWriteFile(rec.SignaturePath, signature, 0o600); err != nil {
return Cached{}, err
}
}
raw, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return Cached{}, err
}
if err := fileutil.AtomicWriteFile(c.metadataPath(), append(raw, '\n'), 0o600); err != nil {
return Cached{}, err
}
return rec.cached(), nil
}
// Holds reports whether the cache already contains exactly this artifact, so a
// panel can say "downloaded" and an install can skip the network. The bytes on
// disk are re-hashed, not trusted from the metadata.
func (c Cache) Holds(version string, asset Asset, kind string) bool {
rec, err := c.load()
if err != nil || !rec.serves(kind) {
return false
}
return rec.Version == version &&
rec.Platform == CurrentPlatform() &&
strings.EqualFold(rec.SHA256, asset.SHA256) &&
rec.Size == asset.Size &&
fileSHA256Matches(rec.Path, rec.SHA256)
}
// Verified returns the cached artifact and its bytes, but only when it is the
// version asked for. Checking platform and digest without the version would
// hand a rollback whichever release the cache happened to hold.
func (c Cache) Verified(version string) (Cached, []byte, error) {
rec, err := c.load()
if err != nil {
return Cached{}, nil, err
}
if rec.Platform != CurrentPlatform() {
return Cached{}, nil, fmt.Errorf("update: cached update is for %s, current platform is %s", rec.Platform, CurrentPlatform())
}
if rec.Version != version {
return Cached{}, nil, fmt.Errorf("update: cached version %s does not match requested version %s", rec.Version, version)
}
data, err := os.ReadFile(rec.Path)
if err != nil {
return Cached{}, nil, err
}
if err := CheckSHA256(data, rec.SHA256); err != nil {
return Cached{}, nil, err
}
if NormalizeKind(rec.ArtifactKind) == KindDeb {
if rec.SignaturePath == "" {
return Cached{}, nil, fmt.Errorf("update: cached deb is missing its signature")
}
if _, err := os.Stat(rec.SignaturePath); err != nil {
return Cached{}, nil, fmt.Errorf("update: cached deb signature is missing")
}
}
return rec.cached(), data, nil
}
func (c Cache) metadataPath() string { return filepath.Join(c.Dir, "downloaded.json") }
func (c Cache) load() (record, error) {
raw, err := fileencoding.ReadFileUTF8(c.metadataPath())
if err != nil {
return record{}, err
}
var rec record
if err := json.Unmarshal(raw, &rec); err != nil {
return record{}, err
}
if rec.Version == "" || rec.Platform == "" || rec.Path == "" || rec.SHA256 == "" {
return record{}, fmt.Errorf("update: cached metadata is incomplete")
}
return rec, nil
}
// serves reports whether the record can answer a request for kind. A deb needs
// its signature still on disk; a record written before artifactKind existed is
// a tarball and stays usable as one.
func (r record) serves(kind string) bool {
stored := NormalizeKind(r.ArtifactKind)
if NormalizeKind(kind) != KindDeb {
return stored == KindTarball
}
if stored != KindDeb || r.SignaturePath == "" {
return false
}
_, err := os.Stat(r.SignaturePath)
return err == nil
}
func (r record) cached() Cached {
return Cached{
Version: r.Version,
Path: r.Path,
Size: r.Size,
SHA256: r.SHA256,
Kind: NormalizeKind(r.ArtifactKind),
SignaturePath: r.SignaturePath,
}
}
// artifactName keeps the published filename so a user who opens the cache
// directory recognizes what is in it; the fallback only runs for a malformed URL.
func artifactName(asset Asset, version string) string {
if u, err := url.Parse(asset.URL); err == nil {
if base := filepath.Base(u.Path); base != "." && base != "/" {
return base
}
}
clean := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-").Replace(version)
return "Reasonix-" + clean + "-" + CurrentPlatform() + ".update"
}
+234
View File
@@ -0,0 +1,234 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func assetFor(data []byte, name string) Asset {
return Asset{
URL: "https://dl.reasonix.io/studio-v9.9.9/" + name,
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
}
func TestCacheRoundTrip(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
if c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("an empty cache should not report a downloaded update")
}
meta, err := c.Save("v9.9.9", asset, data, KindTarball, nil)
if err != nil {
t.Fatalf("Save: %v", err)
}
if filepath.Base(meta.Path) != "Reasonix-linux-amd64.tar.gz" {
t.Fatalf("cached artifact keeps the published name, got %q", meta.Path)
}
if meta.Size != int64(len(data)) || meta.Kind != KindTarball {
t.Fatalf("cached metadata mismatch: %+v", meta)
}
if !c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("the saved artifact should report as downloaded")
}
got, body, err := c.Verified("v9.9.9")
if err != nil {
t.Fatalf("Verified: %v", err)
}
if string(body) != string(data) || got.Path != meta.Path {
t.Fatalf("Verified returned %q from %q", body, got.Path)
}
}
// A rollback deliberately installs a version that is not the newest, so the
// reader must refuse a cache that is intact in every way except the version.
func TestCacheRefusesAnotherVersion(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
if _, err := c.Save("v9.9.9", asset, data, KindTarball, nil); err != nil {
t.Fatalf("Save: %v", err)
}
if _, _, err := c.Verified("v9.9.9"); err != nil {
t.Fatalf("the cached version must still be readable: %v", err)
}
if _, _, err := c.Verified("v9.8.0"); err == nil {
t.Fatal("a cache holding another version must not be installed")
}
if c.Holds("v9.8.0", asset, KindTarball) {
t.Fatal("a cache holding another version must not report as downloaded")
}
}
func TestCacheRejectsTamperedArtifact(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
meta, err := c.Save("v9.9.9", asset, data, KindTarball, nil)
if err != nil {
t.Fatalf("Save: %v", err)
}
if err := os.WriteFile(meta.Path, []byte("tampered"), 0o600); err != nil {
t.Fatal(err)
}
if c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("a tampered cached artifact should not match")
}
if _, _, err := c.Verified("v9.9.9"); err == nil {
t.Fatal("a tampered cached artifact must be rejected")
}
}
func TestCacheRejectsAMismatchedDigestOnSave(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
asset.SHA256 = sha256Hex([]byte("something else"))
if _, err := c.Save("v9.9.9", asset, data, KindTarball, nil); err == nil {
t.Fatal("saving bytes that do not match the manifest digest must fail")
}
}
func TestDebCacheRequiresSignatureAndRejectsTarballReuse(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("deb-bytes")
asset := assetFor(data, "Reasonix-linux-amd64.deb")
if _, err := c.Save("v9.9.9", asset, data, KindDeb, nil); err == nil {
t.Fatal("deb cache without signature must fail")
}
meta, err := c.Save("v9.9.9", asset, data, KindDeb, []byte("minisig-bytes"))
if err != nil {
t.Fatalf("Save deb: %v", err)
}
if meta.SignaturePath == "" {
t.Fatal("deb cache must record signature path")
}
if !c.Holds("v9.9.9", asset, KindDeb) {
t.Fatal("deb cache with matching signature should match")
}
if c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("deb cache must not match tarball requests")
}
if err := os.Remove(meta.SignaturePath); err != nil {
t.Fatal(err)
}
if c.Holds("v9.9.9", asset, KindDeb) {
t.Fatal("deb cache without signature file must not match")
}
if _, _, err := c.Verified("v9.9.9"); err == nil {
t.Fatal("a deb whose signature is gone must not be installed")
}
}
// Metadata written before artifactKind existed is a portable tarball, and must
// keep working across the update that introduced the field.
func TestLegacyMetadataWithoutKindStaysUsable(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("tarball-bytes")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
if _, err := c.Save("v9.9.9", asset, data, KindTarball, nil); err != nil {
t.Fatal(err)
}
rewriteMetadata(t, c, func(raw map[string]any) { delete(raw, "artifactKind") })
if !c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("legacy portable cache should still match tarball")
}
if c.Holds("v9.9.9", asset, KindDeb) {
t.Fatal("legacy portable cache must not match deb")
}
}
// Channels were retired with canary. Metadata that still names one reads back
// unchanged, and metadata that names a retired one is not rejected for it.
func TestCacheIgnoresTheChannelItWasWrittenWith(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
if _, err := c.Save("v9.9.9", asset, data, KindTarball, nil); err != nil {
t.Fatal(err)
}
if got := readMetadata(t, c)["channel"]; got != legacyChannel {
// A pre-hub build rejects metadata whose channel is missing or foreign.
t.Fatalf("channel = %v, want %q so a rollback onto an older build keeps this cache", got, legacyChannel)
}
rewriteMetadata(t, c, func(raw map[string]any) { raw["channel"] = "canary" })
if _, _, err := c.Verified("v9.9.9"); err != nil {
t.Fatalf("a retired channel in the metadata must not block the install: %v", err)
}
}
func TestCacheRejectsAnotherPlatform(t *testing.T) {
c := Cache{Dir: t.TempDir()}
data := []byte("verified artifact")
asset := assetFor(data, "Reasonix-linux-amd64.tar.gz")
if _, err := c.Save("v9.9.9", asset, data, KindTarball, nil); err != nil {
t.Fatal(err)
}
rewriteMetadata(t, c, func(raw map[string]any) { raw["platform"] = "plan9-mips" })
if _, _, err := c.Verified("v9.9.9"); err == nil {
t.Fatal("an artifact fetched for another platform must not be installed")
}
if c.Holds("v9.9.9", asset, KindTarball) {
t.Fatal("an artifact fetched for another platform must not report as downloaded")
}
}
func TestNormalizeKind(t *testing.T) {
for in, want := range map[string]string{
"": KindTarball,
"tarball": KindTarball,
" TARBALL ": KindTarball,
"deb": KindDeb,
"DEB": KindDeb,
} {
if got := NormalizeKind(in); got != want {
t.Errorf("NormalizeKind(%q) = %q, want %q", in, got, want)
}
}
// An unknown kind stays itself so it matches nothing rather than defaulting
// into a path that would install it.
if got := NormalizeKind("msix"); got != "msix" {
t.Errorf("NormalizeKind(msix) = %q, want it preserved", got)
}
}
func readMetadata(t *testing.T, c Cache) map[string]any {
t.Helper()
raw, err := os.ReadFile(c.metadataPath())
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
func rewriteMetadata(t *testing.T, c Cache, edit func(map[string]any)) {
t.Helper()
raw := readMetadata(t, c)
edit(raw)
encoded, err := json.Marshal(raw)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(c.metadataPath(), encoded, 0o600); err != nil {
t.Fatal(err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package update
import (
"strconv"
"strings"
)
// TotalFromContentRange parses the total size out of "bytes 200-999/1000",
// returning 0 when it is absent or "*" (unknown). It drives download progress.
func TotalFromContentRange(v string) int64 {
i := strings.LastIndex(v, "/")
if i < 0 {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
if err != nil {
return 0
}
return n
}
// RangeStartFromContentRange parses the first byte offset out of
// "bytes 200-999/1000". Anything unparseable answers false, which callers must
// treat as "cannot prove this continues my download".
func RangeStartFromContentRange(v string) (int64, bool) {
unit, spec, ok := strings.Cut(strings.TrimSpace(v), " ")
// The unit must be bytes: this offset is about to authorize appending to a
// partial file, so anything else is "cannot prove", not "close enough".
if !ok || !strings.EqualFold(unit, "bytes") {
return 0, false
}
start, _, ok := strings.Cut(spec, "-")
if !ok {
return 0, false
}
n, err := strconv.ParseInt(strings.TrimSpace(start), 10, 64)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
@@ -0,0 +1,36 @@
package update
import "testing"
// The start is what proves a 206 continues the caller's download. Anything
// unparseable must answer false rather than a plausible-looking zero.
func TestRangeStartFromContentRange(t *testing.T) {
cases := []struct {
in string
want int64
wantOK bool
}{
{"bytes 200-999/1000", 200, true},
{"bytes 0-1023/1024", 0, true},
{"bytes 200-999/*", 200, true},
{"", 0, false},
{"bytes */1000", 0, false},
{"pages 1-2/3", 0, false},
{"bytes -5-9/10", 0, false},
}
for _, tc := range cases {
got, ok := RangeStartFromContentRange(tc.in)
if got != tc.want || ok != tc.wantOK {
t.Errorf("RangeStartFromContentRange(%q) = %d, %v; want %d, %v", tc.in, got, ok, tc.want, tc.wantOK)
}
}
}
func TestTotalFromContentRange(t *testing.T) {
if got := TotalFromContentRange("bytes 200-999/1000"); got != 1000 {
t.Errorf("total = %d, want 1000", got)
}
if got := TotalFromContentRange("bytes 200-999/*"); got != 0 {
t.Errorf("unknown total = %d, want 0", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package update
import (
"context"
"fmt"
)
// MaxSignatureSize caps a detached .minisig. A signature is a few hundred bytes;
// anything near this is a wrong URL, not a signature.
const MaxSignatureSize = int64(64 << 10)
// The steps a download passes through, in order. Verifying is the one worth
// naming: the gap between the last byte and a usable cache is signature and
// digest work, and a UI that cannot say so looks stalled on a large artifact.
const (
PhaseDownloading = "downloading"
PhaseVerifying = "verifying"
PhaseCached = "downloaded"
)
// Report is how a download narrates itself to a UI. Both hooks are optional; a
// caller that wants neither passes the zero value.
type Report struct {
Bytes ProgressFunc // fires as the artifact arrives
Phase func(phase string) // fires when the download moves between steps
}
func (r Report) phase(name string) {
if r.Phase != nil {
r.Phase(name)
}
}
// ManifestFor resolves a published version to its own immutable manifest. The
// catalog is the only way in: an entry names that release's <tag>/latest.json,
// which never moves, so an older version resolves exactly as the newest does.
func (u *Updater) ManifestFor(ctx context.Context, version string) (*Manifest, error) {
idx, err := FetchIndex(ctx, u.opts.HTTP, u.opts.IndexURL)
if err != nil {
return nil, err
}
for _, e := range idx.Versions {
if !SameVersion(e.Version, version) {
continue
}
m, err := FetchManifestAt(ctx, u.opts.HTTP, e.Manifest)
if err != nil {
return nil, err
}
// The catalog says this tag holds that version; the manifest under it has
// to agree, or a mis-published row would install a build the user never
// picked from the list.
if !SameVersion(m.Version, version) {
return nil, fmt.Errorf("update: %s resolves to a manifest for %s", version, m.Version)
}
return m, nil
}
return nil, fmt.Errorf("update: %s is not in the published catalog", version)
}
// Download fetches one published version's artifact for this platform, verifies
// its signature and digest, and caches it ready to install. Any version in the
// catalog is a valid target: a rollback and an update differ only in which one
// is asked for, so both earn every check on this path.
func (u *Updater) Download(ctx context.Context, version string, r Report) (Cached, error) {
m, err := u.ManifestFor(ctx, version)
if err != nil {
return Cached{}, err
}
return u.DownloadManifest(ctx, m, r)
}
// DownloadManifest fetches, verifies and caches the artifact a manifest names.
// It is the half the catalog route and a rolling latest.json pointer share: how
// a version is discovered differs between them, what happens to its bytes must
// not — an artifact reached either way earns the same signature and digest.
func (u *Updater) DownloadManifest(ctx context.Context, m *Manifest, r Report) (Cached, error) {
asset, kind, ok := u.assetIn(m)
if !ok {
return Cached{}, fmt.Errorf("update: %s publishes no %s artifact for %s", m.Version, kind, CurrentPlatform())
}
cache := Cache{Dir: u.opts.CacheDir}
if cache.Holds(m.Version, asset, kind) {
if c, _, err := cache.Verified(m.Version); err == nil {
r.phase(PhaseCached)
return c, nil
}
}
t := u.transport()
r.phase(PhaseDownloading)
data, err := t.Download(ctx, asset.URL, asset.Size, r.Bytes)
if err != nil {
return Cached{}, err
}
r.phase(PhaseVerifying)
sig, err := t.Fetch(ctx, asset.Sig, MaxSignatureSize)
if err != nil {
return Cached{}, err
}
// Signature first: the digest is only the manifest's claim about the bytes,
// and the manifest is only trustworthy once its artifact has been verified.
if err := verifyArtifact(data, sig); err != nil {
return Cached{}, err
}
c, err := cache.Save(m.Version, asset, data, kind, sig)
if err != nil {
return Cached{}, err
}
r.phase(PhaseCached)
return c, nil
}
// Apply is the whole move: fetch the version (or reuse what the cache already
// holds), then hand the verified artifact to the host's installer.
func (u *Updater) Apply(ctx context.Context, version string, inst Installer, r Report) error {
c, err := u.Download(ctx, version, r)
if err != nil {
return err
}
return inst.Install(ctx, c)
}
// assetIn picks the artifact this install can actually apply. A deb install
// must not be handed the portable tarball, or the next apt operation would find
// a package manager and a filesystem that disagree about what is installed.
func (u *Updater) assetIn(m *Manifest) (Asset, string, bool) {
if NormalizeKind(u.opts.Kind) == KindDeb {
a, ok := m.NativePackage()
return a, KindDeb, ok
}
a, ok := m.Asset()
return a, KindTarball, ok
}
func (u *Updater) transport() Transport {
return Transport{
Client: u.opts.HTTP,
Fallback: u.opts.Fallback,
UserAgent: u.opts.UserAgent,
AttemptTimeout: u.opts.AttemptTimeout,
}
}
+274
View File
@@ -0,0 +1,274 @@
package update
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"aead.dev/minisign"
)
// release is one published version as the catalog and its manifest describe it.
type release struct {
version string
artifact []byte
}
// releaseServer serves a catalog, a per-version immutable manifest, and the
// signed artifacts — the same three hops a real rollback walks.
type releaseServer struct {
*httptest.Server
hits atomic.Int32
}
func serveReleases(t *testing.T, releases ...release) *releaseServer {
t.Helper()
pub, priv, err := minisign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
pubText, err := pub.MarshalText()
if err != nil {
t.Fatal(err)
}
restore := verifyArtifact
verifyArtifact = func(data, sig []byte) error { return verifyWith(string(pubText), data, sig) }
t.Cleanup(func() { verifyArtifact = restore })
rs := &releaseServer{}
mux := http.NewServeMux()
rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rs.hits.Add(1)
mux.ServeHTTP(w, r)
}))
t.Cleanup(rs.Close)
idx := Index{SchemaVersion: 1}
for _, rel := range releases {
name := "Reasonix-" + CurrentPlatform() + ".tar.gz"
base := rs.URL + "/" + rel.version + "/"
idx.Versions = append(idx.Versions, IndexEntry{
Version: rel.version,
Tag: "studio-" + rel.version,
Manifest: base + "latest.json",
})
m := Manifest{
Version: rel.version,
Platforms: map[string]Asset{CurrentPlatform(): {
URL: base + name,
Sig: base + name + ".minisig",
Size: int64(len(rel.artifact)),
SHA256: sha256Hex(rel.artifact),
}},
}
mux.HandleFunc("/"+rel.version+"/latest.json", serveJSON(t, m))
mux.HandleFunc("/"+rel.version+"/"+name, serveBytes(rel.artifact))
mux.HandleFunc("/"+rel.version+"/"+name+".minisig", serveBytes(minisign.Sign(priv, rel.artifact)))
}
mux.HandleFunc("/versions.json", serveJSON(t, idx))
return rs
}
func serveJSON(t *testing.T, v any) http.HandlerFunc {
t.Helper()
body, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return serveBytes(body)
}
func serveBytes(body []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) }
}
func (rs *releaseServer) updater(t *testing.T, current string) *Updater {
t.Helper()
return New(Options{
Current: current,
IndexURL: rs.URL + "/versions.json",
HTTP: rs.Client(),
CacheDir: t.TempDir(),
})
}
// The point of the catalog: the version behind the running one downloads and
// verifies through exactly the path the newest one does.
func TestDownloadRollsBackToAnOlderVersion(t *testing.T) {
old, latest := []byte("the v1.0.0 build"), []byte("the v2.0.0 build")
rs := serveReleases(t, release{"v1.0.0", old}, release{"v2.0.0", latest})
u := rs.updater(t, "v2.0.0")
got, err := u.Download(context.Background(), "v1.0.0", Report{})
if err != nil {
t.Fatalf("Download: %v", err)
}
if got.Version != "v1.0.0" {
t.Fatalf("downloaded %s, want the version that was asked for", got.Version)
}
cached, body, err := Cache{Dir: u.opts.CacheDir}.Verified("v1.0.0")
if err != nil {
t.Fatalf("the rollback artifact should be cached ready to install: %v", err)
}
if string(body) != string(old) {
t.Fatalf("cached the wrong build: %q", body)
}
if cached.Kind != KindTarball {
t.Fatalf("cached kind = %q, want %q", cached.Kind, KindTarball)
}
}
// The pause between the last byte and a usable cache is signature work. A UI
// that is never told about it shows a full bar and no explanation, so the
// phases have to arrive in order and verifying has to be among them.
func TestDownloadNarratesItsPhases(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte(strings.Repeat("x", 4096))})
u := rs.updater(t, "v1.0.0")
var last int64
var phases []string
report := Report{
Bytes: func(received, _ int64) { last = received },
Phase: func(p string) { phases = append(phases, p) },
}
if _, err := u.Download(context.Background(), "v2.0.0", report); err != nil {
t.Fatalf("Download: %v", err)
}
if last != 4096 {
t.Fatalf("final progress = %d bytes, want the whole 4096", last)
}
want := []string{PhaseDownloading, PhaseVerifying, PhaseCached}
if strings.Join(phases, ">") != strings.Join(want, ">") {
t.Fatalf("phases = %v, want %v", phases, want)
}
// A cache hit still ends at PhaseCached; a UI must not be left mid-download.
phases = nil
if _, err := u.Download(context.Background(), "v2.0.0", report); err != nil {
t.Fatalf("second Download: %v", err)
}
if strings.Join(phases, ">") != PhaseCached {
t.Fatalf("cache-hit phases = %v, want just %q", phases, PhaseCached)
}
}
// A second Download must not refetch what is already verified on disk: the user
// who clicked download and then install pays for one transfer, not two.
func TestDownloadReusesTheCachedArtifact(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte("artifact")})
u := rs.updater(t, "v1.0.0")
if _, err := u.Download(context.Background(), "v2.0.0", Report{}); err != nil {
t.Fatalf("Download: %v", err)
}
after := rs.hits.Load()
if _, err := u.Download(context.Background(), "v2.0.0", Report{}); err != nil {
t.Fatalf("second Download: %v", err)
}
// The catalog and manifest are still read (the entry may have been
// republished); the artifact and its signature must not be.
if fetched := rs.hits.Load() - after; fetched > 2 {
t.Fatalf("second Download made %d requests, want the artifact served from cache", fetched)
}
}
func TestDownloadRefusesAVersionNotInTheCatalog(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte("artifact")})
u := rs.updater(t, "v2.0.0")
_, err := u.Download(context.Background(), "v1.9.9", Report{})
if err == nil || !strings.Contains(err.Error(), "not in the published catalog") {
t.Fatalf("error = %v, want a refusal to install an unpublished version", err)
}
}
// The catalog row and the manifest it points at must name the same release. A
// row that has drifted would otherwise install a build the user never picked.
func TestDownloadRefusesAManifestForAnotherVersion(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte("artifact")})
u := rs.updater(t, "v1.0.0")
if _, err := u.ManifestFor(context.Background(), "v2.0.0"); err != nil {
t.Fatalf("the honest catalog must resolve: %v", err)
}
// Repoint the entry at a manifest that names a different version.
lying := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "versions.json") {
serveJSON(t, Index{Versions: []IndexEntry{{
Version: "v2.0.0", Tag: "studio-v2.0.0", Manifest: "http://" + r.Host + "/v2.0.0/latest.json",
}}})(w, r)
return
}
serveJSON(t, Manifest{Version: "v9.9.9"})(w, r)
}))
defer lying.Close()
u.opts.IndexURL = lying.URL + "/versions.json"
_, err := u.ManifestFor(context.Background(), "v2.0.0")
if err == nil || !strings.Contains(err.Error(), "resolves to a manifest for") {
t.Fatalf("error = %v, want a refusal when the catalog and manifest disagree", err)
}
}
func TestDownloadRefusesAnUnsignedArtifact(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte("artifact")})
u := rs.updater(t, "v1.0.0")
verifyArtifact = func([]byte, []byte) error { return fmt.Errorf("signature verification failed") }
if _, err := u.Download(context.Background(), "v2.0.0", Report{}); err == nil {
t.Fatal("an artifact whose signature does not check out must not be cached")
}
if _, _, err := (Cache{Dir: u.opts.CacheDir}).Verified("v2.0.0"); err == nil {
t.Fatal("a rejected artifact must leave nothing installable behind")
}
}
// A deb install must never be handed the portable tarball; when the release
// carries no package, that is a refusal, not a silent substitution.
func TestDownloadRefusesToSubstituteAnArtifactKind(t *testing.T) {
rs := serveReleases(t, release{"v2.0.0", []byte("artifact")})
u := rs.updater(t, "v1.0.0")
u.opts.Kind = KindDeb
_, err := u.Download(context.Background(), "v2.0.0", Report{})
if err == nil || !strings.Contains(err.Error(), KindDeb) {
t.Fatalf("error = %v, want a refusal naming the missing deb", err)
}
}
// Apply is the whole move; it must not reach the installer for a version that
// failed to download.
func TestApplyInstallsOnlyWhatVerified(t *testing.T) {
rs := serveReleases(t, release{"v1.0.0", []byte("the v1.0.0 build")})
u := rs.updater(t, "v2.0.0")
inst := &recordingInstaller{}
if err := u.Apply(context.Background(), "v1.0.0", inst, Report{}); err != nil {
t.Fatalf("Apply: %v", err)
}
if inst.got.Version != "v1.0.0" {
t.Fatalf("installer received %+v, want the requested version", inst.got)
}
if err := u.Apply(context.Background(), "v1.2.3", inst, Report{}); err == nil {
t.Fatal("Apply must fail for a version that is not published")
}
if inst.calls != 1 {
t.Fatalf("installer ran %d times, want only for the verified download", inst.calls)
}
}
type recordingInstaller struct {
got Cached
calls int
}
func (r *recordingInstaller) Install(_ context.Context, c Cached) error {
r.got, r.calls = c, r.calls+1
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package update
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// IndexURL is the rollback catalog published beside the release assets: which
// versions were ever public, and where each one's immutable manifest lives.
const IndexURL = "https://dl.reasonix.io/versions.json"
// Index is versions.json. It deliberately carries no asset URLs or signatures —
// an entry points at that version's own manifest, so rolling back resolves and
// verifies through the same path a forward update does.
type Index struct {
SchemaVersion int `json:"schemaVersion"`
UpdatedAt string `json:"updatedAt"`
Versions []IndexEntry `json:"versions"`
}
// IndexEntry is one published release, newest first in Index.Versions.
type IndexEntry struct {
Version string `json:"version"`
Tag string `json:"tag"`
Channel string `json:"channel"`
PublishedAt string `json:"publishedAt"`
Manifest string `json:"manifest"`
}
// FetchIndex reads the rollback catalog. A newer schemaVersion is not an error
// and unknown fields are ignored — an old build being able to read this is the
// whole point. Entries without a version or manifest are dropped rather than
// failing the list: one malformed row must not cost the user every other
// version they could go back to.
func FetchIndex(ctx context.Context, c *http.Client, url string) (*Index, error) {
if strings.TrimSpace(url) == "" {
url = IndexURL
}
body, err := fetchJSON(ctx, c, url)
if err != nil {
return nil, err
}
var idx Index
if err := json.Unmarshal(body, &idx); err != nil {
return nil, fmt.Errorf("update: parse %s: %w", url, err)
}
kept := idx.Versions[:0]
for _, e := range idx.Versions {
if strings.TrimSpace(e.Version) != "" && strings.TrimSpace(e.Manifest) != "" {
kept = append(kept, e)
}
}
idx.Versions = kept
return &idx, nil
}
// FetchManifestAt reads one version's immutable manifest.
func FetchManifestAt(ctx context.Context, c *http.Client, url string) (*Manifest, error) {
body, err := fetchJSON(ctx, c, url)
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(body, &m); err != nil {
return nil, fmt.Errorf("update: parse %s: %w", url, err)
}
if strings.TrimSpace(m.Version) == "" {
return nil, fmt.Errorf("update: %s carries no version", url)
}
return &m, nil
}
// Rollbackable reports the entries a user may move to from current, newest
// first. The running version is excluded — "go back to where you already are"
// is not an offer — and so is anything this build cannot install.
func (i *Index) Rollbackable(current string) []IndexEntry {
if i == nil {
return nil
}
var out []IndexEntry
for _, e := range i.Versions {
if SameVersion(e.Version, current) {
continue
}
out = append(out, e)
}
return out
}
// SameVersion compares release versions tolerating the leading v the tag and
// the manifest disagree about.
func SameVersion(a, b string) bool {
return strings.EqualFold(normalizeVersion(a), normalizeVersion(b))
}
// CompareVersions orders two release versions: -1 when a is older. The core is
// compared numerically (1.9.0 is older than 1.25.0, which a lexical compare gets
// backwards), and a release outranks its own prereleases — otherwise moving from
// 2.0.0-rc1 to 2.0.0 would be presented as a rollback.
func CompareVersions(a, b string) int {
aCore, aPre := splitPrerelease(normalizeVersion(a))
bCore, bPre := splitPrerelease(normalizeVersion(b))
as, bs := strings.Split(aCore, "."), strings.Split(bCore, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
x, y := segmentAt(as, i), segmentAt(bs, i)
xn, xErr := strconv.Atoi(x)
yn, yErr := strconv.Atoi(y)
if xErr != nil || yErr != nil {
if x != y {
return sign(strings.Compare(x, y))
}
continue
}
if xn != yn {
return sign(xn - yn)
}
}
switch {
case aPre == bPre:
return 0
case aPre == "":
return 1
case bPre == "":
return -1
default:
return sign(strings.Compare(aPre, bPre))
}
}
// splitPrerelease separates 2.0.0-rc1 into its core and its prerelease tag.
func splitPrerelease(v string) (core, pre string) {
if core, pre, ok := strings.Cut(v, "-"); ok {
return core, pre
}
return v, ""
}
// IsOlderThan reports whether the entry is behind current — the direction that
// makes a move a rollback rather than an update.
func (e IndexEntry) IsOlderThan(current string) bool {
return CompareVersions(e.Version, current) < 0
}
func segmentAt(parts []string, i int) string {
if i < len(parts) {
return parts[i]
}
return "0"
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
func normalizeVersion(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
func fetchJSON(ctx context.Context, c *http.Client, url string) ([]byte, error) {
if c == nil {
c = http.DefaultClient
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update: %s: %s", url, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
+151
View File
@@ -0,0 +1,151 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func indexServer(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// The index is the shape scripts/update-versions-index.sh publishes.
const sampleIndex = `{
"schemaVersion": 1,
"updatedAt": "2026-09-01T00:00:00Z",
"versions": [
{"version":"2.0.0","tag":"studio-v2.0.0","channel":"stable","publishedAt":"2026-09-01T00:00:00Z",
"manifest":"https://dl.reasonix.io/studio-v2.0.0/latest.json"},
{"version":"1.25.1","tag":"desktop-v1.25.1","channel":"stable","publishedAt":"2026-08-12T10:00:00Z",
"manifest":"https://dl.reasonix.io/desktop-v1.25.1/latest.json"}
]
}`
func TestFetchIndexReadsPublishedShape(t *testing.T) {
srv := indexServer(t, 200, sampleIndex)
idx, err := FetchIndex(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("FetchIndex: %v", err)
}
if len(idx.Versions) != 2 {
t.Fatalf("got %d versions, want 2", len(idx.Versions))
}
if idx.Versions[0].Tag != "studio-v2.0.0" {
t.Errorf("first entry = %q, want the newest release", idx.Versions[0].Tag)
}
if idx.Versions[0].Manifest == "" {
t.Error("an entry without its manifest cannot be rolled back to")
}
}
// A newer publisher may add fields. Refusing the whole list would strand a user
// on a broken build with no way back — exactly when they need this most.
func TestFetchIndexToleratesUnknownFields(t *testing.T) {
srv := indexServer(t, 200, `{"schemaVersion":9,"future":{"x":1},"versions":[
{"version":"2.0.0","tag":"studio-v2.0.0","manifest":"https://x/latest.json","extra":true}]}`)
idx, err := FetchIndex(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("FetchIndex: %v", err)
}
if len(idx.Versions) != 1 {
t.Fatalf("got %d versions, want the entry a newer index still carries", len(idx.Versions))
}
}
// One malformed row must not cost the user every other version.
func TestFetchIndexDropsUnusableEntries(t *testing.T) {
srv := indexServer(t, 200, `{"schemaVersion":1,"versions":[
{"version":"","manifest":"https://x/latest.json"},
{"version":"1.9.0","manifest":""},
{"version":"2.0.0","tag":"studio-v2.0.0","manifest":"https://x/2/latest.json"}]}`)
idx, err := FetchIndex(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("FetchIndex: %v", err)
}
if len(idx.Versions) != 1 || idx.Versions[0].Version != "2.0.0" {
t.Fatalf("kept %+v, want only the usable entry", idx.Versions)
}
}
func TestFetchIndexReportsAnUnavailableCatalog(t *testing.T) {
srv := indexServer(t, 503, "nope")
if _, err := FetchIndex(context.Background(), srv.Client(), srv.URL); err == nil {
t.Error("a 503 must surface, not read as an empty catalog")
}
}
func TestRollbackableExcludesTheRunningVersion(t *testing.T) {
srv := indexServer(t, 200, sampleIndex)
idx, err := FetchIndex(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatal(err)
}
got := idx.Rollbackable("v2.0.0") // the leading v the tag disagrees about
if len(got) != 1 || got[0].Version != "1.25.1" {
t.Fatalf("rollbackable = %+v, want only the older release", got)
}
if !got[0].IsOlderThan("2.0.0") {
t.Error("1.25.1 must read as older than 2.0.0")
}
}
func TestCompareVersionsOrdersReleases(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"2.0.0", "1.25.1", 1},
{"1.25.1", "2.0.0", -1},
{"1.25.1", "v1.25.1", 0},
{"1.9.0", "1.25.0", -1}, // numeric, not lexical: 9 < 25
{"2.0.0", "2.0.0-rc1", 1},
}
for _, tc := range cases {
if got := CompareVersions(tc.a, tc.b); got != tc.want {
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestFetchManifestAtRequiresAVersion(t *testing.T) {
ok := indexServer(t, 200, `{"version":"1.25.1","platforms":{}}`)
m, err := FetchManifestAt(context.Background(), ok.Client(), ok.URL)
if err != nil {
t.Fatalf("FetchManifestAt: %v", err)
}
if m.Version != "1.25.1" {
t.Errorf("version = %q", m.Version)
}
empty := indexServer(t, 200, `{"platforms":{}}`)
if _, err := FetchManifestAt(context.Background(), empty.Client(), empty.URL); err == nil {
t.Error("a manifest without a version must not be installable")
}
}
// A prerelease must read as older than its own release, so moving forward off
// an rc is never presented as a rollback.
func TestPrereleaseRanksBelowItsRelease(t *testing.T) {
idx := &Index{Versions: []IndexEntry{
{Version: "2.0.0", Manifest: "https://x/2/latest.json"},
{Version: "1.25.1", Manifest: "https://x/1/latest.json"},
}}
got := idx.Rollbackable("2.0.0-rc1")
if len(got) != 2 {
t.Fatalf("rollbackable = %+v, want both entries offered from an rc", got)
}
if got[0].IsOlderThan("2.0.0-rc1") {
t.Error("2.0.0 must read as newer than 2.0.0-rc1 — that move is an update")
}
if !got[1].IsOlderThan("2.0.0-rc1") {
t.Error("1.25.1 must read as older than 2.0.0-rc1")
}
}
+12
View File
@@ -0,0 +1,12 @@
package update
// WindowsHandoff is what the update helper needs to finish an install after
// this process is gone. A value, not five positional strings: every field is a
// path, and swapping two of them compiles.
type WindowsHandoff struct {
InstallerPath string // verified artifact in the update cache
InstallerSHA256 string // digest the helper re-checks before running it
InstallDir string // InstallRoot: owns current.json, or the flat dir
RelaunchPath string // launcher to start once the install lands
StagingDir string // where the verified helper copy is placed
}
@@ -1,4 +1,4 @@
package main
package update
import (
"fmt"
@@ -0,0 +1,103 @@
package update
import (
"os"
"strings"
"testing"
)
func TestInstallerCommandLineUsesVisibleUpdateModeAndKeepsDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) {
got := windowsUpdateHandoffArgs(
4242,
`C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
strings.Repeat("a", 64),
`D:\Tools\Reasonix App`,
`D:\Tools\Reasonix App\reasonix-desktop.exe`,
"v1.6.0",
"2026-07-29T00:00:00Z",
"transaction-1",
)
want := []string{
"--parent-pid", "4242",
"--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
"--installer-sha256", strings.Repeat("a", 64),
"--to-version", "v1.6.0",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
"--install-dir", `D:\Tools\Reasonix App`,
"--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
func TestWindowsVersionedUpdateHandoffArgsDoNotRequireLegacyPendingIdentity(t *testing.T) {
got := windowsVersionedUpdateHandoffArgs(
4242,
`C:\Temp\Reasonix-installer.exe`,
strings.Repeat("b", 64),
`D:\Tools\Reasonix`,
`D:\Tools\Reasonix\reasonix-launcher.exe`,
"v1.20.0",
)
want := []string{
"--parent-pid", "4242",
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", strings.Repeat("b", 64),
"--to-version", "v1.20.0",
"--install-layout", "versioned-v1",
"--install-dir", `D:\Tools\Reasonix`,
"--relaunch", `D:\Tools\Reasonix\reasonix-launcher.exe`,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("args = %#v, want %#v", got, want)
}
for _, legacy := range []string{"--created-at", "--transaction-id"} {
if strings.Contains(strings.Join(got, " "), legacy) {
t.Fatalf("versioned handoff must not carry legacy field %s", legacy)
}
}
}
func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) {
data, err := os.ReadFile("install_windows.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
if !strings.Contains(source, "return startWindowsUpdateHelper(") {
t.Fatal("Windows update handoff does not require the observed helper path")
}
if strings.Contains(source, "return installerCommand(") {
t.Fatal("Windows update silently falls back to an unobserved installer")
}
if !strings.Contains(source, "cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}") {
t.Fatal("Windows handoff helper should stay hidden while NSIS shows update progress")
}
helperData, err := os.ReadFile("../../cmd/update-helper/main_windows.go")
if err != nil {
t.Fatal(err)
}
helperSource := string(helperData)
if !strings.Contains(helperSource, "reconcileWindowsUninstallRegistrationFn(installDir, toVersion)") {
t.Fatal("versioned Windows activation must refresh its managed uninstall registration")
}
if strings.Contains(helperSource, "installerCommandLine(installer, installDir), HideWindow: true") {
t.Fatal("update helper still hides the NSIS progress window")
}
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package update
import (
"os/exec"
"reasonix/internal/repair"
)
// installerCommand exists only so the handoff compiles off Windows; the Windows
// apply path is never dispatched there.
func installerCommand(name, _ string) *exec.Cmd {
return exec.Command(name)
}
func (h WindowsHandoff) Start(_ *repair.UpdateTransaction) error {
return installerCommand(h.InstallerPath, h.InstallDir).Start()
}
func (h WindowsHandoff) StartVersioned(_ string) error {
return installerCommand(h.InstallerPath, h.InstallDir).Start()
}
@@ -1,6 +1,6 @@
//go:build windows
package main
package update
import (
"bytes"
@@ -50,19 +50,22 @@ func installerCommand(name, dir string) *exec.Cmd {
return cmd
}
func startWindowsUpdateHandoff(installerPath, installerSHA256, installDir, relaunchPath string, prepared *repair.UpdateTransaction) error {
// The helper is the only process that can observe an installer failure after
// the desktop exits and route recovery back through Guard. Starting NSIS
// directly here would make a failed/partial install indistinguishable from a
// successful handoff, so a missing or quarantined helper must fail safely.
return startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath, prepared)
// Start hands a legacy flat install to the helper — the only process left to
// observe an installer failure after the desktop exits. Starting NSIS directly
// would make a partial install look like a successful handoff, so a missing or
// quarantined helper fails here instead.
func (h WindowsHandoff) Start(prepared *repair.UpdateTransaction) error {
return startWindowsUpdateHelper(h, prepared)
}
func startWindowsVersionedUpdateHandoff(installerPath, installerSHA256, installDir, relaunchPath, targetVersion string) error {
if installDir == "" {
// StartVersioned hands a versioned-v1 install to the helper. It needs no
// prepared transaction: the version directory is published beside the running
// one and current.json swaps last, so there is nothing to roll back by hand.
func (h WindowsHandoff) StartVersioned(targetVersion string) error {
if h.InstallDir == "" {
return os.ErrNotExist
}
helperPath, helperSHA256, err := prepareVersionedWindowsUpdateHelper(installDir)
helperPath, helperSHA256, err := prepareVersionedWindowsUpdateHelper(h.InstallDir, h.StagingDir)
if err != nil {
return err
}
@@ -73,7 +76,7 @@ func startWindowsVersionedUpdateHandoff(installerPath, installerSHA256, installD
defer releaseExecution()
err = retryWindowsUpdateHelperStart(func() error {
cmd := exec.Command(helperPath, windowsVersionedUpdateHandoffArgs(
os.Getpid(), installerPath, installerSHA256, installDir, relaunchPath, targetVersion,
os.Getpid(), h.InstallerPath, h.InstallerSHA256, h.InstallDir, h.RelaunchPath, targetVersion,
)...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
@@ -84,15 +87,15 @@ func startWindowsVersionedUpdateHandoff(installerPath, installerSHA256, installD
return nil
}
func startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath string, prepared *repair.UpdateTransaction) error {
if installDir == "" {
func startWindowsUpdateHelper(h WindowsHandoff, prepared *repair.UpdateTransaction) error {
if h.InstallDir == "" {
return os.ErrNotExist
}
preparedHelperSHA256, err := preparedWindowsUpdateHelperSHA256(prepared, installDir)
preparedHelperSHA256, err := preparedWindowsUpdateHelperSHA256(prepared)
if err != nil {
return err
}
helperPath, helperSHA256, err := prepareWindowsUpdateHelper(installDir, preparedHelperSHA256)
helperPath, helperSHA256, err := prepareWindowsUpdateHelper(h.InstallDir, h.StagingDir, preparedHelperSHA256)
if err != nil {
return err
}
@@ -104,10 +107,10 @@ func startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaun
err = retryWindowsUpdateHelperStart(func() error {
cmd := exec.Command(helperPath, windowsUpdateHandoffArgs(
os.Getpid(),
installerPath,
installerSHA256,
installDir,
relaunchPath,
h.InstallerPath,
h.InstallerSHA256,
h.InstallDir,
h.RelaunchPath,
prepared.ToVersion,
prepared.CreatedAt,
repair.UpdateTransactionID(prepared),
@@ -121,7 +124,7 @@ func startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaun
return nil
}
func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, installDir string) (string, error) {
func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction) (string, error) {
if prepared == nil || prepared.TargetKind != "file" || repair.UpdateTransactionID(prepared) == "" {
return "", fmt.Errorf("prepare Windows update helper: transaction identity is incomplete")
}
@@ -136,7 +139,6 @@ func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, insta
}
return strings.TrimSpace(file.SHA256), nil
}
_ = installDir
return "", fmt.Errorf("prepare Windows update helper: helper is outside the prepared release unit")
}
@@ -149,49 +151,37 @@ func validWindowsSHA256(value string) bool {
return err == nil
}
func prepareWindowsUpdateHelper(installDir, preparedSHA256 string) (string, [sha256.Size]byte, error) {
src := resolveWindowsUpdateHelperSource(installDir)
data, err := os.ReadFile(src)
func prepareWindowsUpdateHelper(installDir, stagingDir, preparedSHA256 string) (string, [sha256.Size]byte, error) {
data, err := os.ReadFile(resolveWindowsUpdateHelperSource(installDir))
if err != nil {
return "", [sha256.Size]byte{}, err
}
expectedSHA256 := sha256.Sum256(data)
// Identity before shape: a helper that drifted from the prepared transaction
// is the specific failure, and saying so beats "invalid PE image".
if !strings.EqualFold(hex.EncodeToString(expectedSHA256[:]), strings.TrimSpace(preparedSHA256)) {
return "", [sha256.Size]byte{}, fmt.Errorf("packaged Windows update helper changed after transaction prepare")
}
if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil {
return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err)
}
dir, err := updateCacheDir()
if err != nil {
return "", [sha256.Size]byte{}, err
}
dst, err := stageWindowsUpdateHelperCopy(dir, data)
if err != nil {
return "", [sha256.Size]byte{}, err
}
return dst, expectedSHA256, nil
return stageValidatedWindowsUpdateHelper(data, expectedSHA256, stagingDir)
}
func prepareVersionedWindowsUpdateHelper(installDir string) (string, [sha256.Size]byte, error) {
src := resolveWindowsUpdateHelperSource(installDir)
data, err := os.ReadFile(src)
func prepareVersionedWindowsUpdateHelper(installDir, stagingDir string) (string, [sha256.Size]byte, error) {
data, err := os.ReadFile(resolveWindowsUpdateHelperSource(installDir))
if err != nil {
return "", [sha256.Size]byte{}, err
}
expectedSHA256 := sha256.Sum256(data)
return stageValidatedWindowsUpdateHelper(data, sha256.Sum256(data), stagingDir)
}
func stageValidatedWindowsUpdateHelper(data []byte, sum [sha256.Size]byte, stagingDir string) (string, [sha256.Size]byte, error) {
if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil {
return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err)
}
dir, err := updateCacheDir()
dst, err := stageWindowsUpdateHelperCopy(stagingDir, data)
if err != nil {
return "", [sha256.Size]byte{}, err
}
dst, err := stageWindowsUpdateHelperCopy(dir, data)
if err != nil {
return "", [sha256.Size]byte{}, err
}
return dst, expectedSHA256, nil
return dst, sum, nil
}
func stageWindowsUpdateHelperCopy(dir string, data []byte) (string, error) {
@@ -1,6 +1,6 @@
//go:build windows
package main
package update
import (
"crypto/sha256"
@@ -168,12 +168,12 @@ func TestPreparedWindowsUpdateHelperSHA256BindsReleaseUnitMember(t *testing.T) {
SHA256: expected,
}},
}
got, err := preparedWindowsUpdateHelperSHA256(prepared, installDir)
got, err := preparedWindowsUpdateHelperSHA256(prepared)
if err != nil || got != expected {
t.Fatalf("prepared helper SHA-256 = %q, %v", got, err)
}
prepared.Files[0].MissingBefore = true
if _, err := preparedWindowsUpdateHelperSHA256(prepared, installDir); err == nil {
if _, err := preparedWindowsUpdateHelperSHA256(prepared); err == nil {
t.Fatal("prepared transaction with a missing helper authorized execution")
}
}
@@ -188,7 +188,7 @@ func TestPrepareWindowsUpdateHelperRejectsPreparedHashDrift(t *testing.T) {
t.Fatal(err)
}
prepared := sha256.Sum256([]byte("prepared-helper"))
_, _, err := prepareWindowsUpdateHelper(installDir, fmt.Sprintf("%x", prepared))
_, _, err := prepareWindowsUpdateHelper(installDir, t.TempDir(), fmt.Sprintf("%x", prepared))
if err == nil || !strings.Contains(err.Error(), "changed after transaction prepare") {
t.Fatalf("changed packaged helper = %v", err)
}
+99
View File
@@ -0,0 +1,99 @@
package update
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/installlayout"
)
// Layout is where this build lives on disk. The three are resolved together
// because they are three answers to one question, and an installer that pairs
// a root from one install with a launcher from another writes a broken one.
type Layout struct {
Executable string // the running binary, symlinks resolved
Root string // owns current.json, or the flat install directory
Launcher string // thin launcher to restart through; else Executable
}
// Here resolves the running build's layout. Every field is empty when the
// executable path cannot be resolved at all — a caller must treat that as "do
// not install", not as "install into the current directory".
func Here() Layout {
exe := currentExecutable()
if exe == "" {
return Layout{}
}
l := Layout{Executable: exe, Root: filepath.Dir(exe)}
if root, err := installlayout.ResolveInstallRoot(exe); err == nil && root != "" {
l.Root = root
}
l.Launcher = launcherIn(l.Root, exe)
return l
}
// Relaunch starts the launcher and returns once it has been started, not once
// it is up: the caller is about to exit so the new process can replace it.
func (l Layout) Relaunch() error {
if l.Launcher == "" {
return os.ErrNotExist
}
args := []string{}
// Only the legacy Guard understands "launch --detach"; the thin launcher
// strips it. Guard is a one-release migration fallback for flat 1.18-1.19.1.
if strings.Contains(strings.ToLower(filepath.Base(l.Launcher)), "guard") {
args = []string{"launch", "--detach"}
}
cmd := exec.Command(l.Launcher, args...)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
return cmd.Start()
}
// Versioned reports whether this install carries the v1.20+ versioned layout,
// where a release is published into its own directory and current.json swaps
// last. A flat install predates that and needs the transactional apply path.
func (l Layout) Versioned() bool {
return l.Root != "" && installlayout.HasCurrent(l.Root)
}
func currentExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
// launcherIn finds the entry point that survives the running binary being
// replaced. An install missing every candidate relaunches itself, which is
// correct for a portable copy that was never installed.
func launcherIn(root, exe string) string {
for _, name := range launcherNames() {
path := filepath.Join(root, name)
if _, err := os.Stat(path); err == nil {
return path
}
}
// An incomplete install can still have Guard beside the executable when the
// resolved root pointed elsewhere.
if runtime.GOOS == "windows" {
guard := filepath.Join(filepath.Dir(exe), "reasonix-guard.exe")
if _, err := os.Stat(guard); err == nil {
return guard
}
}
return exe
}
func launcherNames() []string {
if runtime.GOOS == "windows" {
return []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-guard.exe"}
}
return []string{"reasonix-launcher", "reasonix-guard"}
}
+40
View File
@@ -0,0 +1,40 @@
package update
// Offer is what the shell should do about a release it just learned about.
// Separating "there is something newer" from "install it" is the whole point:
// a user who rolled back must still be told a fix exists without being moved
// onto it again.
type Offer struct {
Available bool // a version other than the running one is published
Newer bool // that version is ahead of what is running
AutoInstall bool // the shell may install it without being asked
Pinned bool // the user chose to stay on the running build
PinnedTo string // the version they chose, when it is still the running one
StalePin bool // pinned to something this build is not; the pin no longer describes reality
}
// OfferFor decides what a published version means for a running build: a pin
// suppresses installation, never discovery. It prevents the failure every
// rollback UI gets wrong — the user rolls back on Monday, the updater puts the
// broken build back on Tuesday, and they uninstall on Wednesday.
func OfferFor(current, latest, pinned string) Offer {
offer := Offer{
Available: latest != "" && !SameVersion(latest, current),
Newer: latest != "" && CompareVersions(latest, current) > 0,
}
switch {
case pinned == "":
offer.AutoInstall = offer.Newer
case SameVersion(pinned, current):
// Honoured: stay put, but keep reporting what exists.
offer.Pinned = true
offer.PinnedTo = pinned
default:
// The running build is not the pinned one — the user moved on by other
// means (a manual install, a reinstall). A pin that describes a version
// nobody is running must not keep suppressing updates forever.
offer.StalePin = true
offer.AutoInstall = offer.Newer
}
return offer
}
+72
View File
@@ -0,0 +1,72 @@
package update
import "testing"
func TestOfferForUnpinnedFollowsTheChannel(t *testing.T) {
got := OfferFor("2.0.0", "2.1.0", "")
if !got.Available || !got.Newer || !got.AutoInstall {
t.Fatalf("offer = %+v, want an installable newer release", got)
}
if got.Pinned {
t.Error("nothing was pinned")
}
}
// The failure every rollback UI gets wrong: the user leaves a broken build on
// Monday and the updater puts them back on it on Tuesday.
func TestOfferForPinnedNeverInstalls(t *testing.T) {
got := OfferFor("1.25.1", "2.0.0", "1.25.1")
if got.AutoInstall {
t.Error("a pinned build must not be updated out from under the user")
}
if !got.Available || !got.Newer {
t.Error("a pin suppresses installing, not knowing: the fix must still be reported")
}
if !got.Pinned || got.PinnedTo != "1.25.1" {
t.Errorf("offer = %+v, want the pin surfaced so the user can release it", got)
}
}
// A pin naming a version nobody is running has stopped describing reality, and
// must not suppress updates forever.
func TestOfferForStalePinResumesUpdates(t *testing.T) {
got := OfferFor("2.0.0", "2.1.0", "1.25.1")
if !got.StalePin {
t.Error("pinned to 1.25.1 while running 2.0.0 is a stale pin")
}
if !got.AutoInstall {
t.Error("a stale pin must not keep the user off updates")
}
if got.Pinned {
t.Error("a stale pin is not an honoured pin")
}
}
func TestOfferForIgnoresTheLeadingV(t *testing.T) {
got := OfferFor("v2.0.0", "2.0.0", "")
if got.Available || got.Newer || got.AutoInstall {
t.Fatalf("offer = %+v, want nothing to do when the versions match", got)
}
if pinned := OfferFor("2.0.0", "2.1.0", "v2.0.0"); !pinned.Pinned {
t.Errorf("offer = %+v, want the pin honoured despite the leading v", pinned)
}
}
// Rolling back leaves the published latest ahead of the running build. That is
// the state the pin exists for, and it must not read as "nothing available".
func TestOfferAfterARollback(t *testing.T) {
got := OfferFor("1.25.1", "2.0.0", "1.25.1")
if !got.Available {
t.Error("the version they left must stay visible so they can go forward again")
}
if got.AutoInstall {
t.Error("going forward again is the user's decision, not the updater's")
}
}
func TestOfferForNoPublishedVersion(t *testing.T) {
got := OfferFor("2.0.0", "", "")
if got.Available || got.Newer || got.AutoInstall {
t.Fatalf("offer = %+v, want nothing offered when the catalog is unknown", got)
}
}
+65
View File
@@ -0,0 +1,65 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"path"
"strings"
)
// releaseUnitMembers are the binaries a Linux tarball must carry. reasonix-guard
// is still packaged for v1.18-v1.19 updaters reading these archives; v1.20+
// extracts it and never persists it again.
var releaseUnitMembers = []string{"reasonix-desktop", "reasonix-guard", "reasonix"}
// ExtractReleaseUnit reads the release binaries out of a verified .tar.gz. It
// requires every member and rejects duplicates: a partial extraction would
// publish a version directory that is missing a binary, which the pointer swap
// would then make current.
func ExtractReleaseUnit(targz []byte) (map[string][]byte, error) {
want := make(map[string]struct{}, len(releaseUnitMembers))
for _, name := range releaseUnitMembers {
want[name] = struct{}{}
}
found := make(map[string][]byte, len(want))
gz, err := gzip.NewReader(bytes.NewReader(targz))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
name := path.Base(strings.TrimSpace(h.Name))
if _, ok := want[name]; !ok {
continue
}
if h.Typeflag != tar.TypeReg || h.Size < 0 {
return nil, fmt.Errorf("update: release member %q is not a regular file", name)
}
if _, duplicate := found[name]; duplicate {
return nil, fmt.Errorf("update: release member %q appears more than once", name)
}
body, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
found[name] = body
}
for _, name := range releaseUnitMembers {
if _, ok := found[name]; !ok {
return nil, fmt.Errorf("update: release member %q not found in archive", name)
}
}
return found, nil
}
@@ -0,0 +1,89 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"strings"
"testing"
)
func makeArchive(t *testing.T, headers []tar.Header, bodies [][]byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for i := range headers {
header := headers[i]
if err := tw.WriteHeader(&header); err != nil {
t.Fatal(err)
}
if i < len(bodies) {
if _, err := tw.Write(bodies[i]); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func regularMember(name string, body []byte) tar.Header {
return tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}
}
func completeRelease() ([]tar.Header, [][]byte) {
return []tar.Header{
regularMember("reasonix-desktop", []byte("desktop")),
regularMember("reasonix-guard", []byte("guard")),
regularMember("reasonix", []byte("cli")),
}, [][]byte{
[]byte("desktop"), []byte("guard"), []byte("cli"),
}
}
// Every member is resolved by basename, so an archive carrying two paths that
// end in the same name is ambiguous about which one gets published.
func TestExtractReleaseUnitRejectsAmbiguousMembers(t *testing.T) {
headers, bodies := completeRelease()
if got, err := ExtractReleaseUnit(makeArchive(t, headers, bodies)); err != nil ||
string(got["reasonix-desktop"]) != "desktop" {
t.Fatalf("complete release extraction = %v, %q", err, got["reasonix-desktop"])
}
duplicateHeaders := append(append([]tar.Header(nil), headers...), regularMember("nested/reasonix", []byte("duplicate")))
duplicateBodies := append(append([][]byte(nil), bodies...), []byte("duplicate"))
if _, err := ExtractReleaseUnit(makeArchive(t, duplicateHeaders, duplicateBodies)); err == nil ||
!strings.Contains(err.Error(), "appears more than once") {
t.Fatalf("duplicate release member error = %v", err)
}
nonRegular := append([]tar.Header(nil), headers...)
nonRegular[1] = tar.Header{Name: "reasonix-guard", Typeflag: tar.TypeSymlink, Linkname: "outside"}
nonRegularBodies := [][]byte{bodies[0], nil, bodies[2]}
if _, err := ExtractReleaseUnit(makeArchive(t, nonRegular, nonRegularBodies)); err == nil ||
!strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("non-regular release member error = %v", err)
}
}
// A missing member must fail before anything is published: the pointer swap
// would otherwise make a version directory current that has no binary in it.
func TestExtractReleaseUnitRequiresEveryMember(t *testing.T) {
headers, bodies := completeRelease()
for i, missing := range releaseUnitMembers {
short := append([]tar.Header(nil), headers[:i]...)
short = append(short, headers[i+1:]...)
shortBodies := append([][]byte(nil), bodies[:i]...)
shortBodies = append(shortBodies, bodies[i+1:]...)
_, err := ExtractReleaseUnit(makeArchive(t, short, shortBodies))
if err == nil || !strings.Contains(err.Error(), missing) {
t.Fatalf("archive without %s: err = %v, want it named", missing, err)
}
}
}
+314
View File
@@ -0,0 +1,314 @@
package update
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// MaxAssetSize caps a release artifact. A response that declares more is
// refused before it is read, so a redirect to something enormous cannot fill
// the disk while it is being verified.
const MaxAssetSize = int64(1 << 30)
// Attempts caps how many times a transient transport failure (connection reset,
// read timeout, gateway 5xx) is retried. CN IPv6 routes to Cloudflare reset
// mid-transfer often enough that a retry or two usually completes the download.
const Attempts = 3
// ErrTooLarge is a response bigger than MaxAssetSize. It is deliberately not
// transient: retrying cannot make it smaller.
var ErrTooLarge = errors.New("update: response exceeds allowed size")
// Backoff is the pause before the Nth retry; a variable so tests shrink it.
var Backoff = func(attempt int) time.Duration { return time.Duration(attempt) * 500 * time.Millisecond }
// StatusError is a response the server refused with a status. Typing it is what
// lets a 404 or a Cloudflare 403 be told apart from a network blip — an untyped
// error read as transient and burned every retry before surfacing.
type StatusError struct {
URL string
Status string
Code int
}
func (e *StatusError) Error() string { return fmt.Sprintf("GET %s: %s", e.URL, e.Status) }
// Transient reports whether retrying err could plausibly succeed.
func Transient(err error) bool {
if errors.Is(err, ErrTooLarge) {
return false
}
var statusErr *StatusError
if !errors.As(err, &statusErr) {
return true
}
return statusErr.Code == http.StatusRequestTimeout ||
statusErr.Code == http.StatusTooManyRequests ||
statusErr.Code >= 500
}
// Transport fetches release artifacts. Fallback is tried from the second
// attempt: a mid-transfer reset usually means the default (often IPv6) route is
// the problem, so the retry takes a different one.
type Transport struct {
Client *http.Client
Fallback *http.Client
UserAgent string
// AttemptTimeout bounds one Fetch attempt: a primary that stalls without
// erroring would otherwise hold the budget and the retry that reaches the
// fallback route would never run. Download has none; artifacts are large.
AttemptTimeout time.Duration
}
// Retry runs attempt 1..Attempts of fetch until one succeeds, pausing between
// tries. It stops early on a non-transient failure or a cancelled context.
func Retry(ctx context.Context, fetch func(attempt int) error) error {
var err error
for attempt := 1; attempt <= Attempts; attempt++ {
if err = fetch(attempt); err == nil {
return nil
}
if !Transient(err) || ctx.Err() != nil || attempt == Attempts {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(Backoff(attempt)):
}
}
return err
}
// Fetch GETs a URL fully into memory, bounded by maxBytes.
func (t Transport) Fetch(ctx context.Context, url string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return nil, fmt.Errorf("update: invalid response size limit %d", maxBytes)
}
var out []byte
err := Retry(ctx, func(attempt int) error {
attemptCtx, cancel := t.attemptContext(ctx)
defer cancel()
data, err := t.fetchOnce(attemptCtx, t.clientFor(attempt), url, maxBytes)
if err != nil {
return err
}
out = data
return nil
})
return out, err
}
// Download fetches an artifact, resuming from what it already holds when a
// retry is needed. expectedSize is the manifest's size: 0 leaves it unbounded
// up to MaxAssetSize.
func (t Transport) Download(ctx context.Context, url string, expectedSize int64, onProgress ProgressFunc) ([]byte, error) {
if expectedSize < 0 || expectedSize > MaxAssetSize {
return nil, fmt.Errorf("update: invalid expected asset size %d", expectedSize)
}
total := expectedSize
var buf bytes.Buffer
err := Retry(ctx, func(attempt int) error {
return t.downloadInto(ctx, t.clientFor(attempt), url, expectedSize, &buf, &total, onProgress)
})
if err != nil {
return nil, err
}
if expectedSize > 0 && int64(buf.Len()) != expectedSize {
return nil, fmt.Errorf("update: downloaded size mismatch: got %d want %d", buf.Len(), expectedSize)
}
return buf.Bytes(), nil
}
// FetchOnce is Fetch without the retry, for a caller that owns its own attempt
// budget — the manifest path splits one timeout across two transports and must
// not have a retry loop spend it.
func (t Transport) FetchOnce(ctx context.Context, c *http.Client, url string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return nil, fmt.Errorf("update: invalid response size limit %d", maxBytes)
}
if c == nil {
c = t.clientFor(1)
}
return t.fetchOnce(ctx, c, url, maxBytes)
}
func (t Transport) attemptContext(ctx context.Context) (context.Context, context.CancelFunc) {
if t.AttemptTimeout <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, t.AttemptTimeout)
}
func (t Transport) clientFor(attempt int) *http.Client {
if attempt > 1 && t.Fallback != nil {
return t.Fallback
}
if t.Client == nil {
return http.DefaultClient
}
return t.Client
}
func (t Transport) request(ctx context.Context, url string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if t.UserAgent != "" {
req.Header.Set("User-Agent", t.UserAgent)
}
return req, nil
}
func (t Transport) fetchOnce(ctx context.Context, c *http.Client, url string, maxBytes int64) ([]byte, error) {
req, err := t.request(ctx, url)
if err != nil {
return nil, err
}
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &StatusError{URL: url, Status: resp.Status, Code: resp.StatusCode}
}
if resp.ContentLength > maxBytes {
return nil, fmt.Errorf("%w: GET %s declared %d bytes, maximum is %d", ErrTooLarge, url, resp.ContentLength, maxBytes)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%w: GET %s exceeded %d bytes", ErrTooLarge, url, maxBytes)
}
return data, nil
}
// downloadInto appends url's body to buf, resuming from buf's length via a
// Range request. A 200 means the server ignored Range, so buf is reset.
func (t Transport) downloadInto(ctx context.Context, c *http.Client, url string, expectedSize int64, buf *bytes.Buffer, total *int64, onProgress ProgressFunc) error {
req, err := t.request(ctx, url)
if err != nil {
return err
}
resumeFrom := int64(buf.Len())
if resumeFrom > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeFrom))
}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
buf.Reset()
if resp.ContentLength > 0 {
if resp.ContentLength > MaxAssetSize {
return fmt.Errorf("update: response size %d exceeds maximum %d", resp.ContentLength, MaxAssetSize)
}
*total = resp.ContentLength
}
case http.StatusPartialContent:
contentRange := resp.Header.Get("Content-Range")
if size := TotalFromContentRange(contentRange); size > 0 {
if size > MaxAssetSize {
return fmt.Errorf("update: response size %d exceeds maximum %d", size, MaxAssetSize)
}
*total = size
}
// An intermediary answering a resume from a different offset (CN CDNs and
// proxies do) would otherwise corrupt the artifact by appending to ours.
start, ok := RangeStartFromContentRange(contentRange)
if !ok || start != resumeFrom {
buf.Reset()
if start != 0 {
return &StatusError{URL: url, Status: "206 resumed at " + contentRange, Code: http.StatusPartialContent}
}
}
default:
return &StatusError{URL: url, Status: resp.Status, Code: resp.StatusCode}
}
have := int64(buf.Len())
if expectedSize > 0 && have > expectedSize {
return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", have, expectedSize)
}
limit := MaxAssetSize - have + 1
if expectedSize > 0 {
limit = expectedSize - have + 1
}
pr := &progressReader{r: io.LimitReader(resp.Body, limit), received: have, lastEmit: have, total: *total, onProgress: onProgress}
if _, err = io.Copy(buf, pr); err != nil {
return err
}
if expectedSize > 0 && int64(buf.Len()) > expectedSize {
return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", buf.Len(), expectedSize)
}
if int64(buf.Len()) > MaxAssetSize {
return fmt.Errorf("update: downloaded size exceeds maximum %d", MaxAssetSize)
}
return nil
}
// progressReader reports cumulative bytes read, throttled so the event channel
// is not flooded.
type progressReader struct {
r io.Reader
received int64
total int64
lastEmit int64
onProgress ProgressFunc
}
func (p *progressReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.received += int64(n)
// Emit roughly every 256 KiB, and always on the final read (io.EOF).
if p.onProgress != nil && (p.received-p.lastEmit >= 256<<10 || err == io.EOF) {
p.lastEmit = p.received
p.onProgress(p.received, p.total)
}
return n, err
}
// TrustedRedirect is the CheckRedirect an update client must use: HTTPS only,
// no userinfo, no port, and only hosts that serve our releases. Without it a
// redirect could walk an authenticated download onto an attacker's host.
func TrustedRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("update: stopped after 10 redirects")
}
if req == nil || req.URL == nil {
return errors.New("update: redirect has no target URL")
}
if !strings.EqualFold(req.URL.Scheme, "https") {
return fmt.Errorf("update: refusing redirect to non-HTTPS URL %q", req.URL.String())
}
if req.URL.Hostname() == "" {
return fmt.Errorf("update: refusing redirect without a hostname %q", req.URL.String())
}
if req.URL.User != nil {
return fmt.Errorf("update: refusing redirect with userinfo %q", req.URL.String())
}
if req.URL.Port() != "" || !trustedReleaseHost(req.URL.Hostname()) {
return fmt.Errorf("update: refusing redirect to untrusted host %q", req.URL.Host)
}
return nil
}
func trustedReleaseHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "reasonix.io" ||
strings.HasSuffix(host, ".reasonix.io") ||
host == "github.com" ||
strings.HasSuffix(host, ".githubusercontent.com")
}
+376
View File
@@ -0,0 +1,376 @@
package update
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
)
func fastRetry(t *testing.T) {
t.Helper()
restore := Backoff
Backoff = func(int) time.Duration { return time.Millisecond }
t.Cleanup(func() { Backoff = restore })
}
// tp is a Transport with the attempt bound the desktop ships, so a stalled
// primary route cannot hold the whole budget.
func tp(client, fallback *http.Client) Transport {
return Transport{Client: client, Fallback: fallback, UserAgent: "Reasonix-Updater/test", AttemptTimeout: 5 * time.Second}
}
func TestDownloadRecoversFromMidStreamReset(t *testing.T) {
fastRetry(t)
const body = "complete-installer-bytes"
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < int32(Attempts) {
// Mid-stream reset: promise 100 bytes, send a few, drop the socket —
// the client's body read fails with unexpected EOF, exactly the CN-IPv6
// "forcibly closed" case the retry exists for.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
bw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial")
bw.Flush()
conn.Close()
return
}
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
data, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, 0, nil)
if err != nil {
t.Fatalf("download should recover after %d resets: %v", Attempts-1, err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if n := calls.Load(); n != int32(Attempts) {
t.Fatalf("made %d attempts, want %d", n, Attempts)
}
}
func TestDownloadGivesUpAfterCap(t *testing.T) {
fastRetry(t)
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
conn.Close()
}))
defer srv.Close()
if _, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, 0, nil); err == nil {
t.Fatal("download should fail after exhausting retries")
}
if n := calls.Load(); n != int32(Attempts) {
t.Fatalf("made %d attempts, want %d", n, Attempts)
}
}
func TestRetryStopsWhenCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
if err := Retry(ctx, func(int) error {
calls++
return errors.New("boom")
}); err == nil {
t.Fatal("cancelled retry should return the error")
}
if calls != 1 {
t.Fatalf("cancelled retry made %d calls, want 1", calls)
}
}
func TestDownloadResumesWithRange(t *testing.T) {
fastRetry(t)
full := bytes.Repeat([]byte("0123456789"), 50) // 500 bytes
const cut = 200
var calls atomic.Int32
rangeCh := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
// First attempt: promise the whole file, send a prefix, drop the socket.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
fmt.Fprintf(bw, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(full))
bw.Write(full[:cut])
bw.Flush()
conn.Close()
return
}
// Resume attempt: honor the Range header with a 206 + Content-Range.
rng := r.Header.Get("Range")
rangeCh <- rng
start := 0
fmt.Sscanf(rng, "bytes=%d-", &start)
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(full)-1, len(full)))
w.WriteHeader(http.StatusPartialContent)
w.Write(full[start:])
}))
defer srv.Close()
data, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if !bytes.Equal(data, full) {
t.Fatalf("assembled %d bytes, want %d (equal=%v)", len(data), len(full), bytes.Equal(data, full))
}
select {
case rng := <-rangeCh:
if rng != fmt.Sprintf("bytes=%d-", cut) {
t.Fatalf("resume Range = %q, want bytes=%d-", rng, cut)
}
default:
t.Fatal("resume attempt sent no Range header")
}
}
func TestDownloadFallsBackToSecondClient(t *testing.T) {
fastRetry(t)
const body = "served-over-ipv4"
primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection reset (ipv6)")
})}
var fbCalls atomic.Int32
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
fbCalls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
data, err := tp(primary, fallback).Download(context.Background(), "http://example.invalid/x", 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if fbCalls.Load() == 0 {
t.Fatal("fallback client was never used after the primary failed")
}
}
func TestDownloadRejectsBodyShorterThanManifestSize(t *testing.T) {
fastRetry(t)
body := []byte("short")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
if _, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, int64(len(body)+1), nil); err == nil {
t.Fatal("download accepted fewer bytes than the manifest declared")
}
}
func TestDownloadRejectsBodyLongerThanManifestSize(t *testing.T) {
fastRetry(t)
body := bytes.Repeat([]byte("x"), 64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
if _, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, 8, nil); err == nil {
t.Fatal("download accepted more bytes than the manifest declared")
}
}
func TestDownloadRejectsAssetSizeAboveMaximum(t *testing.T) {
if _, err := tp(&http.Client{}, nil).Download(
context.Background(), "https://dl.reasonix.io/file", MaxAssetSize+1, nil,
); err == nil {
t.Fatal("download accepted an asset size above the release maximum")
}
}
func TestFetchFallsBackToSecondClient(t *testing.T) {
fastRetry(t)
primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("read tcp [ipv6]: connection reset")
})}
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return okBody("manifest"), nil
})}
data, err := tp(primary, fallback).Fetch(context.Background(), "https://example.invalid/latest.json", MaxAssetSize)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(data) != "manifest" {
t.Fatalf("got %q, want manifest", data)
}
}
// A primary that stalls without erroring holds the whole budget, so the retry
// that would have reached the IPv4 route never runs. AttemptTimeout is what
// bounds it; Download deliberately has no such bound.
func TestFetchEscapesStalledPrimary(t *testing.T) {
fastRetry(t)
primary := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
})}
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return okBody("ipv4"), nil
})}
transport := tp(primary, fallback)
transport.AttemptTimeout = 10 * time.Millisecond
data, err := transport.Fetch(context.Background(), "https://example.invalid/latest.json", MaxAssetSize)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(data) != "ipv4" {
t.Fatalf("got %q, want ipv4", data)
}
}
func TestFetchDoesNotRetryPermanentHTTPStatus(t *testing.T) {
fastRetry(t)
var calls atomic.Int32
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusForbidden,
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader("forbidden")),
Header: make(http.Header),
}, nil
})}
if _, err := tp(client, nil).Fetch(context.Background(), "https://example.invalid/latest.json", MaxAssetSize); err == nil {
t.Fatal("Fetch should return a permanent HTTP error")
}
if got := calls.Load(); got != 1 {
t.Fatalf("permanent HTTP error made %d requests, want 1", got)
}
}
func TestFetchRejectsOversizeResponsesWithoutRetry(t *testing.T) {
fastRetry(t)
t.Run("declared content length", func(t *testing.T) {
var calls atomic.Int32
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
resp := okBody("ignored")
resp.ContentLength = 9
return resp, nil
})}
if _, err := tp(client, nil).Fetch(context.Background(), "https://example.invalid/latest.json", 8); !errors.Is(err, ErrTooLarge) {
t.Fatalf("declared oversize error = %v, want ErrTooLarge", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("declared oversize response made %d requests, want 1", got)
}
})
t.Run("chunked body", func(t *testing.T) {
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
resp := okBody("123456789")
resp.ContentLength = -1
return resp, nil
})}
if _, err := tp(client, nil).Fetch(context.Background(), "https://example.invalid/latest.json", 8); !errors.Is(err, ErrTooLarge) {
t.Fatalf("chunked oversize error = %v, want ErrTooLarge", err)
}
})
}
func okBody(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}
}
type rtFunc func(*http.Request) (*http.Response, error)
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// A 404 or a Cloudflare 403 on the asset is a verdict, not a blip. An untyped
// error here read as transient, so every such failure burned the full retry
// schedule before surfacing.
func TestDownloadDoesNotRetryATerminalStatus(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
if _, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, 0, nil); err == nil {
t.Fatal("a 403 must fail the download")
}
if got := hits.Load(); got != 1 {
t.Errorf("server saw %d requests, want 1: a terminal status must not be retried", got)
}
}
// An intermediary answering a resume from a different offset must not have its
// bytes appended to what we already hold. CN CDNs and proxies do this.
func TestDownloadRestartsWhenAResumeIgnoresTheOffset(t *testing.T) {
fastRetry(t)
payload := bytes.Repeat([]byte("ab"), 512) // 1024 bytes
var served atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if served.Add(1) == 1 {
// First attempt dies halfway, leaving a partial buffer to resume.
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload[:400])
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
return
}
// The retry asks to resume at 400; this server answers from 0 anyway.
w.Header().Set("Content-Range", "bytes 0-1023/1024")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(payload)
}))
defer srv.Close()
got, err := tp(srv.Client(), nil).Download(context.Background(), srv.URL, int64(len(payload)), nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("resumed download is corrupt: got %d bytes, want %d intact", len(got), len(payload))
}
}
+16
View File
@@ -52,6 +52,22 @@ func TestVerifyWith(t *testing.T) {
}
}
func TestCheckSHA256(t *testing.T) {
data := []byte("hello world")
// echo -n "hello world" | shasum -a 256
const sum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if err := CheckSHA256(data, sum); err != nil {
t.Errorf("matching digest should pass: %v", err)
}
if err := CheckSHA256(data, "deadbeef"); err == nil {
t.Error("mismatched digest should fail")
}
// Case-insensitive hex.
if err := CheckSHA256(data, "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"); err != nil {
t.Errorf("uppercase digest should pass: %v", err)
}
}
// TestPlatformKey pins the key format the manifest generator and the updater both
// rely on; if these drift, lookups silently miss.
func TestPlatformKey(t *testing.T) {
+88
View File
@@ -0,0 +1,88 @@
package update
import (
"context"
"net/http"
"strings"
"time"
)
// ProgressFunc reports bytes received against the total, for a progress bar.
type ProgressFunc func(received, total int64)
// Installer applies a verified artifact and hands over to the relaunched build.
// It stays with the host on purpose: applying is where an update stops being
// bytes and becomes this machine's install layout — a package manager, an app
// bundle, or a helper process that has to outlive the process starting it.
type Installer interface {
Install(ctx context.Context, c Cached) error
}
// Options configures an Updater. The pin is passed in rather than read here:
// it is a user setting the shells own, and keeping it out makes the policy
// testable without a config file.
type Options struct {
Current string // running version
Pinned string // release the user pinned, or ""
IndexURL string // catalog; empty uses the published one
HTTP *http.Client // carries the caller's proxy and timeouts
Fallback *http.Client // IPv4-pinned route, tried from the second attempt
CacheDir string // where a verified artifact waits for its install
Kind string // artifact this install applies; empty is the tarball
// UserAgent identifies updater traffic. Go's default is what edge bot
// protection scores worst (#6005), so a build that leaves this empty is
// asking to be 403'd by its own CDN.
UserAgent string
// AttemptTimeout bounds one manifest or signature fetch so a stalled
// primary route cannot hold the budget the IPv4 fallback needs. Downloads
// are deliberately unbounded; artifacts are large.
AttemptTimeout time.Duration
}
// Updater answers what is published and what that means for this machine.
type Updater struct {
opts Options
}
// New returns an Updater for the running build.
func New(opts Options) *Updater { return &Updater{opts: opts} }
// Status is what a version panel renders and what an auto-updater decides on.
// Entries is the whole catalog, newest first, because a rollback needs the
// versions behind the running one as much as an update needs the one ahead.
type Status struct {
Current string
Latest string
Pinned string
Available bool
Newer bool
AutoOK bool // may be installed without asking; false while pinned
StalePin bool
Entries []IndexEntry
}
// Check reads the catalog and applies the pin rule. A failure to reach the
// catalog is returned with the status still filled in as far as it got: which
// version is running is a local fact and must survive a network error.
func (u *Updater) Check(ctx context.Context) (Status, error) {
st := Status{Current: u.opts.Current, Pinned: strings.TrimSpace(u.opts.Pinned)}
idx, err := FetchIndex(ctx, u.opts.HTTP, u.opts.IndexURL)
if err != nil {
return st, err
}
st.Entries = idx.Versions
for _, e := range idx.Versions {
if st.Latest == "" || CompareVersions(e.Version, st.Latest) > 0 {
st.Latest = e.Version
}
}
offer := OfferFor(st.Current, st.Latest, st.Pinned)
st.Available, st.Newer, st.AutoOK, st.StalePin = offer.Available, offer.Newer, offer.AutoInstall, offer.StalePin
return st, nil
}
// Manifest resolves one catalog entry to its immutable manifest, the single
// source for that version's assets and signatures.
func (u *Updater) Manifest(ctx context.Context, e IndexEntry) (*Manifest, error) {
return FetchManifestAt(ctx, u.opts.HTTP, e.Manifest)
}
+85
View File
@@ -0,0 +1,85 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func checkAgainst(t *testing.T, body string, opts Options) (Status, error) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
opts.IndexURL, opts.HTTP = srv.URL, srv.Client()
return New(opts).Check(context.Background())
}
const catalog = `{"schemaVersion":1,"versions":[
{"version":"2.1.0","tag":"studio-v2.1.0","manifest":"https://x/2.1.0/latest.json"},
{"version":"2.0.0","tag":"studio-v2.0.0","manifest":"https://x/2.0.0/latest.json"},
{"version":"1.25.1","tag":"desktop-v1.25.1","manifest":"https://x/1.25.1/latest.json"}]}`
func TestCheckReportsAnUpdate(t *testing.T) {
st, err := checkAgainst(t, catalog, Options{Current: "2.0.0"})
if err != nil {
t.Fatalf("Check: %v", err)
}
if st.Latest != "2.1.0" || !st.Available || !st.Newer || !st.AutoOK {
t.Fatalf("status = %+v, want an installable 2.1.0", st)
}
// The catalog comes back whole: a rollback needs what is behind the running
// build as much as an update needs what is ahead.
if len(st.Entries) != 3 {
t.Errorf("entries = %d, want the whole catalog", len(st.Entries))
}
}
func TestCheckWillNotAutoInstallOverAPin(t *testing.T) {
st, err := checkAgainst(t, catalog, Options{Current: "1.25.1", Pinned: "1.25.1"})
if err != nil {
t.Fatalf("Check: %v", err)
}
if st.AutoOK {
t.Error("a pinned build must not be updated out from under the user")
}
if !st.Available || !st.Newer {
t.Error("the pin suppresses installing, not reporting: 2.1.0 must still surface")
}
}
// Which version is running is a local fact. A catalog that cannot be reached
// must not be able to erase it — that is the moment a user most needs to know
// what they are on.
func TestCheckKeepsTheRunningVersionWhenTheCatalogFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
st, err := New(Options{Current: "2.0.0", IndexURL: srv.URL, HTTP: srv.Client()}).Check(context.Background())
if err == nil {
t.Fatal("an unreachable catalog must be reported")
}
if st.Current != "2.0.0" {
t.Errorf("current = %q, want it preserved through the failure", st.Current)
}
if st.Available || st.Newer || st.AutoOK {
t.Errorf("status = %+v, want nothing offered from a catalog we could not read", st)
}
}
func TestCheckOnAnUnpublishedBuild(t *testing.T) {
st, err := checkAgainst(t, catalog, Options{Current: "2.2.0-dev"})
if err != nil {
t.Fatalf("Check: %v", err)
}
if st.Newer || st.AutoOK {
t.Errorf("status = %+v, want no update offered to a build ahead of the catalog", st)
}
if !st.Available {
t.Error("other versions exist and must remain reachable for a rollback")
}
}
+36
View File
@@ -1,8 +1,13 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"strings"
"aead.dev/minisign"
)
@@ -21,10 +26,41 @@ RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq`
// touching disk — never apply an update whose signature has not checked out.
func Verify(data, sig []byte) error { return verifyWith(publicKey, data, sig) }
// verifyArtifact is the gate Download gets its artifacts through, indirected
// only so package tests can sign with a throwaway key: the embedded key's
// private half exists solely in CI secrets, so nothing else can produce a
// signature this would accept.
var verifyArtifact = Verify
// PublicKey returns the embedded public key in its canonical two-line text form,
// so docs/UI can surface it for manual `minisign -Vm <file>` verification.
func PublicKey() string { return publicKey }
// CheckSHA256 is the second integrity check, after the signature: the manifest
// digest must also match, so a signed-but-swapped asset entry cannot be applied.
func CheckSHA256(data []byte, want string) error {
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, want) {
return fmt.Errorf("update: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// fileSHA256Matches re-hashes a cached artifact in place, so a file edited or
// truncated after it was written is not offered as already downloaded.
func fileSHA256Matches(path, want string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false
}
return strings.EqualFold(hex.EncodeToString(h.Sum(nil)), want)
}
// verifyWith is the testable core: it parses an arbitrary public-key text and
// verifies the signature, letting tests use a throwaway key pair without the
// embedded key's (secret) counterpart.
+7 -1
View File
@@ -23,6 +23,8 @@ import (
// Blank imports wire compile-time built-ins into their registries, exactly as
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
"reasonix/desktop/internal/update"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
@@ -67,6 +69,10 @@ func macSelfUpdateAllowed() bool {
}
}
// The updater cannot read this build's flags, so the flag is handed to it once,
// before anything can ask whether a macOS update may be applied.
func init() { update.MacSelfUpdate = macSelfUpdateAllowed() }
func windowsWebview2GPUDisabled() bool {
for _, key := range []string{disableWebview2GPUEnv, legacyDisableWebview2GPUEnv} {
if raw, ok := os.LookupEnv(key); ok {
@@ -98,7 +104,7 @@ func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
func main() {
// Detached macOS self-update child: wait for the old PID, hold the shared
// repair mutation lock, then swap the .app bundle. Must run before Wails.
if handled, exitCode := maybeRunMacUpdateHandoff(os.Args[1:]); handled {
if handled, exitCode := update.MaybeRunMacHandoff(os.Args[1:]); handled {
os.Exit(exitCode)
}
capturePreviousFatalCrash()
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"fmt"
"strings"
"reasonix/desktop/internal/update"
"reasonix/internal/installlayout"
)
// manifest_policy.go decides whether a fetched latest.json is one of our
// releases. Separate from the transport that fetched it: what counts as
// official changes with the packaging, not with the network.
type requiredDesktopAsset struct {
group string
key string
filename string
}
var (
requiredDesktopUpdaterAssets = []requiredDesktopAsset{
{group: "platforms", key: "darwin-arm64", filename: "Reasonix-darwin-arm64.zip"},
{group: "platforms", key: "darwin-amd64", filename: "Reasonix-darwin-amd64.zip"},
{group: "platforms", key: "windows-amd64", filename: "Reasonix-windows-amd64-installer.exe"},
{group: "platforms", key: "windows-arm64", filename: "Reasonix-windows-arm64-installer.exe"},
{group: "platforms", key: "linux-amd64", filename: "Reasonix-linux-amd64.tar.gz"},
{group: "native_packages", key: "linux-amd64", filename: "Reasonix-linux-amd64.deb"},
}
requiredDesktopDownloadAssets = []requiredDesktopAsset{
{group: "downloads", key: "Reasonix-darwin-universal.dmg", filename: "Reasonix-darwin-universal.dmg"},
{group: "downloads", key: "Reasonix-windows-amd64.zip", filename: "Reasonix-windows-amd64.zip"},
}
)
// validateManifestChannel rejects every prerelease. The selected value remains
// in the signature for compatibility with existing callers.
func validateManifestChannel(selected string, m *update.Manifest) error {
_ = selected
if !stableDesktopVersionRE.MatchString(m.Version) {
return fmt.Errorf("official manifest has invalid release version %q", m.Version)
}
return nil
}
func desktopReleaseTag(_ string, version string) string {
return "desktop-" + version
}
func desktopAssetBases(selected, version string, allowLegacyPreview bool) []string {
_ = selected
_ = allowLegacyPreview
tag := desktopReleaseTag(selected, version)
return []string{
fmt.Sprintf("%s/%s/", r2Base, tag),
fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", tag),
fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", version),
}
}
func validateManifestAsset(selected, version, filename string, asset update.Asset, allowLegacyPreview bool) (string, error) {
base := ""
for _, candidate := range desktopAssetBases(selected, version, allowLegacyPreview) {
if asset.URL == candidate+filename {
base = candidate
break
}
}
if base == "" {
return "", fmt.Errorf("asset URL %q is not the official %s path for %s", asset.URL, normalizeUpdateChannel(selected), filename)
}
if asset.Sig != asset.URL+".minisig" {
return "", fmt.Errorf("asset signature URL %q does not match %q", asset.Sig, asset.URL+".minisig")
}
if asset.Size <= 0 || asset.Size > maxDesktopReleaseAssetSize {
return "", fmt.Errorf("asset %s has invalid size %d", filename, asset.Size)
}
if !sha256RE.MatchString(asset.SHA256) {
return "", fmt.Errorf("asset %s has invalid SHA-256 %q", filename, asset.SHA256)
}
if err := validateAssetInstallLayout(asset.InstallLayout); err != nil {
return "", err
}
return base, nil
}
// validateAssetInstallLayout accepts the pre-v1.20 empty layout (flat install)
// and the v1.20+ versioned-v1 layout. Unknown values must fail closed so a new
// client never partially installs an unrecognized package shape.
func validateAssetInstallLayout(layout string) error {
switch strings.TrimSpace(layout) {
case "", installlayout.InstallLayoutVersionedV1:
return nil
default:
return fmt.Errorf("unsupported install_layout %q (keeping current version)", layout)
}
}
func validateDesktopManifest(selected string, m *update.Manifest) error {
selected = normalizeUpdateChannel(selected)
if err := validateManifestChannel(selected, m); err != nil {
return err
}
if m.DownloadPage != manifestDownloadPageURL {
return fmt.Errorf("%s manifest has invalid download page %q", selected, m.DownloadPage)
}
// Older public manifests predate the two website-only download assets. Keep
// accepting their six signed updater artifacts so an upgrade to the first
// single-channel release does not strand existing users. Once downloads is
// present it is a new-format manifest and all eight assets are mandatory.
legacyManifest := m.Downloads == nil
requiredAssets := append([]requiredDesktopAsset(nil), requiredDesktopUpdaterAssets...)
if !legacyManifest {
requiredAssets = append(requiredAssets, requiredDesktopDownloadAssets...)
}
base := ""
for _, required := range requiredAssets {
var assets map[string]update.Asset
switch required.group {
case "platforms":
assets = m.Platforms
case "native_packages":
assets = m.NativePackages
case "downloads":
assets = m.Downloads
default:
return fmt.Errorf("unsupported manifest asset group %q", required.group)
}
asset, ok := assets[required.key]
if !ok {
return fmt.Errorf("%s manifest has no %s asset for %s", selected, required.group, required.key)
}
assetBase, err := validateManifestAsset(selected, m.Version, required.filename, asset, legacyManifest)
if err != nil {
return fmt.Errorf("%s %s asset: %w", required.group, required.key, err)
}
if base != "" && assetBase != base {
return fmt.Errorf("%s manifest mixes asset bases %q and %q", selected, base, assetBase)
}
base = assetBase
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"context"
"fmt"
"os"
goruntime "runtime"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/netclient"
)
// updateEventName carries install progress to the version panel. It is separate
// from the kernel's event bus on purpose: an install is the shell's business,
// and minting kernel events for it would put host concerns in the transcript.
const updateEventName = "rx:update"
// updateTimeout bounds the whole move. It is generous because the artifact is
// large and a CN route to the CDN can be slow; the user can always close the
// window, which cancels nothing but leaves a verified cache to resume from.
const updateTimeout = 30 * time.Minute
// UpdateProgress is one report from an install in flight.
type UpdateProgress struct {
Version string `json:"version"`
Phase string `json:"phase"` // downloading | verifying | downloaded | relaunching | error
Received int64 `json:"received"`
Total int64 `json:"total"`
Err string `json:"err,omitempty"`
}
// GoToVersion installs a published release, forward or back. The pin is set
// before the install, not after: if the machine dies mid-swap, the next launch
// must not helpfully update past the version the user chose.
func (a *App) GoToVersion(target string) error {
target = strings.TrimSpace(target)
if target == "" {
return fmt.Errorf("没有指定要切换到的版本")
}
if update.SameVersion(target, version) {
return nil
}
layout := update.Here()
if layout.Root == "" {
return fmt.Errorf("认不出当前安装位置,无法切换版本")
}
dir, err := update.CacheDir()
if err != nil {
return a.failUpdate(target, err)
}
u, err := studioUpdater(target, dir)
if err != nil {
return a.failUpdate(target, err)
}
if err := a.PinVersion(target); err != nil {
return a.failUpdate(target, err)
}
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel()
inst := update.VersionedInstaller{Layout: layout, Staging: dir, Current: version}
if err := u.Apply(ctx, target, inst, a.updateReport(target)); err != nil {
return a.failUpdate(target, err)
}
a.emit(UpdateProgress{Version: target, Phase: "relaunching"})
a.handOver(layout)
return nil
}
// handOver ends this process so the installed build can take its place. Windows
// and macOS already have a helper waiting for the exit; Linux replaced the files
// in place, so this process is the one that has to start the new one.
func (a *App) handOver(layout update.Layout) {
if a.srv != nil {
a.srv.Controller().Close()
}
if goruntime.GOOS == "linux" {
_ = layout.Relaunch()
}
os.Exit(0)
}
func (a *App) updateReport(target string) update.Report {
var total int64
return update.Report{
Bytes: func(received, size int64) {
total = size
a.emit(UpdateProgress{Version: target, Phase: update.PhaseDownloading, Received: received, Total: size})
},
Phase: func(phase string) {
if phase != update.PhaseDownloading {
a.emit(UpdateProgress{Version: target, Phase: phase, Received: total, Total: total})
}
},
}
}
func (a *App) failUpdate(target string, err error) error {
a.emit(UpdateProgress{Version: target, Phase: "error", Err: err.Error()})
return err
}
func (a *App) emit(p UpdateProgress) {
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, updateEventName, p)
}
func studioUpdater(target, cacheDir string) (*update.Updater, error) {
client, err := netclient.NewHTTPClient(proxySpecForUpdates(), netclient.TransportOptions{})
if err != nil {
return nil, err
}
// Best-effort IPv4 route: a nil fallback just means retries reuse the first.
v4, _ := netclient.NewHTTPClient(proxySpecForUpdates(), netclient.TransportOptions{ForceIPv4: true})
return update.New(update.Options{
Current: version,
Pinned: target,
HTTP: client,
Fallback: v4,
CacheDir: cacheDir,
// Go's default user agent is what release-edge bot protection scores
// worst (#6005), and a 403 there looks like "no versions" to the panel.
UserAgent: fmt.Sprintf("Reasonix-Studio/%s (%s/%s)", version, goruntime.GOOS, goruntime.GOARCH),
AttemptTimeout: 5 * time.Second,
}), nil
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"sort"
"strings"
"time"
"reasonix/desktop/internal/update"
"reasonix/internal/config"
"reasonix/internal/netclient"
)
// version is stamped by the build (-X main.version=…); an unstamped dev build
// says so rather than pretending to be a release.
var version = "dev"
// VersionEntry is one published release as the panel shows it.
type VersionEntry struct {
Version string `json:"version"`
Tag string `json:"tag"`
PublishedAt string `json:"publishedAt"`
Notes string `json:"notes"`
Current bool `json:"current"`
Older bool `json:"older"`
}
// VersionHub is everything the version panel renders from. Err is carried
// alongside the data rather than replacing it: an unreachable catalog must not
// hide which version is running.
type VersionHub struct {
Current string `json:"current"`
Pinned string `json:"pinned"`
StalePin bool `json:"stalePin"`
Latest string `json:"latest"`
Newer bool `json:"newer"`
Versions []VersionEntry `json:"versions"`
Err string `json:"err,omitempty"`
}
// Versions reads the rollback catalog through the shared updater, so the shell
// holds no copy of what "newer", "pinned" or "latest" mean.
func (a *App) Versions() VersionHub {
hub := VersionHub{Current: version}
pinned := ""
if cfg, err := config.Load(); err == nil && cfg != nil {
pinned = cfg.DesktopPinnedVersion()
}
hub.Pinned = pinned
client, err := netclient.NewHTTPClient(proxySpecForUpdates(), netclient.TransportOptions{})
if err != nil {
hub.Err, hub.Versions = err.Error(), versionRows(nil, version)
return hub.nonNil()
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
st, err := update.New(update.Options{Current: version, Pinned: pinned, HTTP: client}).Check(ctx)
hub.Latest, hub.Newer, hub.StalePin = st.Latest, st.Newer, st.StalePin
hub.Versions = versionRows(st.Entries, version)
if err != nil {
hub.Err = err.Error()
}
return hub.nonNil()
}
// versionRows merges the running build into the catalog, newest first. It is
// always present even when the catalog cannot be read or does not carry it (a
// local build, a release still publishing): the row is the panel's statement of
// what runs and its only handle for pinning, so it cannot depend on the network.
func versionRows(entries []update.IndexEntry, current string) []VersionEntry {
rows := make([]VersionEntry, 0, len(entries)+1)
seen := false
for _, e := range entries {
row := VersionEntry{Version: e.Version, Tag: e.Tag, PublishedAt: e.PublishedAt}
if update.SameVersion(e.Version, current) {
row.Current, seen = true, true
} else {
row.Older = e.IsOlderThan(current)
}
rows = append(rows, row)
}
if !seen && strings.TrimSpace(current) != "" {
rows = append(rows, VersionEntry{Version: current, Current: true})
}
sort.SliceStable(rows, func(i, j int) bool {
return update.CompareVersions(rows[i].Version, rows[j].Version) > 0
})
return rows
}
// nonNil keeps an empty catalog an empty list. A nil slice marshals to null,
// and a client that maps over it crashes its whole render — the same reason
// serve's session listing does this.
func (h VersionHub) nonNil() VersionHub {
if h.Versions == nil {
h.Versions = []VersionEntry{}
}
return h
}
// PinVersion holds this machine on a release, or releases the hold when version
// is empty. It is the half of a rollback that outlives the install: without it
// the updater would put the user back on the build they left.
func (a *App) PinVersion(pin string) error {
cfg := config.LoadForEdit(config.UserConfigPath())
if err := cfg.SetDesktopPinnedVersion(pin); err != nil {
return err
}
return cfg.SaveTo(config.UserConfigPath())
}
func proxySpecForUpdates() netclient.ProxySpec {
if cfg, err := config.Load(); err == nil && cfg != nil {
return cfg.NetworkProxySpec()
}
return netclient.ProxySpec{Mode: netclient.ModeAuto}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"testing"
"reasonix/desktop/internal/update"
)
func rowVersions(rows []VersionEntry) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = r.Version
}
return out
}
// The running build is the panel's statement of what runs and its only handle
// for pinning, so it cannot depend on a catalog fetch that may have failed.
func TestVersionRowsAlwaysCarryTheRunningBuild(t *testing.T) {
rows := versionRows(nil, "2.0.0-dev")
if len(rows) != 1 || rows[0].Version != "2.0.0-dev" || !rows[0].Current {
t.Fatalf("rows = %+v, want the running build alone and marked current", rows)
}
if rows[0].Older {
t.Error("the running build is not older than itself")
}
if got := versionRows(nil, ""); len(got) != 0 {
t.Errorf("rows = %+v, want nothing to invent when there is no version", got)
}
}
// A published catalog that does not list the running build (a release still
// publishing, or a local one) must not drop it.
func TestVersionRowsPrependAnUnpublishedRunningBuild(t *testing.T) {
rows := versionRows([]update.IndexEntry{
{Version: "1.25.1", Tag: "desktop-v1.25.1"},
}, "2.0.0-dev")
if got := rowVersions(rows); len(got) != 2 || got[0] != "2.0.0-dev" || got[1] != "1.25.1" {
t.Fatalf("rows = %v, want the running build first", got)
}
if !rows[0].Current || rows[1].Current {
t.Errorf("rows = %+v, want exactly the running build marked current", rows)
}
if !rows[1].Older {
t.Error("1.25.1 is older than the running build and must say so")
}
}
// When the catalog does carry it, the row is marked rather than duplicated.
func TestVersionRowsDoNotDuplicateTheRunningBuild(t *testing.T) {
rows := versionRows([]update.IndexEntry{
{Version: "2.0.0", Tag: "studio-v2.0.0"},
{Version: "1.25.1", Tag: "desktop-v1.25.1"},
}, "v2.0.0") // the leading v the tag and the manifest disagree about
if got := rowVersions(rows); len(got) != 2 {
t.Fatalf("rows = %v, want no duplicate row for the running build", got)
}
if !rows[0].Current || rows[0].Tag != "studio-v2.0.0" {
t.Errorf("rows[0] = %+v, want the catalog entry marked current, keeping its tag", rows[0])
}
}
// Newest first, and a newer published release is not "older".
func TestVersionRowsOrderNewestFirst(t *testing.T) {
rows := versionRows([]update.IndexEntry{
{Version: "1.9.0"},
{Version: "2.1.0"},
{Version: "1.25.0"},
}, "1.25.0")
if got := rowVersions(rows); len(got) != 3 || got[0] != "2.1.0" || got[1] != "1.25.0" || got[2] != "1.9.0" {
t.Fatalf("rows = %v, want 2.1.0, 1.25.0, 1.9.0", got)
}
if rows[0].Older {
t.Error("2.1.0 is ahead of the running build, not behind it")
}
if !rows[2].Older {
t.Error("1.9.0 is behind the running build")
}
}
+54 -748
View File
@@ -5,8 +5,6 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -15,11 +13,9 @@ import (
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -62,27 +58,6 @@ var (
sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
type requiredDesktopAsset struct {
group string
key string
filename string
}
var (
requiredDesktopUpdaterAssets = []requiredDesktopAsset{
{group: "platforms", key: "darwin-arm64", filename: "Reasonix-darwin-arm64.zip"},
{group: "platforms", key: "darwin-amd64", filename: "Reasonix-darwin-amd64.zip"},
{group: "platforms", key: "windows-amd64", filename: "Reasonix-windows-amd64-installer.exe"},
{group: "platforms", key: "windows-arm64", filename: "Reasonix-windows-arm64-installer.exe"},
{group: "platforms", key: "linux-amd64", filename: "Reasonix-linux-amd64.tar.gz"},
{group: "native_packages", key: "linux-amd64", filename: "Reasonix-linux-amd64.deb"},
}
requiredDesktopDownloadAssets = []requiredDesktopAsset{
{group: "downloads", key: "Reasonix-darwin-universal.dmg", filename: "Reasonix-darwin-universal.dmg"},
{group: "downloads", key: "Reasonix-windows-amd64.zip", filename: "Reasonix-windows-amd64.zip"},
}
)
// githubManifestFallback is the stable channel's last-resort manifest source.
// dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
// protection that 403s a user's egress IP takes out both first-party endpoints
@@ -229,33 +204,7 @@ func newHTTPClient(forceIPv4 bool) (*http.Client, error) {
}
func validateUpdateRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("update: stopped after 10 redirects")
}
if req == nil || req.URL == nil {
return errors.New("update: redirect has no target URL")
}
if !strings.EqualFold(req.URL.Scheme, "https") {
return fmt.Errorf("update: refusing redirect to non-HTTPS URL %q", req.URL.String())
}
if req.URL.Hostname() == "" {
return fmt.Errorf("update: refusing redirect without a hostname %q", req.URL.String())
}
if req.URL.User != nil {
return fmt.Errorf("update: refusing redirect with userinfo %q", req.URL.String())
}
if req.URL.Port() != "" || !isTrustedUpdateRedirectHost(req.URL.Hostname()) {
return fmt.Errorf("update: refusing redirect to untrusted host %q", req.URL.Host)
}
return nil
}
func isTrustedUpdateRedirectHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "reasonix.io" ||
strings.HasSuffix(host, ".reasonix.io") ||
host == "github.com" ||
strings.HasSuffix(host, ".githubusercontent.com")
return update.TrustedRedirect(req, via)
}
// canSelfUpdate reports whether in-place update is possible. Windows and Linux
@@ -289,115 +238,6 @@ func normalizeVersion(v string) (string, bool) {
return semver.Canonical(v), true
}
// validateManifestChannel rejects every prerelease. The selected value remains
// in the signature for compatibility with existing callers.
func validateManifestChannel(selected string, m *update.Manifest) error {
_ = selected
if !stableDesktopVersionRE.MatchString(m.Version) {
return fmt.Errorf("official manifest has invalid release version %q", m.Version)
}
return nil
}
func desktopReleaseTag(_ string, version string) string {
return "desktop-" + version
}
func desktopAssetBases(selected, version string, allowLegacyPreview bool) []string {
_ = selected
_ = allowLegacyPreview
tag := desktopReleaseTag(selected, version)
return []string{
fmt.Sprintf("%s/%s/", r2Base, tag),
fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", tag),
fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", version),
}
}
func validateManifestAsset(selected, version, filename string, asset update.Asset, allowLegacyPreview bool) (string, error) {
base := ""
for _, candidate := range desktopAssetBases(selected, version, allowLegacyPreview) {
if asset.URL == candidate+filename {
base = candidate
break
}
}
if base == "" {
return "", fmt.Errorf("asset URL %q is not the official %s path for %s", asset.URL, normalizeUpdateChannel(selected), filename)
}
if asset.Sig != asset.URL+".minisig" {
return "", fmt.Errorf("asset signature URL %q does not match %q", asset.Sig, asset.URL+".minisig")
}
if asset.Size <= 0 || asset.Size > maxDesktopReleaseAssetSize {
return "", fmt.Errorf("asset %s has invalid size %d", filename, asset.Size)
}
if !sha256RE.MatchString(asset.SHA256) {
return "", fmt.Errorf("asset %s has invalid SHA-256 %q", filename, asset.SHA256)
}
if err := validateAssetInstallLayout(asset.InstallLayout); err != nil {
return "", err
}
return base, nil
}
// validateAssetInstallLayout accepts the pre-v1.20 empty layout (flat install)
// and the v1.20+ versioned-v1 layout. Unknown values must fail closed so a new
// client never partially installs an unrecognized package shape.
func validateAssetInstallLayout(layout string) error {
switch strings.TrimSpace(layout) {
case "", installlayout.InstallLayoutVersionedV1:
return nil
default:
return fmt.Errorf("unsupported install_layout %q (keeping current version)", layout)
}
}
func validateDesktopManifest(selected string, m *update.Manifest) error {
selected = normalizeUpdateChannel(selected)
if err := validateManifestChannel(selected, m); err != nil {
return err
}
if m.DownloadPage != manifestDownloadPageURL {
return fmt.Errorf("%s manifest has invalid download page %q", selected, m.DownloadPage)
}
// Older public manifests predate the two website-only download assets. Keep
// accepting their six signed updater artifacts so an upgrade to the first
// single-channel release does not strand existing users. Once downloads is
// present it is a new-format manifest and all eight assets are mandatory.
legacyManifest := m.Downloads == nil
requiredAssets := append([]requiredDesktopAsset(nil), requiredDesktopUpdaterAssets...)
if !legacyManifest {
requiredAssets = append(requiredAssets, requiredDesktopDownloadAssets...)
}
base := ""
for _, required := range requiredAssets {
var assets map[string]update.Asset
switch required.group {
case "platforms":
assets = m.Platforms
case "native_packages":
assets = m.NativePackages
case "downloads":
assets = m.Downloads
default:
return fmt.Errorf("unsupported manifest asset group %q", required.group)
}
asset, ok := assets[required.key]
if !ok {
return fmt.Errorf("%s manifest has no %s asset for %s", selected, required.group, required.key)
}
assetBase, err := validateManifestAsset(selected, m.Version, required.filename, asset, legacyManifest)
if err != nil {
return fmt.Errorf("%s %s asset: %w", required.group, required.key, err)
}
if base != "" && assetBase != base {
return fmt.Errorf("%s manifest mixes asset bases %q and %q", selected, base, assetBase)
}
base = assetBase
}
return nil
}
// fetchManifest pulls latest.json from each endpoint in order until one both
// responds, decodes, and matches an official release. Every endpoint's
// failure is kept — a user staring at a gateway 403 (#6005) needs to see that
@@ -433,13 +273,13 @@ func fetchManifest(ctx context.Context, c, fallback *http.Client, selected strin
func fetchManifestBytes(ctx context.Context, c, fallback *http.Client, selected, url string) ([]byte, error) {
attemptTimeout := manifestEndpointTimeout / 2
attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
data, err := fetchBytesOnce(attemptCtx, c, selected, url, maxDesktopManifestSize)
data, err := transportFor(c, nil, selected).FetchOnce(attemptCtx, c, url, maxDesktopManifestSize)
cancel()
if err == nil || !isTransientFetchError(err) || fallback == nil {
if err == nil || !update.Transient(err) || fallback == nil {
return data, err
}
attemptCtx, cancel = context.WithTimeout(ctx, attemptTimeout)
fallbackData, fallbackErr := fetchBytesOnce(attemptCtx, fallback, selected, url, maxDesktopManifestSize)
fallbackData, fallbackErr := transportFor(fallback, nil, selected).FetchOnce(attemptCtx, fallback, url, maxDesktopManifestSize)
cancel()
if fallbackErr == nil {
return fallbackData, nil
@@ -504,7 +344,7 @@ func evaluateWithProfileForChannel(current, selected string, m *update.Manifest,
}
if a, kind, ok := selectUpdateAsset(m, profile); ok {
info.AssetSize = a.Size
info.Downloaded = cachedUpdateMatchesForChannel(selected, m.Version, a, kind)
info.Downloaded = cachedUpdateMatches(m.Version, a, kind)
} else if a, ok := m.Asset(); ok {
// Manual installs (or a missing native package) still surface the portable
// artifact size so the UI can show how large the download is on the page.
@@ -513,499 +353,64 @@ func evaluateWithProfileForChannel(current, selected string, m *update.Manifest,
return info
}
type cachedUpdate struct {
Version string `json:"version"`
Channel string `json:"channel"`
Platform string `json:"platform"`
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
DownloadedAt string `json:"downloadedAt"`
ArtifactKind string `json:"artifactKind,omitempty"` // tarball | deb
SignaturePath string `json:"signaturePath,omitempty"` // required for deb
}
func updateCacheDir() (string, error) { return update.CacheDir() }
var updateCacheBaseDir = defaultUpdateCacheBaseDir
func defaultUpdateCacheBaseDir() (string, error) {
if cd := config.CacheDir(); cd != "" {
return filepath.Join(cd, "updates"), nil
}
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
}
return filepath.Join(base, "Reasonix", "updates"), nil
}
func updateCacheDir() (string, error) {
dir, err := updateCacheBaseDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
func updateMetadataPath() (string, error) {
// updateCache is the one verified artifact this build has downloaded, wherever
// the user's cache directory turned out to be.
func updateCache() (update.Cache, error) {
dir, err := updateCacheDir()
if err != nil {
return "", err
return update.Cache{}, err
}
return filepath.Join(dir, "downloaded.json"), nil
return update.Cache{Dir: dir}, nil
}
func assetFileName(asset update.Asset, version string) string {
if u, err := url.Parse(asset.URL); err == nil {
if base := filepath.Base(u.Path); base != "." && base != "/" {
return base
}
}
clean := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-").Replace(version)
return "Reasonix-" + clean + "-" + update.CurrentPlatform() + ".update"
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
name := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
func saveCachedUpdate(version string, asset update.Asset, data []byte, kind string, signature []byte) (*cachedUpdate, error) {
return saveCachedUpdateForChannel(runningUpdateChannel(), version, asset, data, kind, signature)
}
func saveCachedUpdateForChannel(selected, version string, asset update.Asset, data []byte, kind string, signature []byte) (*cachedUpdate, error) {
selected = normalizeUpdateChannel(selected)
if err := checkSHA256(data, asset.SHA256); err != nil {
return nil, err
}
kind = artifactKindFromMeta(kind)
// updaterFor binds this build's transports, cache and artifact kind to the
// shared updater. The rolling pointer still finds what is newest (the catalog
// is not the desktop's discovery path), but everything after that — download,
// verify, cache — is the code a rollback runs.
func updaterFor(profile installProfile) (*update.Updater, error) {
dir, err := updateCacheDir()
if err != nil {
return nil, err
}
path := filepath.Join(dir, assetFileName(asset, version))
if err := writeAtomic(path, data, 0o600); err != nil {
return nil, err
}
meta := &cachedUpdate{
Version: version,
Channel: selected,
Platform: update.CurrentPlatform(),
Path: path,
Size: int64(len(data)),
SHA256: asset.SHA256,
DownloadedAt: time.Now().UTC().Format(time.RFC3339),
ArtifactKind: kind,
}
if kind == artifactKindDeb {
if len(signature) == 0 {
return nil, fmt.Errorf("update: deb cache requires a signature")
}
sigPath := path + ".minisig"
if err := writeAtomic(sigPath, signature, 0o600); err != nil {
return nil, err
}
meta.SignaturePath = sigPath
}
raw, err := json.MarshalIndent(meta, "", " ")
c, err := httpClient()
if err != nil {
return nil, err
}
metadataPath, err := updateMetadataPath()
if err != nil {
return nil, err
v4, _ := httpClientIPv4() // best-effort; nil just means retries reuse c
pinned := ""
if cfg, err := config.Load(); err == nil && cfg != nil {
pinned = cfg.DesktopPinnedVersion()
}
if err := writeAtomic(metadataPath, append(raw, '\n'), 0o600); err != nil {
return nil, err
}
return meta, nil
}
func loadCachedUpdate() (*cachedUpdate, error) {
path, err := updateMetadataPath()
if err != nil {
return nil, err
}
raw, err := readFileUTF8(path)
if err != nil {
return nil, err
}
var meta cachedUpdate
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, err
}
if meta.Version == "" || meta.Channel == "" || meta.Platform == "" || meta.Path == "" || meta.SHA256 == "" {
return nil, fmt.Errorf("update: cached metadata is incomplete")
}
return &meta, nil
return update.New(update.Options{
Current: version,
Pinned: pinned,
HTTP: c,
Fallback: v4,
CacheDir: dir,
Kind: profile.ArtifactKind,
UserAgent: updaterUserAgent(runningUpdateChannel()),
AttemptTimeout: fetchAttemptTimeout,
}), nil
}
func cachedUpdateMatches(version string, asset update.Asset, kind string) bool {
return cachedUpdateMatchesForChannel(runningUpdateChannel(), version, asset, kind)
}
func cachedUpdateMatchesForChannel(selected, version string, asset update.Asset, kind string) bool {
selected = normalizeUpdateChannel(selected)
meta, err := loadCachedUpdate()
c, err := updateCache()
if err != nil {
return false
}
kind = artifactKindFromMeta(kind)
metaKind := artifactKindFromMeta(meta.ArtifactKind)
// Legacy portable caches omit artifactKind and remain valid for tarball only.
// Deb installs never reuse a cache that lacks a matching signature file.
if kind == artifactKindDeb {
if metaKind != artifactKindDeb || meta.SignaturePath == "" {
return false
}
if _, err := os.Stat(meta.SignaturePath); err != nil {
return false
}
} else if metaKind != artifactKindTarball {
return false
}
return meta.Version == version &&
meta.Channel == selected &&
meta.Platform == update.CurrentPlatform() &&
strings.EqualFold(meta.SHA256, asset.SHA256) &&
meta.Size == asset.Size &&
fileSHA256Matches(meta.Path, meta.SHA256)
return c.Holds(version, asset, kind)
}
func fileSHA256Matches(path, want string) bool {
f, err := os.Open(path)
if err != nil {
return false
// transportFor binds this build's user agent to the shared transport.
func transportFor(c, fallback *http.Client, selected string) update.Transport {
return update.Transport{
Client: c,
Fallback: fallback,
UserAgent: updaterUserAgent(selected),
AttemptTimeout: fetchAttemptTimeout,
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false
}
return strings.EqualFold(hex.EncodeToString(h.Sum(nil)), want)
}
func readVerifiedCachedUpdate() (*cachedUpdate, []byte, error) {
return readVerifiedCachedUpdateForChannel(runningUpdateChannel())
}
func readVerifiedCachedUpdateForChannel(selected string) (*cachedUpdate, []byte, error) {
selected = normalizeUpdateChannel(selected)
meta, err := loadCachedUpdate()
if err != nil {
return nil, nil, err
}
if meta.Channel != selected {
return nil, nil, fmt.Errorf("update: cached update is for %s channel, selected channel is %s", meta.Channel, selected)
}
if meta.Platform != update.CurrentPlatform() {
return nil, nil, fmt.Errorf("update: cached update is for %s, current platform is %s", meta.Platform, update.CurrentPlatform())
}
data, err := os.ReadFile(meta.Path)
if err != nil {
return nil, nil, err
}
if err := checkSHA256(data, meta.SHA256); err != nil {
return nil, nil, err
}
meta.ArtifactKind = artifactKindFromMeta(meta.ArtifactKind)
if meta.ArtifactKind == artifactKindDeb {
if meta.SignaturePath == "" {
return nil, nil, fmt.Errorf("update: cached deb is missing its signature")
}
if _, err := os.Stat(meta.SignaturePath); err != nil {
return nil, nil, fmt.Errorf("update: cached deb signature is missing")
}
}
return meta, data, nil
}
// downloadAttempts caps how many times a transient transport failure (connection
// reset, read timeout, gateway 5xx) is retried before the update gives up. CN IPv6
// routes to Cloudflare reset mid-transfer often enough that a retry or two usually
// completes the download instead of surfacing a "forcibly closed" error.
const downloadAttempts = 3
// retryBackoff is the pause before the Nth retry; a package var so tests shrink it.
var retryBackoff = func(attempt int) time.Duration { return time.Duration(attempt) * 500 * time.Millisecond }
// retryTransient runs attempt 1..downloadAttempts of fetch, pausing between tries,
// until one succeeds. fetch receives the 1-based attempt number so a caller can
// switch transports on a retry. It stops early when ctx is cancelled (window closed
// / user cancelled). Only the transport is retried; the signature and sha256 checks
// run downstream in downloadVerify and are not retried.
func retryTransient(ctx context.Context, fetch func(attempt int) error) error {
var err error
for attempt := 1; attempt <= downloadAttempts; attempt++ {
if err = fetch(attempt); err == nil {
return nil
}
if !isTransientFetchError(err) {
break
}
if ctx.Err() != nil || attempt == downloadAttempts {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(retryBackoff(attempt)):
}
}
return err
}
type httpStatusError struct {
url string
status string
code int
}
func (e *httpStatusError) Error() string { return fmt.Sprintf("GET %s: %s", e.url, e.status) }
func isTransientFetchError(err error) bool {
if errors.Is(err, errUpdateResponseTooLarge) {
return false
}
var statusErr *httpStatusError
if !errors.As(err, &statusErr) {
return true
}
return statusErr.code == http.StatusRequestTimeout || statusErr.code == http.StatusTooManyRequests || statusErr.code >= 500
}
// fetchBytes GETs a URL fully into memory, retrying transient transport failures.
func fetchBytes(ctx context.Context, c *http.Client, url string) ([]byte, error) {
return fetchBytesFallbackForChannel(ctx, c, nil, runningUpdateChannel(), url)
}
// fetchBytesFallback retries transport failures with the IPv4-pinned client.
// This covers small manifest/signature requests as well as the artifact body;
// previously only the large artifact download escaped a broken IPv6 route.
func fetchBytesFallback(ctx context.Context, c, fallback *http.Client, url string) ([]byte, error) {
return fetchBytesFallbackForChannel(ctx, c, fallback, runningUpdateChannel(), url)
}
func fetchBytesFallbackForChannel(ctx context.Context, c, fallback *http.Client, selected, url string) ([]byte, error) {
return fetchBytesFallbackForChannelSized(ctx, c, fallback, selected, url, maxDesktopManifestSize)
}
func fetchBytesFallbackForChannelSized(
ctx context.Context,
c, fallback *http.Client,
selected, url string,
maxBytes int64,
) ([]byte, error) {
selected = normalizeUpdateChannel(selected)
var data []byte
err := retryTransient(ctx, func(attempt int) error {
client := c
if attempt > 1 && fallback != nil {
client = fallback
}
var e error
attemptCtx, cancel := context.WithTimeout(ctx, fetchAttemptTimeout)
data, e = fetchBytesOnce(attemptCtx, client, selected, url, maxBytes)
cancel()
return e
})
return data, err
}
var errUpdateResponseTooLarge = errors.New("update: response exceeds allowed size")
func fetchBytesOnce(ctx context.Context, c *http.Client, selected, url string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return nil, fmt.Errorf("update: invalid response size limit %d", maxBytes)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", updaterUserAgent(selected))
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &httpStatusError{url: url, status: resp.Status, code: resp.StatusCode}
}
if resp.ContentLength > maxBytes {
return nil, fmt.Errorf("%w: GET %s declared %d bytes, maximum is %d", errUpdateResponseTooLarge, url, resp.ContentLength, maxBytes)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%w: GET %s exceeded %d bytes", errUpdateResponseTooLarge, url, maxBytes)
}
return data, nil
}
// download fetches url into memory, invoking onProgress as bytes arrive. A transient
// transport failure is retried; the retry resumes from the bytes already received
// via a Range request instead of restarting, and switches to the IPv4 fallback
// client (when provided) since a reset usually means the IPv6 route is the problem.
// total is the expected size for the progress denominator (refined from the response).
func download(ctx context.Context, c, fallback *http.Client, url string, total int64, onProgress func(received, total int64)) ([]byte, error) {
return downloadForChannel(ctx, c, fallback, runningUpdateChannel(), url, total, onProgress)
}
func downloadForChannel(ctx context.Context, c, fallback *http.Client, selected, url string, total int64, onProgress func(received, total int64)) ([]byte, error) {
selected = normalizeUpdateChannel(selected)
if total < 0 || total > maxDesktopReleaseAssetSize {
return nil, fmt.Errorf("update: invalid expected asset size %d", total)
}
expectedSize := total
var buf bytes.Buffer
err := retryTransient(ctx, func(attempt int) error {
client := c
if attempt > 1 && fallback != nil {
client = fallback
}
return downloadInto(ctx, client, selected, url, expectedSize, &buf, &total, onProgress)
})
if err != nil {
return nil, err
}
if expectedSize > 0 && int64(buf.Len()) != expectedSize {
return nil, fmt.Errorf("update: downloaded size mismatch: got %d want %d", buf.Len(), expectedSize)
}
return buf.Bytes(), nil
}
// downloadInto appends url's body to buf, resuming from buf's current length via a
// Range request so a retry continues the partial download. A 206 carries the
// remaining bytes; a 200 means the server ignored Range, so buf is reset and the
// whole file re-downloaded. total is refined from the response for the progress
// denominator (Content-Length on 200, the size field of Content-Range on 206).
func downloadInto(ctx context.Context, c *http.Client, selected, url string, expectedSize int64, buf *bytes.Buffer, total *int64, onProgress func(received, total int64)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", updaterUserAgent(selected))
if buf.Len() > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
buf.Reset()
if resp.ContentLength > 0 {
if resp.ContentLength > maxDesktopReleaseAssetSize {
return fmt.Errorf("update: response size %d exceeds maximum %d", resp.ContentLength, maxDesktopReleaseAssetSize)
}
*total = resp.ContentLength
}
case http.StatusPartialContent:
if t := totalFromContentRange(resp.Header.Get("Content-Range")); t > 0 {
if t > maxDesktopReleaseAssetSize {
return fmt.Errorf("update: response size %d exceeds maximum %d", t, maxDesktopReleaseAssetSize)
}
*total = t
}
default:
return fmt.Errorf("GET %s: %s", url, resp.Status)
}
have := int64(buf.Len())
if expectedSize > 0 && have > expectedSize {
return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", have, expectedSize)
}
limit := maxDesktopReleaseAssetSize - have + 1
if expectedSize > 0 {
limit = expectedSize - have + 1
}
body := io.LimitReader(resp.Body, limit)
pr := &progressReader{r: body, received: have, lastEmit: have, total: *total, onProgress: onProgress}
_, err = io.Copy(buf, pr)
if err == nil && expectedSize > 0 && int64(buf.Len()) > expectedSize {
return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", buf.Len(), expectedSize)
}
if err == nil && int64(buf.Len()) > maxDesktopReleaseAssetSize {
return fmt.Errorf("update: downloaded size exceeds maximum %d", maxDesktopReleaseAssetSize)
}
return err
}
// totalFromContentRange parses the total size out of a "bytes 200-999/1000" header,
// returning 0 when it's absent or "*" (unknown).
func totalFromContentRange(v string) int64 {
i := strings.LastIndex(v, "/")
if i < 0 {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
if err != nil {
return 0
}
return n
}
// progressReader reports cumulative bytes read, throttled so the event channel
// isn't flooded.
type progressReader struct {
r io.Reader
received int64
total int64
lastEmit int64
onProgress func(received, total int64)
}
func (p *progressReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.received += int64(n)
// Emit roughly every 256 KiB, and always on the final read (io.EOF).
if p.onProgress != nil && (p.received-p.lastEmit >= 256<<10 || err == io.EOF) {
p.lastEmit = p.received
p.onProgress(p.received, p.total)
}
return n, err
}
// checkSHA256 verifies data's digest matches the lowercase-hex want.
func checkSHA256(data []byte, want string) error {
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, want) {
return fmt.Errorf("update: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// extractBinary pulls a single named regular file out of a .tar.gz blob.
@@ -1031,58 +436,10 @@ func extractBinary(targz []byte, name string) ([]byte, error) {
return nil, fmt.Errorf("update: %q not found in archive", name)
}
func extractLinuxReleaseUnit(targz []byte) (map[string][]byte, error) {
const (
desktop = "reasonix-desktop"
guard = "reasonix-guard"
cli = "reasonix"
)
want := map[string]struct{}{desktop: {}, guard: {}, cli: {}}
found := make(map[string][]byte, len(want))
gz, err := gzip.NewReader(bytes.NewReader(targz))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
name := path.Base(strings.TrimSpace(h.Name))
if _, ok := want[name]; !ok {
continue
}
if h.Typeflag != tar.TypeReg || h.Size < 0 {
return nil, fmt.Errorf("update: release member %q is not a regular file", name)
}
if _, duplicate := found[name]; duplicate {
return nil, fmt.Errorf("update: release member %q appears more than once", name)
}
body, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
found[name] = body
}
if len(found) != len(want) {
for name := range want {
if _, ok := found[name]; !ok {
return nil, fmt.Errorf("update: release member %q not found in archive", name)
}
}
}
return found, nil
}
// applyLinux replaces the running binary with the one inside the downloaded
// tar.gz; the caller relaunches afterwards.
func applyLinux(targz []byte, prepared *repair.UpdateTransaction) error {
release, err := extractLinuxReleaseUnit(targz)
release, err := update.ExtractReleaseUnit(targz)
if err != nil {
return err
}
@@ -1126,57 +483,7 @@ func applyLinux(targz []byte, prepared *repair.UpdateTransaction) error {
return nil
}
// applyLinuxVersioned publishes a verified compatibility tarball into a new
// version directory and swaps current.json last. The tar still contains the
// one-shot reasonix-guard member for v1.18-v1.19 updaters, but v1.20+ ignores
// that member and never persists it again.
func applyLinuxVersioned(targz []byte, targetVersion string) error {
release, err := extractLinuxReleaseUnit(targz)
if err != nil {
return err
}
root := currentInstallDirForLinuxUpdate()
if _, err := installlayout.ReadCurrent(root); err != nil {
return fmt.Errorf("update: resolve active Linux layout: %w", err)
}
targetVersion = strings.TrimSpace(targetVersion)
if !strings.HasPrefix(targetVersion, "v") {
targetVersion = "v" + targetVersion
}
if err := installlayout.ValidateVersionName(targetVersion); err != nil {
return err
}
staging, err := os.MkdirTemp(root, ".reasonix-linux-update-*")
if err != nil {
return fmt.Errorf("update: create Linux version staging: %w", err)
}
defer os.RemoveAll(staging)
desktopPath := filepath.Join(staging, installlayout.DesktopBinaryName())
cliPath := filepath.Join(staging, installlayout.CLIBinaryName())
if err := os.WriteFile(desktopPath, release["reasonix-desktop"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux desktop: %w", err)
}
if err := os.WriteFile(cliPath, release["reasonix"], 0o700); err != nil {
return fmt.Errorf("update: stage Linux CLI: %w", err)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: targetVersion,
RequestID: "linux-" + targetVersion,
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: desktopPath, Mode: 0o700},
{Name: installlayout.CLIBinaryName(), Path: cliPath, Mode: 0o700},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
return fmt.Errorf("update: activate Linux version: %w", err)
}
_ = installlayout.RetainPreviousVersions(root, 0)
return nil
}
var currentExecutablePathForLinux = currentExecutablePath
var currentInstallDirForLinuxUpdate = currentInstallDir
var applyLinuxReleaseUnit = func(
claimed *repair.UpdateTransaction,
@@ -1203,26 +510,25 @@ var applyLinuxReleaseUnit = func(
}
func applyWindowsFile(path, expectedSHA256, targetVersion string, prepared *repair.UpdateTransaction) error {
staging, err := updateCacheDir()
if err != nil {
return err
}
installDir := currentInstallDir()
handoff := update.WindowsHandoff{
InstallerPath: path,
InstallerSHA256: expectedSHA256,
InstallDir: installDir,
RelaunchPath: currentLauncherPath(),
StagingDir: staging,
}
if installlayout.HasCurrent(installDir) {
return startWindowsVersionedUpdateHandoff(
path,
expectedSHA256,
installDir,
currentLauncherPath(),
targetVersion,
)
return handoff.StartVersioned(targetVersion)
}
if prepared == nil {
return fmt.Errorf("update: prepared transaction is unavailable")
}
return startWindowsUpdateHandoff(
path,
expectedSHA256,
installDir,
currentLauncherPath(),
prepared,
)
return handoff.Start(prepared)
}
func currentExecutablePath() string {
+66 -86
View File
@@ -13,7 +13,6 @@ import (
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -175,24 +174,21 @@ func (a *App) downloadUpdateRequest(selectedChannel, expectedVersion, requestID
if !profile.CanSelfUpdate {
return nil, a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
}
asset, kind, ok := selectUpdateAsset(m, profile)
if !ok {
return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, fmt.Errorf("no update artifact for %s", update.CurrentPlatform()))
}
data, sig, err := a.downloadVerify(requestID, selectedChannel, expectedVersion, asset)
u, err := updaterFor(profile)
if err != nil {
return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
}
meta, err := saveCachedUpdateForChannel(selectedChannel, m.Version, asset, data, kind, sig)
// The rolling pointer found the manifest; from here a forward update and a
// rollback are the same download, the same two checks, and the same cache.
meta, err := u.DownloadManifest(a.reqCtx(), m, a.updateReport(requestID, selectedChannel, expectedVersion))
if err != nil {
return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
}
a.emitProgress(requestID, selectedChannel, meta.Version, "downloaded", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, update.PhaseCached, meta.Size, meta.Size, "")
return &UpdateDownloadResult{
RequestID: requestID,
Version: meta.Version,
Channel: meta.Channel,
Channel: normalizeUpdateChannel(selectedChannel),
Path: meta.Path,
Size: meta.Size,
SHA256: meta.SHA256,
@@ -210,16 +206,13 @@ func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID s
if !profile.CanSelfUpdate || !canSelfUpdate() {
return a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
}
meta, data, err := readVerifiedCachedUpdateForChannel(selectedChannel)
cache, err := updateCache()
if err != nil {
return a.failUpdate(requestID, selectedChannel, expectedVersion, err)
}
if meta.Version != expectedVersion {
return a.failUpdate(requestID, selectedChannel, expectedVersion, fmt.Errorf(
"update: cached version %s does not match checked version %s",
meta.Version,
expectedVersion,
))
meta, data, err := cache.Verified(expectedVersion)
if err != nil {
return a.failUpdate(requestID, selectedChannel, expectedVersion, err)
}
// Re-detect install type at install time so a path change between download
// and install cannot apply the wrong artifact kind.
@@ -245,20 +238,20 @@ func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID s
// artifact kinds disagree with the active mode.
wantKind := profile.ArtifactKind
if wantKind == "" {
wantKind = artifactKindTarball
wantKind = update.KindTarball
}
if artifactKindFromMeta(meta.ArtifactKind) != artifactKindFromMeta(wantKind) {
if meta.Kind != update.NormalizeKind(wantKind) {
return a.failUpdate(requestID, selectedChannel, expectedVersion, errUpdateCacheMismatch)
}
if err := a.reconcilePendingUpdateForRequest(requestID, meta); err != nil {
if err := a.reconcilePendingUpdateForRequest(requestID, selectedChannel, meta.Version, meta.Size); err != nil {
return err
}
switch profile.Mode {
case installModeDeb:
return a.installDebUpdate(requestID, meta)
return a.installDebUpdate(requestID, selectedChannel, meta)
default:
return a.installPortableUpdate(requestID, meta, data)
return a.installPortableUpdate(requestID, selectedChannel, meta, data)
}
}
@@ -266,9 +259,9 @@ func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID s
// install-mode dispatch. The early pass avoids paying download and verification
// costs for a blocked update; the second pass prevents a profile change or a
// concurrent process from bypassing an unfinished release-unit transaction.
func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpdate) error {
func (a *App) reconcilePendingUpdateForRequest(requestID, selectedChannel, target string, size int64) error {
if pendingUpdateExistsForInstall() {
a.emitProgress(requestID, meta.Channel, meta.Version, "recovering", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, target, "recovering", size, size, "")
// A user-initiated update proves the desktop reached a usable UI. Retire
// an eligible superseded app-bundle or flat-layout transaction here as
// well as in the delayed post-DOM health task, so an immediate click never
@@ -314,7 +307,7 @@ func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpd
} else {
err = fmt.Errorf("update recovery: could not safely finish the previous update: %w", err)
}
return a.failUpdate(requestID, meta.Channel, meta.Version, err)
return a.failUpdate(requestID, selectedChannel, target, err)
}
return nil
}
@@ -342,43 +335,44 @@ func (a *App) AbandonPendingUpdate() error {
return nil
}
func (a *App) installDebUpdate(requestID string, meta *cachedUpdate) error {
func (a *App) installDebUpdate(requestID, selectedChannel string, meta update.Cached) error {
// authorizing = Polkit password dialog. The helper streams
// REASONIX_UPDATE_PHASE=installing on stderr after validation and before
// apt-get, so the UI can leave authorizing while the package manager runs.
a.emitProgress(requestID, meta.Channel, meta.Version, "authorizing", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "authorizing", meta.Size, meta.Size, "")
err := applyDebLinux(meta.Path, meta.SignaturePath, func(phase string) {
if phase == "installing" {
a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "installing", meta.Size, meta.Size, "")
}
})
if isAuthCancelled(err) {
// User dismissed the Polkit dialog: keep the verified cache and return to
// the downloaded state so they can retry. Do not count as an update error.
a.recordUpdateEvent("authorization_cancelled")
a.emitProgress(requestID, meta.Channel, meta.Version, "downloaded", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "downloaded", meta.Size, meta.Size, "")
return nil
}
if err != nil {
if errors.Is(err, errUpdateAuthFailed) {
// Surface a manual-install hint without writing /usr/bin ourselves.
return a.failUpdate(requestID, meta.Channel, meta.Version, fmt.Errorf("%w. %s", err, manualDebInstallHint()))
return a.failUpdate(requestID, selectedChannel, meta.Version, fmt.Errorf("%w. %s", err, manualDebInstallHint()))
}
return a.failUpdate(requestID, meta.Channel, meta.Version, err)
return a.failUpdate(requestID, selectedChannel, meta.Version, err)
}
// Ensure installing was shown even if a phase line was missed (older helper).
a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
a.emitProgress(requestID, meta.Channel, meta.Version, "done", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "installing", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "done", meta.Size, meta.Size, "")
a.shutdown(a.ctx)
_ = relaunchThroughLauncher()
os.Exit(0)
return nil
}
func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data []byte) error {
a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
func (a *App) installPortableUpdate(requestID, selectedChannel string, meta update.Cached, data []byte) error {
a.emitProgress(requestID, selectedChannel, meta.Version, "installing", meta.Size, meta.Size, "")
var preparedUpdate *repair.UpdateTransaction
versionedPortable := (runtime.GOOS == "windows" || runtime.GOOS == "linux") && installlayout.HasCurrent(currentInstallDir())
layout := update.Here()
versionedPortable := (runtime.GOOS == "windows" || runtime.GOOS == "linux") && layout.Versioned()
if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && !versionedPortable {
// Back up the complete legacy release unit (main binary plus launcher
// and migration siblings) so rollback never leaves a mixed-version
@@ -387,21 +381,19 @@ func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data [
var err error
preparedUpdate, err = repair.PrepareFileUpdate(version, meta.Version, currentExecutablePath(), updateSiblingArtifacts()...)
if err != nil {
return a.failUpdate(requestID, meta.Channel, meta.Version, err)
return a.failUpdate(requestID, selectedChannel, meta.Version, err)
}
}
var err error
switch runtime.GOOS {
case "windows":
switch {
// Everything but a legacy flat install goes through the shared installer,
// the same one Studio uses. Only 1.x keeps the transactional fallback.
case versionedPortable, runtime.GOOS == "darwin":
err = a.versionedInstaller(layout).Install(a.reqCtx(), meta)
case runtime.GOOS == "windows":
err = applyWindowsFile(meta.Path, meta.SHA256, meta.Version, preparedUpdate)
case "darwin":
err = applyMac(meta.Path, meta.Version)
case "linux":
if versionedPortable {
err = applyLinuxVersioned(data, meta.Version)
} else {
err = applyLinux(data, preparedUpdate)
}
case runtime.GOOS == "linux":
err = applyLinux(data, preparedUpdate)
default:
err = fmt.Errorf("self-update unsupported on %s", runtime.GOOS)
}
@@ -433,10 +425,10 @@ func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data [
}
}
}
return a.failUpdate(requestID, meta.Channel, meta.Version, err)
return a.failUpdate(requestID, selectedChannel, meta.Version, err)
}
a.emitProgress(requestID, meta.Channel, meta.Version, "done", meta.Size, meta.Size, "")
a.emitProgress(requestID, selectedChannel, meta.Version, "done", meta.Size, meta.Size, "")
// Persist the conversation and stop subprocesses before handing off (same as
// shutdown). On Linux the binary is now replaced, so relaunch it; on Windows and
@@ -466,10 +458,7 @@ func (a *App) ApplyUpdateRequest(selectedChannel, expectedVersion, requestID str
// sequence, so another request cannot slip into the former phase gap.
defer finish()
if err := a.reconcilePendingUpdateForRequest(requestID, &cachedUpdate{
Channel: selectedChannel,
Version: expectedVersion,
}); err != nil {
if err := a.reconcilePendingUpdateForRequest(requestID, selectedChannel, expectedVersion, 0); err != nil {
return err
}
if _, err := a.downloadUpdateRequest(selectedChannel, expectedVersion, requestID); err != nil {
@@ -483,40 +472,31 @@ func (a *App) ApplyUpdateRequest(selectedChannel, expectedVersion, requestID str
return nil
}
// downloadVerify downloads the asset (streaming progress), verifies its minisign
// signature against the embedded public key, then its sha256. It returns the
// verified bytes and the raw signature (needed for deb helper re-verification).
func (a *App) downloadVerify(requestID, selectedChannel, expectedVersion string, asset update.Asset) (data, sig []byte, err error) {
c, err := httpClient()
if err != nil {
return nil, nil, err
// versionedInstaller is the shared v1.20+ install path bound to this build's
// cache directory. A staging directory that cannot be resolved leaves it empty,
// which the Windows handoff refuses rather than staging the helper anywhere.
func (a *App) versionedInstaller(layout update.Layout) update.VersionedInstaller {
staging, _ := updateCacheDir()
return update.VersionedInstaller{Layout: layout, Staging: staging, Current: version}
}
// updateReport forwards the updater's narration to the frontend. The
// denominator is carried from the last byte report so verifying shows a full
// bar; PhaseCached is left to the caller, which knows the cached size a cache
// hit never reported a byte of.
func (a *App) updateReport(requestID, selectedChannel, expectedVersion string) update.Report {
var total int64
return update.Report{
Bytes: func(received, size int64) {
total = size
a.emitProgress(requestID, selectedChannel, expectedVersion, update.PhaseDownloading, received, size, "")
},
Phase: func(phase string) {
if phase == update.PhaseVerifying {
a.emitProgress(requestID, selectedChannel, expectedVersion, phase, total, total, "")
}
},
}
v4, _ := httpClientIPv4() // best-effort IPv4 fallback; nil just means retries reuse c
data, err = downloadForChannel(a.reqCtx(), c, v4, selectedChannel, asset.URL, asset.Size, func(rcv, total int64) {
a.emitProgress(requestID, selectedChannel, expectedVersion, "downloading", rcv, total, "")
})
if err != nil {
return nil, nil, err
}
a.emitProgress(requestID, selectedChannel, expectedVersion, "verifying", asset.Size, asset.Size, "")
sig, err = fetchBytesFallbackForChannelSized(
a.reqCtx(),
c,
v4,
selectedChannel,
asset.Sig,
maxDesktopSignatureSize,
)
if err != nil {
return nil, nil, err
}
if err := update.Verify(data, sig); err != nil {
return nil, nil, err
}
if err := checkSHA256(data, asset.SHA256); err != nil {
return nil, nil, err
}
return data, sig, nil
}
// reqCtx is the context for updater HTTP calls — the Wails context once startup has
+3 -4
View File
@@ -196,18 +196,17 @@ func isAuthCancelled(err error) bool {
// ensureDebCacheMatchesProfile re-detects install mode at install time so a
// download made as portable cannot be applied after the install path changes.
func ensureDebCacheMatchesProfile(meta *cachedUpdate, profile installProfile) error {
kind := artifactKindFromMeta(meta.ArtifactKind)
func ensureDebCacheMatchesProfile(meta update.Cached, profile installProfile) error {
switch profile.Mode {
case installModeDeb:
if kind != artifactKindDeb {
if meta.Kind != update.KindDeb {
return errUpdateCacheMismatch
}
if meta.SignaturePath == "" {
return errUpdateCacheMismatch
}
case installModePortable:
if kind != artifactKindTarball {
if meta.Kind != update.KindTarball {
return errUpdateCacheMismatch
}
default:
+6 -2
View File
@@ -2,7 +2,11 @@
package main
import "fmt"
import (
"fmt"
"reasonix/desktop/internal/update"
)
var (
errUpdateAuthFailed = fmt.Errorf("update: authorization failed")
@@ -17,7 +21,7 @@ func isAuthCancelled(err error) bool {
return false
}
func ensureDebCacheMatchesProfile(meta *cachedUpdate, profile installProfile) error {
func ensureDebCacheMatchesProfile(meta update.Cached, profile installProfile) error {
if profile.Mode == installModeDeb {
return fmt.Errorf("update: deb install is only supported on Linux")
}
+7 -23
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"runtime"
"strings"
"reasonix/desktop/internal/update"
)
@@ -15,9 +14,6 @@ const (
installModeDeb = "deb"
installModeManual = "manual"
artifactKindTarball = "tarball"
artifactKindDeb = "deb"
linuxUpdateHelperPath = "/usr/lib/reasonix/reasonix-update-helper"
linuxPkexecPath = "/usr/bin/pkexec"
linuxDebPackageName = "reasonix-desktop"
@@ -29,7 +25,7 @@ type installProfile struct {
CanSelfUpdate bool
RequiresElev bool
ManualReason string
ArtifactKind string // artifactKindTarball | artifactKindDeb | ""
ArtifactKind string // update.KindTarball | update.KindDeb | ""
}
// detectInstallProfile classifies how this binary was installed and which update
@@ -43,14 +39,14 @@ func detectInstallProfile() installProfile {
return installProfile{
Mode: installModePortable,
CanSelfUpdate: true,
ArtifactKind: artifactKindTarball,
ArtifactKind: update.KindTarball,
}
case "darwin":
if canSelfUpdate() {
return installProfile{
Mode: installModePortable,
CanSelfUpdate: true,
ArtifactKind: artifactKindTarball,
ArtifactKind: update.KindTarball,
}
}
return installProfile{
@@ -91,7 +87,7 @@ func profileForManifest(base installProfile, m *update.Manifest) installProfile
base.ManualReason = "system update helper is unavailable; install with: sudo apt install ./Reasonix-linux-amd64.deb"
return base
}
base.ArtifactKind = artifactKindDeb
base.ArtifactKind = update.KindDeb
base.CanSelfUpdate = true
base.RequiresElev = true
return base
@@ -103,7 +99,7 @@ func profileForManifest(base installProfile, m *update.Manifest) installProfile
base.ManualReason = "no update artifact is published for this platform"
return base
}
base.ArtifactKind = artifactKindTarball
base.ArtifactKind = update.KindTarball
return base
default:
return base
@@ -115,11 +111,11 @@ func selectUpdateAsset(m *update.Manifest, profile installProfile) (update.Asset
switch profile.Mode {
case installModeDeb:
if a, ok := m.NativePackage(); ok {
return a, artifactKindDeb, true
return a, update.KindDeb, true
}
case installModePortable:
if a, ok := m.Asset(); ok {
return a, artifactKindTarball, true
return a, update.KindTarball, true
}
}
return update.Asset{}, "", false
@@ -141,15 +137,3 @@ func linuxDebHelperReady() bool {
func manualDebInstallHint() string {
return "Install manually with: sudo apt install ./Reasonix-linux-amd64.deb"
}
func artifactKindFromMeta(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case artifactKindDeb:
return artifactKindDeb
case artifactKindTarball, "":
// Empty means legacy portable cache written before artifactKind existed.
return artifactKindTarball
default:
return kind
}
}
-16
View File
@@ -1,16 +0,0 @@
//go:build !darwin
package main
import (
"fmt"
"runtime"
)
func applyMac(string, string) error {
return fmt.Errorf("self-update unsupported on %s", runtime.GOOS)
}
func maybeRunMacUpdateHandoff([]string) (bool, int) {
return false, 0
}
-23
View File
@@ -1,23 +0,0 @@
//go:build !windows
package main
import (
"os/exec"
"reasonix/internal/repair"
)
// installerCommand exists only so updater.go compiles off Windows; applyWindows is
// never dispatched there (see updater_app.go).
func installerCommand(name, _ string) *exec.Cmd {
return exec.Command(name)
}
func startWindowsUpdateHandoff(name, _, dir, _ string, _ *repair.UpdateTransaction) error {
return installerCommand(name, dir).Start()
}
func startWindowsVersionedUpdateHandoff(name, _, dir, _, _ string) error {
return installerCommand(name, dir).Start()
}
+17 -586
View File
@@ -12,18 +12,15 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/desktop/internal/update"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -164,8 +161,7 @@ func TestUpdaterReconcilesPendingUpdateBeforeInstallModeDispatch(t *testing.T) {
}
return repair.PendingUpdateReconcileResult{Pending: true, Cleared: true}, nil
}
meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
if err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta); err != nil {
if err := (&App{}).reconcilePendingUpdateForRequest("install-1", "stable", "v1.18.0-preview.65", 42); err != nil {
t.Fatal(err)
}
if !called {
@@ -195,8 +191,7 @@ func TestUpdaterArchivesSupersededUpdateBeforeReconciliation(t *testing.T) {
}
return repair.PendingUpdateReconcileResult{}, nil
}
meta := &cachedUpdate{Channel: "stable", Version: "v1.20.0", Size: 42}
if err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta); err != nil {
if err := (&App{}).reconcilePendingUpdateForRequest("install-1", "stable", "v1.20.0", 42); err != nil {
t.Fatal(err)
}
}
@@ -239,8 +234,7 @@ func TestUpdaterBlocksInstallWhilePreviousReleaseAwaitsHealth(t *testing.T) {
reconcilePendingUpdateForInstall = func(string) (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
}
meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta)
err := (&App{}).reconcilePendingUpdateForRequest("install-1", "stable", "v1.18.0-preview.65", 42)
if err == nil || !strings.Contains(err.Error(), "startup health check") {
t.Fatalf("health-check recovery error = %v", err)
}
@@ -284,7 +278,7 @@ func TestEvaluate(t *testing.T) {
portable := installProfile{
Mode: installModePortable,
CanSelfUpdate: runtime.GOOS != "darwin",
ArtifactKind: artifactKindTarball,
ArtifactKind: update.KindTarball,
}
if runtime.GOOS == "darwin" {
portable.Mode = installModeManual
@@ -341,7 +335,7 @@ func TestEvaluateDebSelectsNativePackage(t *testing.T) {
Mode: installModeDeb,
CanSelfUpdate: true,
RequiresElev: true,
ArtifactKind: artifactKindDeb,
ArtifactKind: update.KindDeb,
}
got := evaluateWithProfile("v1.0.0", m, deb)
if !got.Available || got.AssetSize != 200 {
@@ -805,9 +799,9 @@ func TestValidateUpdateRedirect(t *testing.T) {
func withUpdateCacheDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
restore := updateCacheBaseDir
updateCacheBaseDir = func() (string, error) { return dir, nil }
t.Cleanup(func() { updateCacheBaseDir = restore })
restore := update.CacheDirFn
update.CacheDirFn = func() (string, error) { return dir, nil }
t.Cleanup(func() { update.CacheDirFn = restore })
return dir
}
@@ -816,6 +810,8 @@ func sha256Hex(data []byte) string {
return hex.EncodeToString(sum[:])
}
// The cache lives in the update package; what the shell owns is pointing it at
// the user's cache directory and reporting the hit as UpdateInfo.Downloaded.
func TestSaveCachedUpdateMarksEvaluateDownloaded(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
@@ -824,7 +820,7 @@ func TestSaveCachedUpdateMarksEvaluateDownloaded(t *testing.T) {
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
URL: "https://dl.reasonix.io/studio-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
@@ -832,66 +828,22 @@ func TestSaveCachedUpdateMarksEvaluateDownloaded(t *testing.T) {
Version: "v9.9.9",
Platforms: map[string]update.Asset{update.CurrentPlatform(): asset},
}
portable := installProfile{Mode: installModePortable, CanSelfUpdate: true, ArtifactKind: artifactKindTarball}
portable := installProfile{Mode: installModePortable, CanSelfUpdate: true, ArtifactKind: update.KindTarball}
if got := evaluateWithProfile("v1.0.0", manifest, portable); got.Downloaded {
t.Fatal("fresh cache should not report a downloaded update")
}
meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindTarball, nil)
cache, err := updateCache()
if err != nil {
t.Fatalf("saveCachedUpdate: %v", err)
t.Fatal(err)
}
if meta.Version != "v9.9.9" || meta.Channel != "stable" || meta.Platform != update.CurrentPlatform() {
t.Fatalf("cached metadata mismatch: %+v", meta)
if _, err := cache.Save("v9.9.9", asset, data, update.KindTarball, nil); err != nil {
t.Fatalf("Save: %v", err)
}
if got := evaluateWithProfile("v1.0.0", manifest, portable); !got.Downloaded {
t.Fatalf("evaluate did not detect cached update: %+v", got)
}
}
func TestCachedUpdateRejectsTamperedArtifact(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
channel = "stable"
t.Cleanup(func() { channel = oldChannel })
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindTarball, nil)
if err != nil {
t.Fatalf("saveCachedUpdate: %v", err)
}
if err := os.WriteFile(meta.Path, []byte("tampered"), 0o600); err != nil {
t.Fatal(err)
}
if cachedUpdateMatches("v9.9.9", asset, artifactKindTarball) {
t.Fatal("tampered cached artifact should not match")
}
if _, _, err := readVerifiedCachedUpdate(); err == nil {
t.Fatal("readVerifiedCachedUpdate should reject a tampered artifact")
}
}
func TestCachedUpdateAcceptsLegacyChannelAlias(t *testing.T) {
withUpdateCacheDir(t)
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
if _, err := saveCachedUpdateForChannel("stable", "v9.9.9", asset, data, artifactKindTarball, nil); err != nil {
t.Fatalf("saveCachedUpdateForChannel: %v", err)
}
if _, _, err := readVerifiedCachedUpdateForChannel("preview"); err != nil {
t.Fatalf("legacy Preview alias did not read official cache: %v", err)
}
}
func TestLegacyChannelAliasDoesNotPermitDowngrade(t *testing.T) {
oldChannel := channel
channel = "preview"
@@ -917,74 +869,10 @@ func TestLegacyChannelAliasDoesNotPermitDowngrade(t *testing.T) {
}
}
func TestDebCacheRequiresSignatureAndRejectsTarballReuse(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
channel = "stable"
t.Cleanup(func() { channel = oldChannel })
data := []byte("deb-bytes")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.deb",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
if _, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindDeb, nil); err == nil {
t.Fatal("deb cache without signature must fail")
}
sig := []byte("minisig-bytes")
meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindDeb, sig)
if err != nil {
t.Fatalf("saveCachedUpdate deb: %v", err)
}
if meta.SignaturePath == "" {
t.Fatal("deb cache must record signature path")
}
if !cachedUpdateMatches("v9.9.9", asset, artifactKindDeb) {
t.Fatal("deb cache with matching signature should match")
}
// A tarball install must not reuse a deb cache.
if cachedUpdateMatches("v9.9.9", asset, artifactKindTarball) {
t.Fatal("deb cache must not match tarball requests")
}
// Signature removal invalidates the download marker.
if err := os.Remove(meta.SignaturePath); err != nil {
t.Fatal(err)
}
if cachedUpdateMatches("v9.9.9", asset, artifactKindDeb) {
t.Fatal("deb cache without signature file must not match")
}
// Portable legacy cache (no artifactKind) remains valid for tarball.
tarball := []byte("tarball-bytes")
tAsset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(tarball)),
SHA256: sha256Hex(tarball),
}
meta, err = saveCachedUpdate("v9.9.9", tAsset, tarball, artifactKindTarball, nil)
if err != nil {
t.Fatal(err)
}
// Simulate pre-artifactKind metadata.
meta.ArtifactKind = ""
raw, _ := json.MarshalIndent(meta, "", " ")
path, _ := updateMetadataPath()
if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil {
t.Fatal(err)
}
if !cachedUpdateMatches("v9.9.9", tAsset, artifactKindTarball) {
t.Fatal("legacy portable cache should still match tarball")
}
if cachedUpdateMatches("v9.9.9", tAsset, artifactKindDeb) {
t.Fatal("legacy portable cache must not match deb")
}
}
func TestProfileForManifestDebWithoutHelperBecomesManual(t *testing.T) {
// profileForManifest checks linuxDebHelperReady(); on non-linux it is always
// false, so a synthetic deb profile without native assets becomes manual.
base := installProfile{Mode: installModeDeb, CanSelfUpdate: true, RequiresElev: true, ArtifactKind: artifactKindDeb}
base := installProfile{Mode: installModeDeb, CanSelfUpdate: true, RequiresElev: true, ArtifactKind: update.KindDeb}
m := &update.Manifest{Version: "v1.0.0"}
got := profileForManifest(base, m)
if got.Mode != installModeManual || got.CanSelfUpdate {
@@ -992,22 +880,6 @@ func TestProfileForManifestDebWithoutHelperBecomesManual(t *testing.T) {
}
}
func TestCheckSHA256(t *testing.T) {
data := []byte("hello world")
// echo -n "hello world" | shasum -a 256
const sum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if err := checkSHA256(data, sum); err != nil {
t.Errorf("matching digest should pass: %v", err)
}
if err := checkSHA256(data, "deadbeef"); err == nil {
t.Error("mismatched digest should fail")
}
// Case-insensitive hex.
if err := checkSHA256(data, "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"); err != nil {
t.Errorf("uppercase digest should pass: %v", err)
}
}
func TestExtractBinary(t *testing.T) {
want := []byte("#!/bin/sh\necho reasonix\n")
var buf bytes.Buffer
@@ -1037,129 +909,6 @@ func TestExtractBinary(t *testing.T) {
}
}
func TestExtractLinuxReleaseUnitRejectsAmbiguousMembers(t *testing.T) {
makeArchive := func(t *testing.T, headers []tar.Header, bodies [][]byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for i, header := range headers {
if err := tw.WriteHeader(&header); err != nil {
t.Fatal(err)
}
if i < len(bodies) {
if _, err := tw.Write(bodies[i]); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
base := func(name string, body []byte) tar.Header {
return tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}
}
headers := []tar.Header{
base("reasonix-desktop", []byte("desktop")),
base("reasonix-guard", []byte("guard")),
base("reasonix", []byte("cli")),
}
bodies := [][]byte{[]byte("desktop"), []byte("guard"), []byte("cli")}
if got, err := extractLinuxReleaseUnit(makeArchive(t, headers, bodies)); err != nil ||
string(got["reasonix-desktop"]) != "desktop" {
t.Fatalf("complete release extraction = %v, %q", err, got["reasonix-desktop"])
}
duplicateHeaders := append(append([]tar.Header(nil), headers...), base("nested/reasonix", []byte("duplicate")))
duplicateBodies := append(append([][]byte(nil), bodies...), []byte("duplicate"))
if _, err := extractLinuxReleaseUnit(makeArchive(t, duplicateHeaders, duplicateBodies)); err == nil ||
!strings.Contains(err.Error(), "appears more than once") {
t.Fatalf("duplicate release member error = %v", err)
}
nonRegular := append([]tar.Header(nil), headers...)
nonRegular[1] = tar.Header{Name: "reasonix-guard", Typeflag: tar.TypeSymlink, Linkname: "outside"}
nonRegularBodies := [][]byte{bodies[0], nil, bodies[2]}
if _, err := extractLinuxReleaseUnit(makeArchive(t, nonRegular, nonRegularBodies)); err == nil ||
!strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("non-regular release member error = %v", err)
}
}
func TestApplyLinuxVersionedActivatesWithoutPersistingGuard(t *testing.T) {
root := robustTempDir(t)
source := robustTempDir(t)
for _, name := range []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()} {
if err := os.WriteFile(filepath.Join(source, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: "v1.20.0",
RequestID: "seed-linux",
Members: []installlayout.Member{
{Name: installlayout.DesktopBinaryName(), Path: filepath.Join(source, installlayout.DesktopBinaryName())},
{Name: installlayout.CLIBinaryName(), Path: filepath.Join(source, installlayout.CLIBinaryName())},
},
RequiredNames: []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()},
}); err != nil {
t.Fatal(err)
}
originalRoot := currentInstallDirForLinuxUpdate
currentInstallDirForLinuxUpdate = func() string { return root }
t.Cleanup(func() { currentInstallDirForLinuxUpdate = originalRoot })
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
tw := tar.NewWriter(gz)
for _, name := range []string{"reasonix-desktop", "reasonix-guard", "reasonix"} {
body := []byte("new-" + name)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := applyLinuxVersioned(archive.Bytes(), "1.20.1"); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(activeDesktop)
if err != nil || string(data) != "new-reasonix-desktop" {
t.Fatalf("active desktop=%q err=%v", data, err)
}
for _, guardPath := range []string{
filepath.Join(root, "reasonix-guard"),
filepath.Join(root, "versions", "v1.20.1", "reasonix-guard"),
} {
if _, err := os.Lstat(guardPath); !os.IsNotExist(err) {
t.Fatalf("Guard persisted at %s: %v", guardPath, err)
}
}
}
func TestApplyLinuxHoldsReleaseUnitLockDuringReplace(t *testing.T) {
dir := robustTempDir(t)
t.Setenv("REASONIX_HOME", robustTempDir(t))
@@ -1246,324 +995,6 @@ func TestApplyLinuxHoldsReleaseUnitLockDuringReplace(t *testing.T) {
}
}
func fastRetry(t *testing.T) {
t.Helper()
restore := retryBackoff
retryBackoff = func(int) time.Duration { return time.Millisecond }
t.Cleanup(func() { retryBackoff = restore })
}
func TestDownloadRecoversFromMidStreamReset(t *testing.T) {
fastRetry(t)
const body = "complete-installer-bytes"
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < int32(downloadAttempts) {
// Mid-stream reset: promise 100 bytes, send a few, drop the socket —
// the client's body read fails with unexpected EOF, exactly the CN-IPv6
// "forcibly closed" case the retry exists for.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
bw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial")
bw.Flush()
conn.Close()
return
}
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
if err != nil {
t.Fatalf("download should recover after %d resets: %v", downloadAttempts-1, err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if n := calls.Load(); n != int32(downloadAttempts) {
t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
}
}
func TestDownloadGivesUpAfterCap(t *testing.T) {
fastRetry(t)
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
conn.Close()
}))
defer srv.Close()
if _, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil); err == nil {
t.Fatal("download should fail after exhausting retries")
}
if n := calls.Load(); n != int32(downloadAttempts) {
t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
}
}
func TestRetryTransientStopsWhenCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
if err := retryTransient(ctx, func(int) error {
calls++
return errors.New("boom")
}); err == nil {
t.Fatal("cancelled retry should return the error")
}
if calls != 1 {
t.Fatalf("cancelled retry made %d calls, want 1", calls)
}
}
func TestDownloadResumesWithRange(t *testing.T) {
fastRetry(t)
full := bytes.Repeat([]byte("0123456789"), 50) // 500 bytes
const cut = 200
var calls atomic.Int32
rangeCh := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
// First attempt: promise the whole file, send a prefix, drop the socket.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
fmt.Fprintf(bw, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(full))
bw.Write(full[:cut])
bw.Flush()
conn.Close()
return
}
// Resume attempt: honor the Range header with a 206 + Content-Range.
rng := r.Header.Get("Range")
rangeCh <- rng
start := 0
fmt.Sscanf(rng, "bytes=%d-", &start)
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(full)-1, len(full)))
w.WriteHeader(http.StatusPartialContent)
w.Write(full[start:])
}))
defer srv.Close()
data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if !bytes.Equal(data, full) {
t.Fatalf("assembled %d bytes, want %d (equal=%v)", len(data), len(full), bytes.Equal(data, full))
}
select {
case rng := <-rangeCh:
if rng != fmt.Sprintf("bytes=%d-", cut) {
t.Fatalf("resume Range = %q, want bytes=%d-", rng, cut)
}
default:
t.Fatal("resume attempt sent no Range header")
}
}
func TestDownloadFallsBackToSecondClient(t *testing.T) {
fastRetry(t)
const body = "served-over-ipv4"
primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection reset (ipv6)")
})}
var fbCalls atomic.Int32
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
fbCalls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
data, err := download(context.Background(), primary, fallback, "http://example.invalid/x", 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if fbCalls.Load() == 0 {
t.Fatal("fallback client was never used after the primary failed")
}
}
func TestDownloadRejectsBodyShorterThanManifestSize(t *testing.T) {
fastRetry(t)
body := []byte("short")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
if _, err := download(context.Background(), srv.Client(), nil, srv.URL, int64(len(body)+1), nil); err == nil {
t.Fatal("download accepted fewer bytes than the manifest declared")
}
}
func TestDownloadRejectsBodyLongerThanManifestSize(t *testing.T) {
fastRetry(t)
body := bytes.Repeat([]byte("x"), 64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
if _, err := download(context.Background(), srv.Client(), nil, srv.URL, 8, nil); err == nil {
t.Fatal("download accepted more bytes than the manifest declared")
}
}
func TestFetchBytesFallsBackToSecondClient(t *testing.T) {
fastRetry(t)
primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("read tcp [ipv6]: connection reset")
})}
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader("manifest")),
Header: make(http.Header),
}, nil
})}
data, err := fetchBytesFallback(context.Background(), primary, fallback, "https://example.invalid/latest.json")
if err != nil {
t.Fatalf("fetchBytesFallback: %v", err)
}
if string(data) != "manifest" {
t.Fatalf("got %q, want manifest", data)
}
}
func TestFetchBytesFallbackEscapesStalledPrimary(t *testing.T) {
fastRetry(t)
originalTimeout := fetchAttemptTimeout
fetchAttemptTimeout = 10 * time.Millisecond
t.Cleanup(func() { fetchAttemptTimeout = originalTimeout })
primary := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
})}
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader("ipv4")),
Header: make(http.Header),
}, nil
})}
data, err := fetchBytesFallback(context.Background(), primary, fallback, "https://example.invalid/latest.json")
if err != nil {
t.Fatalf("fetchBytesFallback: %v", err)
}
if string(data) != "ipv4" {
t.Fatalf("got %q, want ipv4", data)
}
}
func TestFetchBytesDoesNotRetryPermanentHTTPStatus(t *testing.T) {
fastRetry(t)
var calls atomic.Int32
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusForbidden,
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader("forbidden")),
Header: make(http.Header),
}, nil
})}
if _, err := fetchBytes(context.Background(), client, "https://example.invalid/latest.json"); err == nil {
t.Fatal("fetchBytes should return a permanent HTTP error")
}
if got := calls.Load(); got != 1 {
t.Fatalf("permanent HTTP error made %d requests, want 1", got)
}
}
func TestFetchBytesRejectsOversizeResponsesWithoutRetry(t *testing.T) {
fastRetry(t)
t.Run("declared content length", func(t *testing.T) {
var calls atomic.Int32
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: 9,
Body: io.NopCloser(strings.NewReader("ignored")),
Header: make(http.Header),
}, nil
})}
if _, err := fetchBytesFallbackForChannelSized(
context.Background(),
client,
nil,
"stable",
"https://example.invalid/latest.json",
8,
); !errors.Is(err, errUpdateResponseTooLarge) {
t.Fatalf("declared oversize error = %v, want errUpdateResponseTooLarge", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("declared oversize response made %d requests, want 1", got)
}
})
t.Run("chunked body", func(t *testing.T) {
client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: -1,
Body: io.NopCloser(strings.NewReader("123456789")),
Header: make(http.Header),
}, nil
})}
if _, err := fetchBytesFallbackForChannelSized(
context.Background(),
client,
nil,
"stable",
"https://example.invalid/latest.json",
8,
); !errors.Is(err, errUpdateResponseTooLarge) {
t.Fatalf("chunked oversize error = %v, want errUpdateResponseTooLarge", err)
}
})
}
func TestDownloadRejectsAssetSizeAboveMaximum(t *testing.T) {
if _, err := download(
context.Background(),
&http.Client{},
nil,
"https://dl.reasonix.io/file",
maxDesktopReleaseAssetSize+1,
nil,
); err == nil {
t.Fatal("download accepted an asset size above the release maximum")
}
}
type rtFunc func(*http.Request) (*http.Response, error)
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
-96
View File
@@ -6,74 +6,6 @@ import (
"testing"
)
func TestInstallerCommandLineUsesVisibleUpdateModeAndKeepsDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) {
got := windowsUpdateHandoffArgs(
4242,
`C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
strings.Repeat("a", 64),
`D:\Tools\Reasonix App`,
`D:\Tools\Reasonix App\reasonix-desktop.exe`,
"v1.6.0",
"2026-07-29T00:00:00Z",
"transaction-1",
)
want := []string{
"--parent-pid", "4242",
"--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
"--installer-sha256", strings.Repeat("a", 64),
"--to-version", "v1.6.0",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
"--install-dir", `D:\Tools\Reasonix App`,
"--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
func TestWindowsVersionedUpdateHandoffArgsDoNotRequireLegacyPendingIdentity(t *testing.T) {
got := windowsVersionedUpdateHandoffArgs(
4242,
`C:\Temp\Reasonix-installer.exe`,
strings.Repeat("b", 64),
`D:\Tools\Reasonix`,
`D:\Tools\Reasonix\reasonix-launcher.exe`,
"v1.20.0",
)
want := []string{
"--parent-pid", "4242",
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", strings.Repeat("b", 64),
"--to-version", "v1.20.0",
"--install-layout", "versioned-v1",
"--install-dir", `D:\Tools\Reasonix`,
"--relaunch", `D:\Tools\Reasonix\reasonix-launcher.exe`,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("args = %#v, want %#v", got, want)
}
for _, legacy := range []string{"--created-at", "--transaction-id"} {
if strings.Contains(strings.Join(got, " "), legacy) {
t.Fatalf("versioned handoff must not carry legacy field %s", legacy)
}
}
}
func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
data, err := os.ReadFile("build/windows/installer/project.nsi")
if err != nil {
@@ -236,31 +168,3 @@ func TestDesktopBuildScriptCompilesAndPackagesWindowsUpdateHelper(t *testing.T)
}
}
}
func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) {
data, err := os.ReadFile("updater_windows.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
if !strings.Contains(source, "return startWindowsUpdateHelper(") {
t.Fatal("Windows update handoff does not require the observed helper path")
}
if strings.Contains(source, "return installerCommand(installerPath, installDir).Start()") {
t.Fatal("Windows update silently falls back to an unobserved installer")
}
if !strings.Contains(source, "cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}") {
t.Fatal("Windows handoff helper should stay hidden while NSIS shows update progress")
}
helperData, err := os.ReadFile("cmd/update-helper/main_windows.go")
if err != nil {
t.Fatal(err)
}
helperSource := string(helperData)
if !strings.Contains(helperSource, "reconcileWindowsUninstallRegistrationFn(installDir, toVersion)") {
t.Fatal("versioned Windows activation must refresh its managed uninstall registration")
}
if strings.Contains(helperSource, "installerCommandLine(installer, installDir), HideWindow: true") {
t.Fatal("update helper still hides the NSIS progress window")
}
}
+22
View File
@@ -0,0 +1,22 @@
package config
import "strings"
// A rollback pins the desktop to the release the user chose. The pin lives in
// config rather than in updater state so it is visible, editable, and survives
// a reinstall — the point of leaving a bad build is not being put back on it.
// DesktopPinnedVersion returns the pinned release, or "" to follow the channel.
func (c *Config) DesktopPinnedVersion() string {
if c == nil {
return ""
}
return strings.TrimSpace(c.Desktop.PinnedVersion)
}
// SetDesktopPinnedVersion pins the desktop to a release, or releases the pin
// when version is empty. Rolling back sets it; only the user clears it.
func (c *Config) SetDesktopPinnedVersion(version string) error {
c.Desktop.PinnedVersion = strings.TrimSpace(version)
return nil
}
+23
View File
@@ -1620,4 +1620,27 @@ grep -Fq 'reasonix/internal/productdocs.linkedVersion=${binaryVersion}' "$repo_r
grep -Fq 'product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION' \
"$repo_root/scripts/desktop-build.sh"
# The rollback index is what lets a user leave a bad version. It must be written
# from the protected control plane, must survive a rerun without rewriting
# history, and must never let a corrupt object reset the list to empty.
grep -Fq 'release-control/scripts/update-versions-index.sh' "$repo_root/.github/workflows/release-desktop.yml"
index_script="$repo_root/scripts/update-versions-index.sh"
first="$test_root/versions-first.json"
bash "$index_script" - 1.25.1 desktop-v1.25.1 stable 2026-08-12T10:00:00Z >"$first"
[ "$(jq -r '.versions[0].manifest' "$first")" = "https://dl.reasonix.io/desktop-v1.25.1/latest.json" ]
# A rerun (or a recovery publication) must not rewrite the published entry.
rerun="$test_root/versions-rerun.json"
bash "$index_script" "$first" 1.25.1 desktop-v1.25.1-RECOVERY stable 2026-09-09T00:00:00Z >"$rerun"
[ "$(jq -r '.versions[0].tag' "$rerun")" = "desktop-v1.25.1" ]
# Newest first, across a major bump.
ordered="$test_root/versions-ordered.json"
bash "$index_script" "$first" 2.0.0 studio-v2.0.0 stable 2026-09-01T00:00:00Z >"$ordered"
[ "$(jq -r '[.versions[].version] | join(",")' "$ordered")" = "2.0.0,1.25.1" ]
# A damaged index fails the release instead of silently starting over.
printf '%s' '{"broken":true}' >"$test_root/versions-bad.json"
if bash "$index_script" "$test_root/versions-bad.json" 1.0.0 t stable 2026-01-01T00:00:00Z >/dev/null 2>&1; then
echo "a corrupt versions index must not be accepted" >&2
exit 1
fi
echo "release workflow contract tests: PASS"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Merge one published release into the rollback index and print the result.
#
# The index is what lets a user go back: it lists which versions were ever
# public and where each one's immutable manifest lives. It deliberately does NOT
# copy asset URLs or signatures — an entry points at <tag>/latest.json, so a
# rollback download verifies through exactly the same path as a forward update
# and there is never a second source of truth for what an artifact should be.
#
# Merging is additive: an existing entry for the same version wins, so a rerun
# or a recovery publication can never rewrite history. Usage:
#
# update-versions-index.sh <existing-index|-> <version> <tag> <channel> <published-at> [keep]
set -euo pipefail
if [ "$#" -lt 5 ] || [ "$#" -gt 6 ]; then
echo "usage: $0 <existing-index|-> <version> <tag> <channel> <published-at> [keep]" >&2
exit 2
fi
existing="$1"
version="$2"
tag="$3"
channel="$4"
published_at="$5"
keep="${6:-20}"
for value in "$version" "$tag" "$channel" "$published_at"; do
if [ -z "$value" ]; then
echo "::error::versions index requires version, tag, channel and published-at" >&2
exit 1
fi
done
case "$keep" in
''|*[!0-9]*)
echo "::error::keep must be a positive integer, got $keep" >&2
exit 1
;;
esac
if [ "$keep" -lt 1 ]; then
echo "::error::keep must be at least 1" >&2
exit 1
fi
base="{\"schemaVersion\":1,\"versions\":[]}"
if [ "$existing" != "-" ]; then
if [ ! -f "$existing" ]; then
echo "::error::existing index $existing not found" >&2
exit 1
fi
# A corrupt index must fail the release rather than silently reset the list:
# losing history is exactly the failure this file exists to prevent.
if ! jq -e '.versions | type == "array"' "$existing" >/dev/null; then
echo "::error::existing index $existing is not a versions index" >&2
exit 1
fi
base="$(cat "$existing")"
fi
printf '%s' "$base" | jq \
--arg version "$version" \
--arg tag "$tag" \
--arg channel "$channel" \
--arg publishedAt "$published_at" \
--argjson keep "$keep" '
def semver: [splits("[.-]")] | map(select(test("^[0-9]+$")) | tonumber);
{
schemaVersion: 1,
updatedAt: $publishedAt,
versions: (
(.versions // [])
+ [{
version: $version,
tag: $tag,
channel: $channel,
publishedAt: $publishedAt,
manifest: ("https://dl.reasonix.io/" + $tag + "/latest.json"),
}]
# Existing entries win: unique_by keeps the first of each group, so a
# rerun cannot rewrite what was already published under that version.
| unique_by(.version)
| sort_by(.version | semver)
| reverse
| .[0:$keep]
),
}
'