refactor(update): move the updater to the kernel, and wire the guard it found

The Electron shell's host binary is built from the root module
(cmd/reasonix-studio-host), and Go's internal rule is about import paths,
not modules: nothing outside reasonix/desktop/ can ever import
reasonix/desktop/internal/update. So while the updater lived there, the
shell that is meant to replace Wails could not reach it at all -- not as a
thing to be written later, but as a thing the language forbids. Moving it
is a precondition for the crossing rather than tidying.

It moves whole because it was already independent: 44 files, zero imports
from the desktop module, and the only things it reaches for -- config,
fileutil, installlayout, repair -- are kernel packages it was already
importing across the boundary. The five desktop commands that use it keep
working, since reasonix/desktop/... may import reasonix/internal/...

The move surfaced TrustedRedirect as an orphan, and it is not dead code:
it is the CheckRedirect an update client must use, written with its own
comment saying a redirect could otherwise walk an authenticated download
onto another host, and never referenced. The shells assemble their client
from netclient, which sets no redirect policy, so every catalog read and
artifact download has been following redirects wherever they pointed.
Minisign verification is what stood between that and an installed
artifact, which is why this is a missing layer rather than an open door.

New now guards copies of the clients it is handed -- copies, because the
shells share one client with the rest of the app, and a caller that
already chose a policy keeps it. Recording that orphan in the baseline
instead would have filed an unwired security guard as debt, which is why
the wiring is in this commit rather than after it.

The baseline carries forward under -allow-widen: the repository totals
only tighten, and what enters internal/update is exactly what left
desktop/internal/update -- complexity 42, essay 3, function-size 180.

