mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
c6c139312b
commit
71c7af07da
62 files changed
+145
-34
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
const artifactPrefix = "ReasonixStudio-"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
// Stable exit codes observed by the desktop installer.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
// applyDownloaded installs what was downloaded. Windows publishes an installer,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/update"
|
||||
)
|
||||
|
||||
func rowVersions(rows []VersionEntry) []string {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
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 ©OfClient
|
||||
}
|
||||
|
||||
// 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.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user