TrustedRedirect had no test either. It has one per field it reads, because
a guard checked only through one bad hostname would still pass after it
stopped reading the scheme. Checked against the live catalog too: eleven
published versions still resolve with the guard installed.
This commit is contained in:
esengine committed 2026-08-31 08:42:11 -07:00
1 parent c6c139312b
commit 71c7af07da
62 files changed
+145 -34

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ import (
"aead.dev/minisign"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// platforms are the manifest keys we publish. A built artifact is matched to a key
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"aead.dev/minisign"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// TestSignFiles signs a file with a throwaway key pair (injected via env, exactly
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"path/filepath"
"strings"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
const artifactPrefix = "ReasonixStudio-"
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"testing"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"strings"
"syscall"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// Stable exit codes observed by the desktop installer.
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"sort"
"strings"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
"reasonix/internal/update"
)
const maxWindowsPayloadMetadataSize = 64 << 10
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
"reasonix/internal/update"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/netclient"
"reasonix/internal/update"
)
// updateEventName carries install progress to the version panel. It is separate
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"context"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// applyDownloaded installs what was downloaded, by staging it into the
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"context"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// applyDownloaded installs what was downloaded. Windows publishes an installer,
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
// studioLine is Studio's identity to the shared update path. Every system path
+1 -1
View File
@@ -30,7 +30,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/crashreport"
@@ -41,6 +40,7 @@ import (
"reasonix/internal/serve"
"reasonix/internal/surface"
"reasonix/internal/traystate"
"reasonix/internal/update"
// Kinds register from init, so a binary builds only what it links. Without
// these the shell answers every Anthropic model with "unknown kind" at
+1 -1
View File
@@ -6,9 +6,9 @@ import (
"strings"
"time"
"reasonix/desktop/internal/update"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/update"
)
// version is stamped by the build (-X main.version=…); an unstamped dev build
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"testing"
"reasonix/desktop/internal/update"
"reasonix/internal/update"
)
func rowVersions(rows []VersionEntry) []string {
+1
View File
@@ -5,6 +5,7 @@ go 1.26.0
toolchain go1.26.6
require (
aead.dev/minisign v0.3.0
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
+2
View File
@@ -1,3 +1,5 @@
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -89,7 +89,9 @@ func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) {
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")
// The helper is still built by Studio's release line and so still lives in
// the desktop module; this contract spans both sides of that line.
helperData, err := os.ReadFile("../../desktop/cmd/update-helper/main_windows.go")
if err != nil {
t.Fatal(err)
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+90
View File
@@ -0,0 +1,90 @@
package update
import (
"errors"
"net/http"
"testing"
)
func redirectTo(t *testing.T, raw string, hops int) error {
t.Helper()
req, err := http.NewRequest(http.MethodGet, raw, nil)
if err != nil {
t.Fatalf("NewRequest(%q): %v", raw, err)
}
return TrustedRedirect(req, make([]*http.Request, hops))
}
// Every field this guard reads is one an attacker controls once a release host
// answers with a redirect, so each is stated as its own case rather than as one
// "bad URL" — a guard that stopped checking the scheme would still pass a test
// that only ever handed it another hostname.
func TestTrustedRedirectRefusesWhatItWasWrittenFor(t *testing.T) {
for _, tc := range []struct{ name, url string }{
{"a plain-HTTP downgrade", "http://dl.reasonix.io/studio/x.tar.gz"},
{"credentials smuggled in the authority", "https://user:[email protected]/x"},
{"a port, which no release host answers on", "https://dl.reasonix.io:8443/x"},
{"a host that only looks like ours", "https://dl.reasonix.io.evil.test/x"},
{"an unrelated host outright", "https://evil.test/x"},
} {
t.Run(tc.name, func(t *testing.T) {
if err := redirectTo(t, tc.url, 1); err == nil {
t.Fatalf("TrustedRedirect(%q) = nil, want a refusal", tc.url)
}
})
}
if err := redirectTo(t, "https://dl.reasonix.io/x", 10); err == nil {
t.Fatal("a chain of ten hops was followed; want it stopped")
}
}
// The hosts releases are actually served from. A guard that refused these would
// break every update rather than protect one.
func TestTrustedRedirectPassesTheReleaseHosts(t *testing.T) {
for _, raw := range []string{
"https://dl.reasonix.io/studio/versions.json",
"https://reasonix.io/studio/x",
"https://github.com/esengine/DeepSeek-Reasonix/releases/download/v1/x",
"https://objects.githubusercontent.com/blob/x",
} {
if err := redirectTo(t, raw, 1); err != nil {
t.Fatalf("TrustedRedirect(%q) = %v, want it followed", raw, err)
}
}
}
// The guard sat unreferenced while the downloads it was written for followed
// redirects wherever they pointed: the shells assemble their client from
// netclient, which sets no policy at all. New is where that is closed, and it
// copies because that client is shared with the rest of the app.
func TestNewGuardsTheClientsItWasHanded(t *testing.T) {
caller := &http.Client{}
fallback := &http.Client{}
u := New(Options{HTTP: caller, Fallback: fallback})
if u.opts.HTTP.CheckRedirect == nil || u.opts.Fallback.CheckRedirect == nil {
t.Fatal("an updater fetched release bytes with no redirect policy")
}
if caller.CheckRedirect != nil || fallback.CheckRedirect != nil {
t.Fatal("the caller's own client was mutated; it is shared with the rest of the app")
}
if u.opts.HTTP == caller || u.opts.Fallback == fallback {
t.Fatal("the guarded client is the caller's, not a copy of it")
}
}
func TestNewKeepsAPolicyTheCallerAlreadyChose(t *testing.T) {
chosen := func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
u := New(Options{HTTP: &http.Client{CheckRedirect: chosen}})
if err := u.opts.HTTP.CheckRedirect(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatal("New replaced a redirect policy its caller had already decided")
}
}
// A nil client is how the version panel is built before a proxy is resolved;
// guarding must not turn that into a client that exists.
func TestNewLeavesAnAbsentClientAbsent(t *testing.T) {
if u := New(Options{}); u.opts.HTTP != nil || u.opts.Fallback != nil {
t.Fatal("guarding invented a client out of nil")
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -44,8 +44,24 @@ type Updater struct {
opts Options
}
// New returns an Updater for the running build.
func New(opts Options) *Updater { return &Updater{opts: opts} }
// New returns an Updater for the running build, with TrustedRedirect on copies
// of the caller's clients: everything fetched here is release bytes, and a
// client assembled from netclient carries no redirect policy of its own.
func New(opts Options) *Updater {
opts.HTTP, opts.Fallback = guarded(opts.HTTP), guarded(opts.Fallback)
return &Updater{opts: opts}
}
// guarded copies rather than mutates: the shells hand in a client the rest of
// the app shares. A caller that already chose a policy keeps it.
func guarded(c *http.Client) *http.Client {
if c == nil || c.CheckRedirect != nil {
return c
}
copyOfClient := *c
copyOfClient.CheckRedirect = TrustedRedirect
return &copyOfClient
}
// 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
File renamed without changes.
File renamed without changes.
+17 -17
View File
@@ -5,14 +5,14 @@
"commented-code": 0,
"complexity": 1513,
"error-text": 12,
"essay": 3245,
"file-size": 41718,
"essay": 3243,
"file-size": 41714,
"frontend-parity": 150,
"function-size": 6596,
"layering": 0,
"marker": 0,
"narrative": 57,
"orphan": 124,
"orphan": 123,
"refusal-path": 0,
"spec-parity": 0,
"struct-state": 41,
@@ -45,17 +45,6 @@
"desktop/cmd/update-helper/versioned_windows.go": {
"essay": 10
},
"desktop/internal/update/apply_darwin.go": {
"complexity": 42,
"essay": 1,
"function-size": 180
},
"desktop/internal/update/install_windows.go": {
"essay": 1
},
"desktop/internal/update/manifest.go": {
"essay": 1
},
"desktop/internal/winuninstall/registry_windows.go": {
"essay": 1
},
@@ -229,7 +218,7 @@
"orphan": 1
},
"internal/agent/run_loop.go": {
"essay": 23,
"essay": 21,
"function-size": 2
},
"internal/agent/run_usage.go": {
@@ -698,7 +687,7 @@
},
"internal/config/provider_presets.go": {
"claude-dialect": 1,
"file-size": 267,
"file-size": 263,
"orphan": 1
},
"internal/config/render.go": {
@@ -1402,7 +1391,7 @@
"file-size": 2901,
"function-size": 308,
"narrative": 2,
"orphan": 14
"orphan": 13
},
"internal/repair/update_handoff_darwin_smoke_test.go": {
"essay": 4
@@ -1623,6 +1612,17 @@
"internal/tool/tool.go": {
"essay": 15
},
"internal/update/apply_darwin.go": {
"complexity": 42,
"essay": 1,
"function-size": 180
},
"internal/update/install_windows.go": {
"essay": 1
},
"internal/update/manifest.go": {
"essay": 1
},
"internal/workspaceid/workspaceid.go": {
"orphan": 1
},