返回 DeepSeek-Reasonix
updater_test.go
根目录 / desktop / updater_test.go
1 package main
2
3 import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/http/httptest"
16 "os"
17 "path/filepath"
18 "reflect"
19 "runtime"
20 "strings"
21 "sync/atomic"
22 "testing"
23 "time"
24
25 "reasonix/desktop/internal/update"
26 "reasonix/internal/installlayout"
27 "reasonix/internal/repair"
28 )
29
30 func TestNormalizeVersion(t *testing.T) {
31 cases := []struct {
32 in string
33 want string
34 ok bool
35 }{
36 {"dev", "", false},
37 {"", "", false},
38 {" ", "", false},
39 {"1.2.3", "v1.2.3", true},
40 {"v1.2.3", "v1.2.3", true},
41 {"v1.2", "v1.2.0", true}, // semver.Canonical fills the patch
42 {"garbage", "", false},
43 }
44 for _, c := range cases {
45 got, ok := normalizeVersion(c.in)
46 if got != c.want || ok != c.ok {
47 t.Errorf("normalizeVersion(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.ok)
48 }
49 }
50 }
51
52 func TestValidateUpdaterRequestBindsChannelVersionAndID(t *testing.T) {
53 tests := []struct {
54 name string
55 request string
56 channel string
57 version string
58 wantErr bool
59 }{
60 {name: "stable", request: "web-stable-1", channel: "stable", version: "v1.18.0"},
61 {name: "legacy preview selects official", request: "web-preview-1", channel: "preview", version: "v1.18.0"},
62 {name: "legacy preview rejects prerelease", request: "web-preview-2", channel: "preview", version: "v1.18.0-preview.1", wantErr: true},
63 {name: "stable rejects preview version", request: "web-stable-2", channel: "stable", version: "v1.18.0-preview.1", wantErr: true},
64 {name: "empty request", channel: "stable", version: "v1.18.0", wantErr: true},
65 {name: "unsafe request", request: "web request", channel: "stable", version: "v1.18.0", wantErr: true},
66 }
67 for _, tt := range tests {
68 t.Run(tt.name, func(t *testing.T) {
69 request, selected, version, err := validateUpdaterRequest(tt.request, tt.channel, tt.version)
70 if (err != nil) != tt.wantErr {
71 t.Fatalf("validateUpdaterRequest() error = %v, wantErr=%v", err, tt.wantErr)
72 }
73 if tt.wantErr {
74 return
75 }
76 if request != tt.request || selected != "stable" || version != tt.version {
77 t.Fatalf("validateUpdaterRequest() = (%q, %q, %q)", request, selected, version)
78 }
79 })
80 }
81 }
82
83 func TestValidateAssetInstallLayout(t *testing.T) {
84 if err := validateAssetInstallLayout(""); err != nil {
85 t.Fatalf("empty layout must remain accepted for legacy assets: %v", err)
86 }
87 if err := validateAssetInstallLayout("versioned-v1"); err != nil {
88 t.Fatalf("versioned-v1 must be accepted: %v", err)
89 }
90 if err := validateAssetInstallLayout(update.ElectronInstallLayout); err != nil {
91 t.Fatal(err)
92 }
93 if err := validateAssetInstallLayout("unknown-layout"); err == nil {
94 t.Fatal("unknown install_layout must be rejected")
95 }
96 }
97
98 func TestUpdaterWailsMethodContracts(t *testing.T) {
99 appType := reflect.TypeFor[*App]()
100 tests := []struct {
101 name string
102 numIn int
103 numOut int
104 }{
105 {name: "ApplyUpdateRequest", numIn: 4, numOut: 1},
106 {name: "CheckUpdate", numIn: 2, numOut: 2},
107 {name: "OpenDownloadPage", numIn: 1, numOut: 0},
108 }
109 // Legacy Download/Install split bindings must stay deleted (v1.20+).
110 for _, removed := range []string{"DownloadUpdate", "InstallUpdate", "DownloadUpdateRequest", "InstallUpdateRequest", "ApplyUpdate"} {
111 if _, ok := appType.MethodByName(removed); ok {
112 t.Fatalf("App.%s must be removed from the Wails surface", removed)
113 }
114 }
115 for _, tt := range tests {
116 method, ok := appType.MethodByName(tt.name)
117 if !ok {
118 t.Fatalf("App.%s is missing", tt.name)
119 }
120 if method.Type.NumIn() != tt.numIn || method.Type.NumOut() != tt.numOut {
121 t.Fatalf(
122 "App.%s signature = %v inputs/%v outputs, want %v/%v",
123 tt.name,
124 method.Type.NumIn(),
125 method.Type.NumOut(),
126 tt.numIn,
127 tt.numOut,
128 )
129 }
130 }
131 }
132
133 func TestUpdaterNativeOperationsFailFastWhileBusy(t *testing.T) {
134 app := NewApp()
135 finishFirst, err := app.beginUpdaterOperation("first")
136 if err != nil {
137 t.Fatal(err)
138 }
139 if _, err := app.beginUpdaterOperation("second"); !errors.Is(err, errUpdateInProgress) {
140 t.Fatalf("second updater operation error = %v, want errUpdateInProgress", err)
141 }
142 finishFirst()
143 finishSecond, err := app.beginUpdaterOperation("second")
144 if err != nil {
145 t.Fatalf("operation did not become available after release: %v", err)
146 }
147 finishSecond()
148 }
149
150 func TestUpdaterReconcilesPendingUpdateBeforeInstallModeDispatch(t *testing.T) {
151 originalExists := pendingUpdateExistsForInstall
152 originalArchive := archiveSupersededPendingUpdateForInstall
153 originalReconcile := reconcilePendingUpdateForInstall
154 t.Cleanup(func() {
155 pendingUpdateExistsForInstall = originalExists
156 archiveSupersededPendingUpdateForInstall = originalArchive
157 reconcilePendingUpdateForInstall = originalReconcile
158 })
159
160 called := false
161 pendingUpdateExistsForInstall = func() bool { return true }
162 archiveSupersededPendingUpdateForInstall = func() (bool, error) { return false, nil }
163 reconcilePendingUpdateForInstall = func(runningVersion string) (repair.PendingUpdateReconcileResult, error) {
164 called = true
165 if runningVersion != version {
166 t.Fatalf("running version = %q, want %q", runningVersion, version)
167 }
168 return repair.PendingUpdateReconcileResult{Pending: true, Cleared: true}, nil
169 }
170 meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
171 if err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta); err != nil {
172 t.Fatal(err)
173 }
174 if !called {
175 t.Fatal("pending update reconciliation was skipped")
176 }
177 }
178
179 func TestUpdaterArchivesSupersededUpdateBeforeReconciliation(t *testing.T) {
180 originalExists := pendingUpdateExistsForInstall
181 originalArchive := archiveSupersededPendingUpdateForInstall
182 originalReconcile := reconcilePendingUpdateForInstall
183 t.Cleanup(func() {
184 pendingUpdateExistsForInstall = originalExists
185 archiveSupersededPendingUpdateForInstall = originalArchive
186 reconcilePendingUpdateForInstall = originalReconcile
187 })
188
189 archived := false
190 pendingUpdateExistsForInstall = func() bool { return true }
191 archiveSupersededPendingUpdateForInstall = func() (bool, error) {
192 archived = true
193 return true, nil
194 }
195 reconcilePendingUpdateForInstall = func(string) (repair.PendingUpdateReconcileResult, error) {
196 if !archived {
197 t.Fatal("reconciliation ran before superseded update archival")
198 }
199 return repair.PendingUpdateReconcileResult{}, nil
200 }
201 meta := &cachedUpdate{Channel: "stable", Version: "v1.20.0", Size: 42}
202 if err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta); err != nil {
203 t.Fatal(err)
204 }
205 }
206
207 func TestUpdaterReconcilesBeforeDownloading(t *testing.T) {
208 setDesktopBuildIdentityForTest(t, "v1.20.0", "stable")
209 originalExists := pendingUpdateExistsForInstall
210 originalArchive := archiveSupersededPendingUpdateForInstall
211 originalReconcile := reconcilePendingUpdateForInstall
212 t.Cleanup(func() {
213 pendingUpdateExistsForInstall = originalExists
214 archiveSupersededPendingUpdateForInstall = originalArchive
215 reconcilePendingUpdateForInstall = originalReconcile
216 })
217 pendingUpdateExistsForInstall = func() bool { return true }
218 archiveSupersededPendingUpdateForInstall = func() (bool, error) { return false, nil }
219 reconcilePendingUpdateForInstall = func(string) (repair.PendingUpdateReconcileResult, error) {
220 return repair.PendingUpdateReconcileResult{Pending: true}, errors.New("blocked before download")
221 }
222 err := (&App{}).ApplyUpdateRequest("stable", "v1.20.0", "preflight-recovery")
223 if err == nil || !strings.Contains(err.Error(), "blocked before download") {
224 t.Fatalf("pre-download recovery error=%v", err)
225 }
226 }
227
228 func TestUpdaterBlocksInstallWhilePreviousReleaseAwaitsHealth(t *testing.T) {
229 originalExists := pendingUpdateExistsForInstall
230 originalArchive := archiveSupersededPendingUpdateForInstall
231 originalReconcile := reconcilePendingUpdateForInstall
232 t.Cleanup(func() {
233 pendingUpdateExistsForInstall = originalExists
234 archiveSupersededPendingUpdateForInstall = originalArchive
235 reconcilePendingUpdateForInstall = originalReconcile
236 })
237
238 pendingUpdateExistsForInstall = func() bool { return true }
239 archiveSupersededPendingUpdateForInstall = func() (bool, error) {
240 return false, errors.New("not a superseded flat-layout transaction")
241 }
242 reconcilePendingUpdateForInstall = func(string) (repair.PendingUpdateReconcileResult, error) {
243 return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
244 }
245 meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
246 err := (&App{}).reconcilePendingUpdateForRequest("install-1", meta)
247 if err == nil || !strings.Contains(err.Error(), "startup health check") {
248 t.Fatalf("health-check recovery error = %v", err)
249 }
250 }
251
252 func TestExpectedUpdateVersionRejectsAdvancedPointer(t *testing.T) {
253 if err := ensureExpectedUpdateVersion("preview", "v1.18.0-preview.1", "v1.18.0-preview.2"); err == nil {
254 t.Fatal("advanced pointer unexpectedly matched the checked version")
255 }
256 if err := ensureExpectedUpdateVersion("stable", "v1.18.0", "v1.18.0"); err != nil {
257 t.Fatalf("identical pointer rejected: %v", err)
258 }
259 }
260
261 func TestUpdateSiblingNamesCoverEveryReplacedEntryPoint(t *testing.T) {
262 windows := strings.Join(updateSiblingNames("windows"), "\x00")
263 for _, want := range []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "Reasonix.exe"} {
264 if !strings.Contains(windows, want) {
265 t.Errorf("Windows release unit omits %q: %q", want, windows)
266 }
267 }
268 if strings.Contains(windows, "reasonix.exe") {
269 t.Fatalf("Windows release unit reintroduces the case-only CLI/launcher collision: %q", windows)
270 }
271 if got := updateSiblingNames("linux"); len(got) != 2 || got[0] != "reasonix-guard" || got[1] != "reasonix" {
272 t.Fatalf("Linux release unit = %q", got)
273 }
274 if got := updateSiblingNames("darwin"); got != nil {
275 t.Fatalf("macOS app-bundle update must not list file siblings: %q", got)
276 }
277 }
278
279 func TestEvaluate(t *testing.T) {
280 mk := func(version string) *update.Manifest {
281 return &update.Manifest{
282 Version: version,
283 Notes: "notes",
284 Platforms: map[string]update.Asset{update.CurrentPlatform(): {Size: 999}},
285 }
286 }
287 portable := installProfile{
288 Mode: installModePortable,
289 CanSelfUpdate: runtime.GOOS != "darwin",
290 ArtifactKind: artifactKindTarball,
291 }
292 if runtime.GOOS == "darwin" {
293 portable.Mode = installModeManual
294 portable.ManualReason = manualUpdateReason()
295 }
296
297 if got := evaluateWithProfile("v1.0.0", mk("v1.1.0"), portable); !got.Available {
298 t.Error("v1.0.0 -> v1.1.0 should be available")
299 }
300 if got := evaluateWithProfile("v1.1.0", mk("v1.1.0"), portable); got.Available {
301 t.Error("same version should not be available")
302 }
303 if got := evaluateWithProfile("v1.2.0", mk("v1.1.0"), portable); got.Available {
304 t.Error("newer-than-manifest should not be available")
305 }
306 // A dev build must never auto-prompt, even against a real release.
307 if got := evaluateWithProfile("dev", mk("v1.1.0"), portable); got.Available {
308 t.Error("dev build should not prompt to update")
309 }
310 // An invalid manifest version is a check error, not an update.
311 got := evaluateWithProfile("v1.0.0", mk("not-a-version"), portable)
312 if got.Available || got.Err == "" {
313 t.Errorf("invalid manifest version: got %+v", got)
314 }
315 // Metadata carries through.
316 full := evaluateWithProfile("v1.0.0", mk("v1.1.0"), portable)
317 if full.Latest != "v1.1.0" || full.Notes != "notes" || full.AssetSize != 999 {
318 t.Errorf("metadata not carried: %+v", full)
319 }
320 if full.CanSelfUpdate != (runtime.GOOS != "darwin") {
321 t.Errorf("CanSelfUpdate = %v on %s", full.CanSelfUpdate, runtime.GOOS)
322 }
323 if full.InstallMode == "" {
324 t.Error("InstallMode should be set")
325 }
326 }
327
328 func TestEvaluateDebSelectsNativePackage(t *testing.T) {
329 if runtime.GOOS == "darwin" && !canSelfUpdate() {
330 // evaluateWithProfile applies the macOS signed-build gate; synthetic deb
331 // profiles are only meaningful on Linux (or a notarized macOS build).
332 t.Skip("deb install mode is a Linux packaging path")
333 }
334 m := &update.Manifest{
335 Version: "v2.0.0",
336 Platforms: map[string]update.Asset{
337 update.CurrentPlatform(): {URL: "https://example/tarball", Size: 100, SHA256: "aa"},
338 },
339 NativePackages: map[string]update.Asset{
340 update.CurrentPlatform(): {URL: "https://example/pkg.deb", Size: 200, SHA256: "bb"},
341 },
342 }
343 deb := installProfile{
344 Mode: installModeDeb,
345 CanSelfUpdate: true,
346 RequiresElev: true,
347 ArtifactKind: artifactKindDeb,
348 }
349 got := evaluateWithProfile("v1.0.0", m, deb)
350 if !got.Available || got.AssetSize != 200 {
351 t.Fatalf("deb evaluate should use native package size: %+v", got)
352 }
353 if !got.RequiresElevation || got.InstallMode != installModeDeb {
354 t.Fatalf("deb flags missing: %+v", got)
355 }
356 // Without native_packages, deb profile becomes manual.
357 m2 := &update.Manifest{
358 Version: "v2.0.0",
359 Platforms: map[string]update.Asset{update.CurrentPlatform(): {Size: 100}},
360 }
361 adjusted := profileForManifest(deb, m2)
362 got = evaluateWithProfile("v1.0.0", m2, adjusted)
363 if got.CanSelfUpdate || got.InstallMode != installModeManual {
364 t.Fatalf("missing native package should force manual: %+v (profile=%+v)", got, adjusted)
365 }
366 }
367
368 func TestManualUpdateRequiredErrorPreservesReason(t *testing.T) {
369 err := manualUpdateRequiredError(installProfile{ManualReason: "system update helper is unavailable"})
370 if !errors.Is(err, errUpdateManualRequired) {
371 t.Fatalf("error = %v, want manual-update sentinel", err)
372 }
373 if !strings.Contains(err.Error(), "system update helper is unavailable") {
374 t.Fatalf("error = %q, want profile reason", err)
375 }
376 }
377
378 func TestLegacyChannelsSelectOfficialPointers(t *testing.T) {
379 stable := manifestEndpoints("stable")
380 preview := manifestEndpoints("preview")
381 want := []string{
382 r2Base + "/latest/latest.json",
383 releaseGatewayBase + "/stable/latest.json",
384 githubManifestFallback,
385 }
386 if !reflect.DeepEqual(stable, want) || !reflect.DeepEqual(preview, want) {
387 t.Fatalf("manifest endpoints: stable=%q preview=%q want=%q", stable, preview, want)
388 }
389 if got := downloadPage("preview"); got != "https://reasonix.io/?download=desktop#start" {
390 t.Errorf("legacy preview download page = %q", got)
391 }
392 if got := manifestDownloadPage("preview", "https://reasonix.io/?channel=preview&download=desktop#start"); got != "https://reasonix.io/?download=desktop#start" {
393 t.Errorf("manifest official page = %q", got)
394 }
395 if got := manifestDownloadPage("preview", "https://example.com/releases"); got != "https://example.com/releases" {
396 t.Errorf("external manifest download page = %q, want unchanged", got)
397 }
398 for _, unsafe := range []string{
399 "javascript:alert(1)",
400 "http://reasonix.io/#start",
401 "https://user@reasonix.io/#start",
402 } {
403 if got := manifestDownloadPage("preview", unsafe); got != downloadPage("stable") {
404 t.Errorf("unsafe manifest page %q = %q, want official fallback", unsafe, got)
405 }
406 }
407 }
408
409 func TestManifestChannelValidation(t *testing.T) {
410 tests := []struct {
411 name string
412 channel string
413 version string
414 wantError bool
415 }{
416 {name: "stable release", channel: "stable", version: "v1.17.21"},
417 {name: "legacy preview selects official", channel: "preview", version: "v1.17.21"},
418 {name: "legacy canary selects official", channel: "canary", version: "v1.17.21"},
419 {name: "legacy preview rejects prerelease", channel: "preview", version: "v1.18.0-preview.7", wantError: true},
420 {name: "legacy canary rejects prerelease", channel: "canary", version: "v1.17.21-canary.56", wantError: true},
421 {name: "Stable rejects Preview", channel: "stable", version: "v1.18.0-preview.7", wantError: true},
422 {name: "Stable requires v prefix", channel: "stable", version: "1.17.21", wantError: true},
423 {name: "Stable rejects build metadata", channel: "stable", version: "v1.17.21+build.1", wantError: true},
424 {name: "Stable rejects prerelease", channel: "stable", version: "v1.17.21-rc.1", wantError: true},
425 {name: "invalid version", channel: "preview", version: "dev", wantError: true},
426 }
427 for _, tt := range tests {
428 t.Run(tt.name, func(t *testing.T) {
429 err := validateManifestChannel(tt.channel, &update.Manifest{Version: tt.version})
430 if (err != nil) != tt.wantError {
431 t.Fatalf("validateManifestChannel(%q, %q) error = %v, wantError=%v", tt.channel, tt.version, err, tt.wantError)
432 }
433 })
434 }
435 }
436
437 func validDesktopManifest(t *testing.T, selected, manifestVersion string) update.Manifest {
438 t.Helper()
439 tag := desktopReleaseTag(selected, manifestVersion)
440 manifest := update.Manifest{
441 Version: manifestVersion,
442 DownloadPage: manifestDownloadPageURL,
443 Platforms: map[string]update.Asset{},
444 NativePackages: map[string]update.Asset{},
445 Downloads: map[string]update.Asset{},
446 }
447 requiredAssets := append([]requiredDesktopAsset(nil), requiredDesktopUpdaterAssets...)
448 requiredAssets = append(requiredAssets, requiredDesktopDownloadAssets...)
449 for _, required := range requiredAssets {
450 assetURL := fmt.Sprintf("%s/%s/%s", r2Base, tag, required.filename)
451 asset := update.Asset{
452 URL: assetURL,
453 Sig: assetURL + ".minisig",
454 Size: 1024,
455 SHA256: strings.Repeat("a", 64),
456 }
457 switch required.group {
458 case "platforms":
459 manifest.Platforms[required.key] = asset
460 case "native_packages":
461 manifest.NativePackages[required.key] = asset
462 case "downloads":
463 manifest.Downloads[required.key] = asset
464 }
465 }
466 return manifest
467 }
468
469 func TestDesktopManifestValidation(t *testing.T) {
470 tests := []struct {
471 name string
472 mutate func(*update.Manifest)
473 }{
474 {
475 name: "missing required platform asset",
476 mutate: func(m *update.Manifest) {
477 delete(m.Platforms, "darwin-arm64")
478 },
479 },
480 {
481 name: "wrong filename",
482 mutate: func(m *update.Manifest) {
483 asset := m.Platforms["darwin-arm64"]
484 asset.URL = strings.Replace(asset.URL, "Reasonix-", "Other-", 1)
485 asset.Sig = asset.URL + ".minisig"
486 m.Platforms["darwin-arm64"] = asset
487 },
488 },
489 {
490 name: "HTTP asset URL",
491 mutate: func(m *update.Manifest) {
492 asset := m.Platforms["darwin-arm64"]
493 asset.URL = strings.Replace(asset.URL, "https://", "http://", 1)
494 asset.Sig = asset.URL + ".minisig"
495 m.Platforms["darwin-arm64"] = asset
496 },
497 },
498 {
499 name: "asset URL userinfo",
500 mutate: func(m *update.Manifest) {
501 asset := m.Platforms["darwin-arm64"]
502 asset.URL = strings.Replace(asset.URL, "https://", "https://user@", 1)
503 asset.Sig = asset.URL + ".minisig"
504 m.Platforms["darwin-arm64"] = asset
505 },
506 },
507 {
508 name: "wrong asset host",
509 mutate: func(m *update.Manifest) {
510 asset := m.Platforms["darwin-arm64"]
511 asset.URL = strings.Replace(asset.URL, "dl.reasonix.io", "example.com", 1)
512 asset.Sig = asset.URL + ".minisig"
513 m.Platforms["darwin-arm64"] = asset
514 },
515 },
516 {
517 name: "wrong release tag",
518 mutate: func(m *update.Manifest) {
519 asset := m.Platforms["darwin-arm64"]
520 asset.URL = strings.Replace(asset.URL, desktopReleaseTag("stable", m.Version), "desktop-v9.9.9", 1)
521 asset.Sig = asset.URL + ".minisig"
522 m.Platforms["darwin-arm64"] = asset
523 },
524 },
525 {
526 name: "signature is not exact URL suffix",
527 mutate: func(m *update.Manifest) {
528 asset := m.Platforms["darwin-arm64"]
529 asset.Sig = asset.URL + ".sig"
530 m.Platforms["darwin-arm64"] = asset
531 },
532 },
533 {
534 name: "zero size",
535 mutate: func(m *update.Manifest) {
536 asset := m.Platforms["darwin-arm64"]
537 asset.Size = 0
538 m.Platforms["darwin-arm64"] = asset
539 },
540 },
541 {
542 name: "negative size",
543 mutate: func(m *update.Manifest) {
544 asset := m.Platforms["darwin-arm64"]
545 asset.Size = -1
546 m.Platforms["darwin-arm64"] = asset
547 },
548 },
549 {
550 name: "size above release maximum",
551 mutate: func(m *update.Manifest) {
552 asset := m.Platforms["darwin-arm64"]
553 asset.Size = maxDesktopReleaseAssetSize + 1
554 m.Platforms["darwin-arm64"] = asset
555 },
556 },
557 {
558 name: "uppercase SHA",
559 mutate: func(m *update.Manifest) {
560 asset := m.Platforms["darwin-arm64"]
561 asset.SHA256 = strings.Repeat("A", 64)
562 m.Platforms["darwin-arm64"] = asset
563 },
564 },
565 {
566 name: "short SHA",
567 mutate: func(m *update.Manifest) {
568 asset := m.Platforms["darwin-arm64"]
569 asset.SHA256 = strings.Repeat("a", 63)
570 m.Platforms["darwin-arm64"] = asset
571 },
572 },
573 {
574 name: "nonhex SHA",
575 mutate: func(m *update.Manifest) {
576 asset := m.Platforms["darwin-arm64"]
577 asset.SHA256 = strings.Repeat("g", 64)
578 m.Platforms["darwin-arm64"] = asset
579 },
580 },
581 {
582 name: "missing download page",
583 mutate: func(m *update.Manifest) {
584 m.DownloadPage = ""
585 },
586 },
587 {
588 name: "wrong download page",
589 mutate: func(m *update.Manifest) {
590 m.DownloadPage = "https://reasonix.io/?channel=stable&download=desktop#start"
591 },
592 },
593 }
594
595 if err := validateDesktopManifest("stable", ptr(validDesktopManifest(t, "stable", "v1.18.0"))); err != nil {
596 t.Fatalf("valid Stable manifest: %v", err)
597 }
598 if err := validateDesktopManifest("preview", ptr(validDesktopManifest(t, "stable", "v1.19.0"))); err != nil {
599 t.Fatalf("legacy Preview selection did not accept official manifest: %v", err)
600 }
601 t.Run("legacy manifests remain upgradeable", func(t *testing.T) {
602 stable := validDesktopManifest(t, "stable", "v1.17.21")
603 stable.Downloads = nil
604 if err := validateDesktopManifest("stable", &stable); err != nil {
605 t.Fatalf("legacy Stable manifest: %v", err)
606 }
607 })
608 t.Run("empty downloads is not a legacy manifest", func(t *testing.T) {
609 manifest := validDesktopManifest(t, "stable", "v1.17.21")
610 manifest.Downloads = map[string]update.Asset{}
611 if err := validateDesktopManifest("stable", &manifest); err == nil {
612 t.Fatal("manifest with empty downloads bypassed the new-format asset requirements")
613 }
614 })
615 t.Run("official manifest rejects legacy rolling asset base", func(t *testing.T) {
616 manifest := validDesktopManifest(t, "stable", "v1.19.0")
617 immutableBase := r2Base + "/desktop-v1.19.0/"
618 rollingBase := r2Base + "/desktop-preview/"
619 for key, asset := range manifest.Platforms {
620 asset.URL = strings.Replace(asset.URL, immutableBase, rollingBase, 1)
621 asset.Sig = asset.URL + ".minisig"
622 manifest.Platforms[key] = asset
623 }
624 for key, asset := range manifest.NativePackages {
625 asset.URL = strings.Replace(asset.URL, immutableBase, rollingBase, 1)
626 asset.Sig = asset.URL + ".minisig"
627 manifest.NativePackages[key] = asset
628 }
629 for key, asset := range manifest.Downloads {
630 asset.URL = strings.Replace(asset.URL, immutableBase, rollingBase, 1)
631 asset.Sig = asset.URL + ".minisig"
632 manifest.Downloads[key] = asset
633 }
634 if err := validateDesktopManifest("stable", &manifest); err == nil {
635 t.Fatal("official manifest accepted mutable rolling assets")
636 }
637 })
638 t.Run("unified GitHub release base", func(t *testing.T) {
639 manifest := validDesktopManifest(t, "stable", "v1.19.0")
640 oldBase := r2Base + "/desktop-v1.19.0/"
641 newBase := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v1.19.0/"
642 for key, asset := range manifest.Platforms {
643 asset.URL = strings.Replace(asset.URL, oldBase, newBase, 1)
644 asset.Sig = asset.URL + ".minisig"
645 manifest.Platforms[key] = asset
646 }
647 for key, asset := range manifest.NativePackages {
648 asset.URL = strings.Replace(asset.URL, oldBase, newBase, 1)
649 asset.Sig = asset.URL + ".minisig"
650 manifest.NativePackages[key] = asset
651 }
652 for key, asset := range manifest.Downloads {
653 asset.URL = strings.Replace(asset.URL, oldBase, newBase, 1)
654 asset.Sig = asset.URL + ".minisig"
655 manifest.Downloads[key] = asset
656 }
657 if err := validateDesktopManifest("stable", &manifest); err != nil {
658 t.Fatalf("unified GitHub release manifest: %v", err)
659 }
660 })
661 for _, tt := range tests {
662 t.Run(tt.name, func(t *testing.T) {
663 manifest := validDesktopManifest(t, "stable", "v1.18.0")
664 tt.mutate(&manifest)
665 if err := validateDesktopManifest("stable", &manifest); err == nil {
666 t.Fatal("validateDesktopManifest accepted malformed manifest")
667 }
668 })
669 }
670
671 t.Run("invalid native package", func(t *testing.T) {
672 manifest := validDesktopManifest(t, "stable", "v1.18.0")
673 native := manifest.NativePackages["linux-amd64"]
674 native.Sig = native.URL + ".sig"
675 manifest.NativePackages["linux-amd64"] = native
676 if err := validateDesktopManifest("stable", &manifest); err == nil {
677 t.Fatal("validateDesktopManifest accepted malformed native package")
678 }
679 })
680
681 t.Run("mixed official bases", func(t *testing.T) {
682 manifest := validDesktopManifest(t, "stable", "v1.18.0")
683 asset := manifest.Platforms["darwin-arm64"]
684 asset.URL = strings.Replace(
685 asset.URL,
686 r2Base+"/desktop-v1.18.0/",
687 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.18.0/",
688 1,
689 )
690 asset.Sig = asset.URL + ".minisig"
691 manifest.Platforms["darwin-arm64"] = asset
692 if err := validateDesktopManifest("stable", &manifest); err == nil {
693 t.Fatal("validateDesktopManifest accepted mixed R2 and GitHub asset bases")
694 }
695 })
696 }
697
698 func ptr[T any](value T) *T {
699 return &value
700 }
701
702 func TestFetchManifestSkipsPrereleaseForLegacyPreviewSelection(t *testing.T) {
703 var calls []string
704 client := &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) {
705 calls = append(calls, req.URL.String())
706 version := "v1.18.0-preview.7"
707 if strings.Contains(req.URL.Path, "/stable/") {
708 version = "v1.18.0"
709 }
710 manifest := validDesktopManifest(t, "stable", version)
711 body, err := json.Marshal(manifest)
712 if err != nil {
713 t.Fatal(err)
714 }
715 return &http.Response{
716 StatusCode: http.StatusOK,
717 Status: "200 OK",
718 Body: io.NopCloser(bytes.NewReader(body)),
719 Header: make(http.Header),
720 }, nil
721 })}
722
723 manifest, err := fetchManifest(context.Background(), client, nil, "preview")
724 if err != nil {
725 t.Fatalf("fetchManifest: %v", err)
726 }
727 if manifest.Version != "v1.18.0" {
728 t.Fatalf("version = %q, want official fallback manifest", manifest.Version)
729 }
730 if len(calls) != 2 || !strings.Contains(calls[0], "/latest/") || !strings.Contains(calls[1], "/stable/") {
731 t.Fatalf("endpoint calls = %q, want official latest then gateway fallback", calls)
732 }
733 }
734
735 func TestFetchManifestSkipsMalformedSuccessfulResponse(t *testing.T) {
736 var calls []string
737 client := &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) {
738 calls = append(calls, req.URL.String())
739 manifest := validDesktopManifest(t, "stable", "v1.18.0")
740 if strings.Contains(req.URL.Path, "/latest/") {
741 delete(manifest.Platforms, update.CurrentPlatform())
742 }
743 body, err := json.Marshal(manifest)
744 if err != nil {
745 t.Fatal(err)
746 }
747 return &http.Response{
748 StatusCode: http.StatusOK,
749 Status: "200 OK",
750 Body: io.NopCloser(bytes.NewReader(body)),
751 Header: make(http.Header),
752 }, nil
753 })}
754
755 manifest, err := fetchManifest(context.Background(), client, nil, "preview")
756 if err != nil {
757 t.Fatalf("fetchManifest: %v", err)
758 }
759 if manifest.Version != "v1.18.0" {
760 t.Fatalf("version = %q, want valid fallback manifest", manifest.Version)
761 }
762 if len(calls) != 2 || !strings.Contains(calls[0], "/latest/") || !strings.Contains(calls[1], "/stable/") {
763 t.Fatalf("endpoint calls = %q, want malformed 200 to fall through", calls)
764 }
765 }
766
767 func TestValidateUpdateRedirect(t *testing.T) {
768 tests := []struct {
769 name string
770 target string
771 wantError bool
772 }{
773 {name: "Reasonix first-party redirect", target: "https://dl.reasonix.io/file"},
774 {name: "GitHub redirect", target: "https://github.com/file"},
775 {name: "GitHub HTTPS asset redirect", target: "https://release-assets.githubusercontent.com/file"},
776 {name: "HTTPS downgrade", target: "http://release-assets.githubusercontent.com/file", wantError: true},
777 {name: "userinfo", target: "https://user@release-assets.githubusercontent.com/file", wantError: true},
778 {name: "missing hostname", target: "https:///file", wantError: true},
779 {name: "arbitrary HTTPS host", target: "https://example.com/file", wantError: true},
780 {name: "Reasonix suffix spoof", target: "https://dl.reasonix.io.evil.invalid/file", wantError: true},
781 {name: "GitHub suffix spoof", target: "https://release-assets.githubusercontent.com.evil.invalid/file", wantError: true},
782 {name: "explicit port", target: "https://dl.reasonix.io:443/file", wantError: true},
783 }
784 for _, tt := range tests {
785 t.Run(tt.name, func(t *testing.T) {
786 req, err := http.NewRequest(http.MethodGet, tt.target, nil)
787 if err != nil {
788 t.Fatal(err)
789 }
790 err = validateUpdateRedirect(req, nil)
791 if (err != nil) != tt.wantError {
792 t.Fatalf("validateUpdateRedirect(%q) error = %v, wantError=%v", tt.target, err, tt.wantError)
793 }
794 })
795 }
796 t.Run("redirect limit", func(t *testing.T) {
797 req, err := http.NewRequest(http.MethodGet, "https://release-assets.githubusercontent.com/file", nil)
798 if err != nil {
799 t.Fatal(err)
800 }
801 via := make([]*http.Request, 10)
802 if err := validateUpdateRedirect(req, via); err == nil {
803 t.Fatal("validateUpdateRedirect accepted more than 10 redirects")
804 }
805 })
806 }
807
808 func withUpdateCacheDir(t *testing.T) string {
809 t.Helper()
810 dir := t.TempDir()
811 restore := updateCacheBaseDir
812 updateCacheBaseDir = func() (string, error) { return dir, nil }
813 t.Cleanup(func() { updateCacheBaseDir = restore })
814 return dir
815 }
816
817 func sha256Hex(data []byte) string {
818 sum := sha256.Sum256(data)
819 return hex.EncodeToString(sum[:])
820 }
821
822 func TestSaveCachedUpdateMarksEvaluateDownloaded(t *testing.T) {
823 withUpdateCacheDir(t)
824 oldChannel := channel
825 channel = "stable"
826 t.Cleanup(func() { channel = oldChannel })
827
828 data := []byte("verified artifact")
829 asset := update.Asset{
830 URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
831 Size: int64(len(data)),
832 SHA256: sha256Hex(data),
833 }
834 manifest := &update.Manifest{
835 Version: "v9.9.9",
836 Platforms: map[string]update.Asset{update.CurrentPlatform(): asset},
837 }
838 portable := installProfile{Mode: installModePortable, CanSelfUpdate: true, ArtifactKind: artifactKindTarball}
839 if got := evaluateWithProfile("v1.0.0", manifest, portable); got.Downloaded {
840 t.Fatal("fresh cache should not report a downloaded update")
841 }
842 meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindTarball, nil)
843 if err != nil {
844 t.Fatalf("saveCachedUpdate: %v", err)
845 }
846 if meta.Version != "v9.9.9" || meta.Channel != "stable" || meta.Platform != update.CurrentPlatform() {
847 t.Fatalf("cached metadata mismatch: %+v", meta)
848 }
849 if got := evaluateWithProfile("v1.0.0", manifest, portable); !got.Downloaded {
850 t.Fatalf("evaluate did not detect cached update: %+v", got)
851 }
852 }
853
854 func TestCachedUpdateRejectsTamperedArtifact(t *testing.T) {
855 withUpdateCacheDir(t)
856 oldChannel := channel
857 channel = "stable"
858 t.Cleanup(func() { channel = oldChannel })
859
860 data := []byte("verified artifact")
861 asset := update.Asset{
862 URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
863 Size: int64(len(data)),
864 SHA256: sha256Hex(data),
865 }
866 meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindTarball, nil)
867 if err != nil {
868 t.Fatalf("saveCachedUpdate: %v", err)
869 }
870 if err := os.WriteFile(meta.Path, []byte("tampered"), 0o600); err != nil {
871 t.Fatal(err)
872 }
873 if cachedUpdateMatches("v9.9.9", asset, artifactKindTarball) {
874 t.Fatal("tampered cached artifact should not match")
875 }
876 if _, _, err := readVerifiedCachedUpdate(); err == nil {
877 t.Fatal("readVerifiedCachedUpdate should reject a tampered artifact")
878 }
879 }
880
881 func TestCachedUpdateAcceptsLegacyChannelAlias(t *testing.T) {
882 withUpdateCacheDir(t)
883
884 data := []byte("verified artifact")
885 asset := update.Asset{
886 URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
887 Size: int64(len(data)),
888 SHA256: sha256Hex(data),
889 }
890 if _, err := saveCachedUpdateForChannel("stable", "v9.9.9", asset, data, artifactKindTarball, nil); err != nil {
891 t.Fatalf("saveCachedUpdateForChannel: %v", err)
892 }
893 if _, _, err := readVerifiedCachedUpdateForChannel("preview"); err != nil {
894 t.Fatalf("legacy Preview alias did not read official cache: %v", err)
895 }
896 }
897
898 func TestLegacyChannelAliasDoesNotPermitDowngrade(t *testing.T) {
899 oldChannel := channel
900 channel = "preview"
901 t.Cleanup(func() { channel = oldChannel })
902
903 m := &update.Manifest{
904 Version: "v1.6.0",
905 Platforms: map[string]update.Asset{
906 update.CurrentPlatform(): {Size: 100},
907 },
908 }
909 got := evaluateWithProfileForChannel(
910 "v1.7.0-preview.12",
911 "stable",
912 m,
913 installProfile{Mode: installModePortable, CanSelfUpdate: true},
914 )
915 if got.Available {
916 t.Fatalf("legacy Preview alias permitted an official downgrade: %+v", got)
917 }
918 if got.Channel != "stable" {
919 t.Fatalf("channel = %q, want stable", got.Channel)
920 }
921 }
922
923 func TestDebCacheRequiresSignatureAndRejectsTarballReuse(t *testing.T) {
924 withUpdateCacheDir(t)
925 oldChannel := channel
926 channel = "stable"
927 t.Cleanup(func() { channel = oldChannel })
928
929 data := []byte("deb-bytes")
930 asset := update.Asset{
931 URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.deb",
932 Size: int64(len(data)),
933 SHA256: sha256Hex(data),
934 }
935 if _, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindDeb, nil); err == nil {
936 t.Fatal("deb cache without signature must fail")
937 }
938 sig := []byte("minisig-bytes")
939 meta, err := saveCachedUpdate("v9.9.9", asset, data, artifactKindDeb, sig)
940 if err != nil {
941 t.Fatalf("saveCachedUpdate deb: %v", err)
942 }
943 if meta.SignaturePath == "" {
944 t.Fatal("deb cache must record signature path")
945 }
946 if !cachedUpdateMatches("v9.9.9", asset, artifactKindDeb) {
947 t.Fatal("deb cache with matching signature should match")
948 }
949 // A tarball install must not reuse a deb cache.
950 if cachedUpdateMatches("v9.9.9", asset, artifactKindTarball) {
951 t.Fatal("deb cache must not match tarball requests")
952 }
953 // Signature removal invalidates the download marker.
954 if err := os.Remove(meta.SignaturePath); err != nil {
955 t.Fatal(err)
956 }
957 if cachedUpdateMatches("v9.9.9", asset, artifactKindDeb) {
958 t.Fatal("deb cache without signature file must not match")
959 }
960
961 // Portable legacy cache (no artifactKind) remains valid for tarball.
962 tarball := []byte("tarball-bytes")
963 tAsset := update.Asset{
964 URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
965 Size: int64(len(tarball)),
966 SHA256: sha256Hex(tarball),
967 }
968 meta, err = saveCachedUpdate("v9.9.9", tAsset, tarball, artifactKindTarball, nil)
969 if err != nil {
970 t.Fatal(err)
971 }
972 // Simulate pre-artifactKind metadata.
973 meta.ArtifactKind = ""
974 raw, _ := json.MarshalIndent(meta, "", " ")
975 path, _ := updateMetadataPath()
976 if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil {
977 t.Fatal(err)
978 }
979 if !cachedUpdateMatches("v9.9.9", tAsset, artifactKindTarball) {
980 t.Fatal("legacy portable cache should still match tarball")
981 }
982 if cachedUpdateMatches("v9.9.9", tAsset, artifactKindDeb) {
983 t.Fatal("legacy portable cache must not match deb")
984 }
985 }
986
987 func TestProfileForManifestDebWithoutHelperBecomesManual(t *testing.T) {
988 // profileForManifest checks linuxDebHelperReady(); on non-linux it is always
989 // false, so a synthetic deb profile without native assets becomes manual.
990 base := installProfile{Mode: installModeDeb, CanSelfUpdate: true, RequiresElev: true, ArtifactKind: artifactKindDeb}
991 m := &update.Manifest{Version: "v1.0.0"}
992 got := profileForManifest(base, m)
993 if got.Mode != installModeManual || got.CanSelfUpdate {
994 t.Fatalf("expected manual without native package: %+v", got)
995 }
996 }
997
998 func TestCheckSHA256(t *testing.T) {
999 data := []byte("hello world")
1000 // echo -n "hello world" | shasum -a 256
1001 const sum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1002 if err := checkSHA256(data, sum); err != nil {
1003 t.Errorf("matching digest should pass: %v", err)
1004 }
1005 if err := checkSHA256(data, "deadbeef"); err == nil {
1006 t.Error("mismatched digest should fail")
1007 }
1008 // Case-insensitive hex.
1009 if err := checkSHA256(data, "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"); err != nil {
1010 t.Errorf("uppercase digest should pass: %v", err)
1011 }
1012 }
1013
1014 func TestExtractBinary(t *testing.T) {
1015 want := []byte("#!/bin/sh\necho reasonix\n")
1016 var buf bytes.Buffer
1017 gz := gzip.NewWriter(&buf)
1018 tw := tar.NewWriter(gz)
1019 files := map[string][]byte{"README": []byte("ignore me"), "reasonix-desktop": want}
1020 for name, body := range files {
1021 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
1022 t.Fatal(err)
1023 }
1024 if _, err := tw.Write(body); err != nil {
1025 t.Fatal(err)
1026 }
1027 }
1028 tw.Close()
1029 gz.Close()
1030
1031 got, err := extractBinary(buf.Bytes(), "reasonix-desktop")
1032 if err != nil {
1033 t.Fatalf("extractBinary: %v", err)
1034 }
1035 if !bytes.Equal(got, want) {
1036 t.Fatalf("extracted %q, want %q", got, want)
1037 }
1038 if _, err := extractBinary(buf.Bytes(), "missing"); err == nil {
1039 t.Error("missing entry should error")
1040 }
1041 }
1042
1043 func TestExtractLinuxReleaseUnitRejectsAmbiguousMembers(t *testing.T) {
1044 makeArchive := func(t *testing.T, headers []tar.Header, bodies [][]byte) []byte {
1045 t.Helper()
1046 var buf bytes.Buffer
1047 gz := gzip.NewWriter(&buf)
1048 tw := tar.NewWriter(gz)
1049 for i, header := range headers {
1050 if err := tw.WriteHeader(&header); err != nil {
1051 t.Fatal(err)
1052 }
1053 if i < len(bodies) {
1054 if _, err := tw.Write(bodies[i]); err != nil {
1055 t.Fatal(err)
1056 }
1057 }
1058 }
1059 if err := tw.Close(); err != nil {
1060 t.Fatal(err)
1061 }
1062 if err := gz.Close(); err != nil {
1063 t.Fatal(err)
1064 }
1065 return buf.Bytes()
1066 }
1067 base := func(name string, body []byte) tar.Header {
1068 return tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}
1069 }
1070 headers := []tar.Header{
1071 base("reasonix-desktop", []byte("desktop")),
1072 base("reasonix-guard", []byte("guard")),
1073 base("reasonix", []byte("cli")),
1074 }
1075 bodies := [][]byte{[]byte("desktop"), []byte("guard"), []byte("cli")}
1076 if got, err := extractLinuxReleaseUnit(makeArchive(t, headers, bodies)); err != nil ||
1077 string(got["reasonix-desktop"]) != "desktop" {
1078 t.Fatalf("complete release extraction = %v, %q", err, got["reasonix-desktop"])
1079 }
1080
1081 duplicateHeaders := append(append([]tar.Header(nil), headers...), base("nested/reasonix", []byte("duplicate")))
1082 duplicateBodies := append(append([][]byte(nil), bodies...), []byte("duplicate"))
1083 if _, err := extractLinuxReleaseUnit(makeArchive(t, duplicateHeaders, duplicateBodies)); err == nil ||
1084 !strings.Contains(err.Error(), "appears more than once") {
1085 t.Fatalf("duplicate release member error = %v", err)
1086 }
1087
1088 nonRegular := append([]tar.Header(nil), headers...)
1089 nonRegular[1] = tar.Header{Name: "reasonix-guard", Typeflag: tar.TypeSymlink, Linkname: "outside"}
1090 nonRegularBodies := [][]byte{bodies[0], nil, bodies[2]}
1091 if _, err := extractLinuxReleaseUnit(makeArchive(t, nonRegular, nonRegularBodies)); err == nil ||
1092 !strings.Contains(err.Error(), "not a regular file") {
1093 t.Fatalf("non-regular release member error = %v", err)
1094 }
1095 }
1096
1097 func TestApplyLinuxVersionedActivatesWithoutPersistingGuard(t *testing.T) {
1098 root := robustTempDir(t)
1099 source := robustTempDir(t)
1100 // This fixture describes a Linux release even when the test host is Windows.
1101 for _, name := range []string{"reasonix-desktop", "reasonix"} {
1102 if err := os.WriteFile(filepath.Join(source, name), []byte("old-"+name), 0o700); err != nil {
1103 t.Fatal(err)
1104 }
1105 }
1106 if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
1107 InstallRoot: root,
1108 Version: "v1.20.0",
1109 RequestID: "seed-linux",
1110 Members: []installlayout.Member{
1111 {Name: "reasonix-desktop", Path: filepath.Join(source, "reasonix-desktop")},
1112 {Name: "reasonix", Path: filepath.Join(source, "reasonix")},
1113 },
1114 RequiredNames: []string{"reasonix-desktop", "reasonix"},
1115 }); err != nil {
1116 t.Fatal(err)
1117 }
1118
1119 originalRoot := currentInstallDirForLinuxUpdate
1120 currentInstallDirForLinuxUpdate = func() string { return root }
1121 t.Cleanup(func() { currentInstallDirForLinuxUpdate = originalRoot })
1122
1123 if err := applyLinuxVersioned(linuxShellArchive(t, nil, ""), "1.20.1"); err != nil {
1124 t.Fatal(err)
1125 }
1126
1127 ptr, err := installlayout.ReadCurrent(root)
1128 if err != nil || ptr.ActiveVersion != "v1.20.1" {
1129 t.Fatalf("pointer=%+v err=%v", ptr, err)
1130 }
1131 activeDesktop := filepath.Join(root, filepath.FromSlash(ptr.ActiveDir), "reasonix-desktop")
1132 data, err := os.ReadFile(activeDesktop)
1133 if err != nil || string(data) != "new-reasonix-desktop" {
1134 t.Fatalf("active desktop=%q err=%v", data, err)
1135 }
1136 for _, guardPath := range []string{
1137 filepath.Join(root, "reasonix-guard"),
1138 filepath.Join(root, "versions", "v1.20.1", "reasonix-guard"),
1139 } {
1140 if _, err := os.Lstat(guardPath); !os.IsNotExist(err) {
1141 t.Fatalf("Guard persisted at %s: %v", guardPath, err)
1142 }
1143 }
1144 }
1145
1146 func TestApplyLinuxHoldsReleaseUnitLockDuringReplace(t *testing.T) {
1147 dir := robustTempDir(t)
1148 t.Setenv("REASONIX_HOME", robustTempDir(t))
1149 exe := filepath.Join(dir, "reasonix-desktop")
1150 releasePaths := releaseUnitPathsFor(dir, "linux")
1151 for _, path := range releasePaths {
1152 if err := os.WriteFile(path, []byte("old"), 0o700); err != nil {
1153 t.Fatal(err)
1154 }
1155 }
1156 prepared, err := repair.PrepareFileUpdate("v1", "v2", exe, releasePaths[1:]...)
1157 if err != nil {
1158 t.Fatal(err)
1159 }
1160 originalPath := currentExecutablePathForLinux
1161 originalApply := applyLinuxReleaseUnit
1162 currentExecutablePathForLinux = func() string { return exe }
1163 entered := make(chan struct{})
1164 releaseReplace := make(chan struct{})
1165 applyLinuxReleaseUnit = func(
1166 tx *repair.UpdateTransaction,
1167 exe string,
1168 bin, guard, cli []byte,
1169 ) ([]repair.FileUpdateInstallReceipt, error) {
1170 close(entered)
1171 <-releaseReplace
1172 return originalApply(tx, exe, bin, guard, cli)
1173 }
1174 t.Cleanup(func() {
1175 currentExecutablePathForLinux = originalPath
1176 applyLinuxReleaseUnit = originalApply
1177 })
1178
1179 var buf bytes.Buffer
1180 gz := gzip.NewWriter(&buf)
1181 tw := tar.NewWriter(gz)
1182 for _, name := range []string{"reasonix-desktop", "reasonix-guard", "reasonix"} {
1183 body := []byte(name)
1184 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
1185 t.Fatal(err)
1186 }
1187 if _, err := tw.Write(body); err != nil {
1188 t.Fatal(err)
1189 }
1190 }
1191 if err := tw.Close(); err != nil {
1192 t.Fatal(err)
1193 }
1194 if err := gz.Close(); err != nil {
1195 t.Fatal(err)
1196 }
1197
1198 applyDone := make(chan error, 1)
1199 go func() { applyDone <- applyLinux(buf.Bytes(), prepared) }()
1200 select {
1201 case <-entered:
1202 case err := <-applyDone:
1203 t.Fatalf("applyLinux failed before replacement: %v", err)
1204 }
1205
1206 lockDone := make(chan error, 1)
1207 go func() {
1208 unlock, err := repair.LockRepairMutations(releasePaths...)
1209 if err == nil {
1210 unlock()
1211 }
1212 lockDone <- err
1213 }()
1214 select {
1215 case err := <-lockDone:
1216 close(releaseReplace)
1217 t.Fatalf("competing updater lock acquired during Linux replacement: %v", err)
1218 case <-time.After(300 * time.Millisecond):
1219 }
1220 close(releaseReplace)
1221 if err := <-applyDone; err != nil {
1222 t.Fatalf("applyLinux: %v", err)
1223 }
1224 if err := <-lockDone; err != nil {
1225 t.Fatalf("competing lock after replacement: %v", err)
1226 }
1227 if _, ok := repair.ReadUpdateApplyFailure(); ok {
1228 t.Fatal("successful Linux release-unit publish left an interruption marker")
1229 }
1230 }
1231
1232 func fastRetry(t *testing.T) {
1233 t.Helper()
1234 restore := retryBackoff
1235 retryBackoff = func(int) time.Duration { return time.Millisecond }
1236 t.Cleanup(func() { retryBackoff = restore })
1237 }
1238
1239 func TestDownloadRecoversFromMidStreamReset(t *testing.T) {
1240 fastRetry(t)
1241 const body = "complete-installer-bytes"
1242 var calls atomic.Int32
1243 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1244 if calls.Add(1) < int32(downloadAttempts) {
1245 // Mid-stream reset: promise 100 bytes, send a few, drop the socket —
1246 // the client's body read fails with unexpected EOF, exactly the CN-IPv6
1247 // "forcibly closed" case the retry exists for.
1248 conn, bw, err := w.(http.Hijacker).Hijack()
1249 if err != nil {
1250 t.Errorf("hijack: %v", err)
1251 return
1252 }
1253 bw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial")
1254 bw.Flush()
1255 conn.Close()
1256 return
1257 }
1258 _, _ = w.Write([]byte(body))
1259 }))
1260 defer srv.Close()
1261
1262 data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
1263 if err != nil {
1264 t.Fatalf("download should recover after %d resets: %v", downloadAttempts-1, err)
1265 }
1266 if string(data) != body {
1267 t.Fatalf("got %q, want %q", data, body)
1268 }
1269 if n := calls.Load(); n != int32(downloadAttempts) {
1270 t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
1271 }
1272 }
1273
1274 func TestDownloadGivesUpAfterCap(t *testing.T) {
1275 fastRetry(t)
1276 var calls atomic.Int32
1277 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1278 calls.Add(1)
1279 conn, _, err := w.(http.Hijacker).Hijack()
1280 if err != nil {
1281 t.Errorf("hijack: %v", err)
1282 return
1283 }
1284 conn.Close()
1285 }))
1286 defer srv.Close()
1287
1288 if _, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil); err == nil {
1289 t.Fatal("download should fail after exhausting retries")
1290 }
1291 if n := calls.Load(); n != int32(downloadAttempts) {
1292 t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
1293 }
1294 }
1295
1296 func TestRetryTransientStopsWhenCancelled(t *testing.T) {
1297 ctx, cancel := context.WithCancel(context.Background())
1298 cancel()
1299 calls := 0
1300 if err := retryTransient(ctx, func(int) error {
1301 calls++
1302 return errors.New("boom")
1303 }); err == nil {
1304 t.Fatal("cancelled retry should return the error")
1305 }
1306 if calls != 1 {
1307 t.Fatalf("cancelled retry made %d calls, want 1", calls)
1308 }
1309 }
1310
1311 func TestDownloadResumesWithRange(t *testing.T) {
1312 fastRetry(t)
1313 full := bytes.Repeat([]byte("0123456789"), 50) // 500 bytes
1314 const cut = 200
1315 var calls atomic.Int32
1316 rangeCh := make(chan string, 4)
1317 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1318 if calls.Add(1) == 1 {
1319 // First attempt: promise the whole file, send a prefix, drop the socket.
1320 conn, bw, err := w.(http.Hijacker).Hijack()
1321 if err != nil {
1322 t.Errorf("hijack: %v", err)
1323 return
1324 }
1325 fmt.Fprintf(bw, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(full))
1326 bw.Write(full[:cut])
1327 bw.Flush()
1328 conn.Close()
1329 return
1330 }
1331 // Resume attempt: honor the Range header with a 206 + Content-Range.
1332 rng := r.Header.Get("Range")
1333 rangeCh <- rng
1334 start := 0
1335 fmt.Sscanf(rng, "bytes=%d-", &start)
1336 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(full)-1, len(full)))
1337 w.WriteHeader(http.StatusPartialContent)
1338 w.Write(full[start:])
1339 }))
1340 defer srv.Close()
1341
1342 data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
1343 if err != nil {
1344 t.Fatalf("download: %v", err)
1345 }
1346 if !bytes.Equal(data, full) {
1347 t.Fatalf("assembled %d bytes, want %d (equal=%v)", len(data), len(full), bytes.Equal(data, full))
1348 }
1349 select {
1350 case rng := <-rangeCh:
1351 if rng != fmt.Sprintf("bytes=%d-", cut) {
1352 t.Fatalf("resume Range = %q, want bytes=%d-", rng, cut)
1353 }
1354 default:
1355 t.Fatal("resume attempt sent no Range header")
1356 }
1357 }
1358
1359 func TestDownloadFallsBackToSecondClient(t *testing.T) {
1360 fastRetry(t)
1361 const body = "served-over-ipv4"
1362 primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1363 return nil, errors.New("connection reset (ipv6)")
1364 })}
1365 var fbCalls atomic.Int32
1366 fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1367 fbCalls.Add(1)
1368 return &http.Response{
1369 StatusCode: http.StatusOK,
1370 Body: io.NopCloser(strings.NewReader(body)),
1371 ContentLength: int64(len(body)),
1372 Header: make(http.Header),
1373 }, nil
1374 })}
1375
1376 data, err := download(context.Background(), primary, fallback, "http://example.invalid/x", 0, nil)
1377 if err != nil {
1378 t.Fatalf("download: %v", err)
1379 }
1380 if string(data) != body {
1381 t.Fatalf("got %q, want %q", data, body)
1382 }
1383 if fbCalls.Load() == 0 {
1384 t.Fatal("fallback client was never used after the primary failed")
1385 }
1386 }
1387
1388 func TestDownloadRejectsBodyShorterThanManifestSize(t *testing.T) {
1389 fastRetry(t)
1390 body := []byte("short")
1391 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1392 _, _ = w.Write(body)
1393 }))
1394 defer srv.Close()
1395
1396 if _, err := download(context.Background(), srv.Client(), nil, srv.URL, int64(len(body)+1), nil); err == nil {
1397 t.Fatal("download accepted fewer bytes than the manifest declared")
1398 }
1399 }
1400
1401 func TestDownloadRejectsBodyLongerThanManifestSize(t *testing.T) {
1402 fastRetry(t)
1403 body := bytes.Repeat([]byte("x"), 64)
1404 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1405 _, _ = w.Write(body)
1406 }))
1407 defer srv.Close()
1408
1409 if _, err := download(context.Background(), srv.Client(), nil, srv.URL, 8, nil); err == nil {
1410 t.Fatal("download accepted more bytes than the manifest declared")
1411 }
1412 }
1413
1414 func TestFetchBytesFallsBackToSecondClient(t *testing.T) {
1415 fastRetry(t)
1416 primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1417 return nil, errors.New("read tcp [ipv6]: connection reset")
1418 })}
1419 fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1420 return &http.Response{
1421 StatusCode: http.StatusOK,
1422 Status: "200 OK",
1423 Body: io.NopCloser(strings.NewReader("manifest")),
1424 Header: make(http.Header),
1425 }, nil
1426 })}
1427
1428 data, err := fetchBytesFallback(context.Background(), primary, fallback, "https://example.invalid/latest.json")
1429 if err != nil {
1430 t.Fatalf("fetchBytesFallback: %v", err)
1431 }
1432 if string(data) != "manifest" {
1433 t.Fatalf("got %q, want manifest", data)
1434 }
1435 }
1436
1437 func TestFetchBytesFallbackEscapesStalledPrimary(t *testing.T) {
1438 fastRetry(t)
1439 originalTimeout := fetchAttemptTimeout
1440 fetchAttemptTimeout = 10 * time.Millisecond
1441 t.Cleanup(func() { fetchAttemptTimeout = originalTimeout })
1442 primary := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
1443 <-r.Context().Done()
1444 return nil, r.Context().Err()
1445 })}
1446 fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1447 return &http.Response{
1448 StatusCode: http.StatusOK,
1449 Status: "200 OK",
1450 Body: io.NopCloser(strings.NewReader("ipv4")),
1451 Header: make(http.Header),
1452 }, nil
1453 })}
1454
1455 data, err := fetchBytesFallback(context.Background(), primary, fallback, "https://example.invalid/latest.json")
1456 if err != nil {
1457 t.Fatalf("fetchBytesFallback: %v", err)
1458 }
1459 if string(data) != "ipv4" {
1460 t.Fatalf("got %q, want ipv4", data)
1461 }
1462 }
1463
1464 func TestFetchBytesDoesNotRetryPermanentHTTPStatus(t *testing.T) {
1465 fastRetry(t)
1466 var calls atomic.Int32
1467 client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1468 calls.Add(1)
1469 return &http.Response{
1470 StatusCode: http.StatusForbidden,
1471 Status: "403 Forbidden",
1472 Body: io.NopCloser(strings.NewReader("forbidden")),
1473 Header: make(http.Header),
1474 }, nil
1475 })}
1476
1477 if _, err := fetchBytes(context.Background(), client, "https://example.invalid/latest.json"); err == nil {
1478 t.Fatal("fetchBytes should return a permanent HTTP error")
1479 }
1480 if got := calls.Load(); got != 1 {
1481 t.Fatalf("permanent HTTP error made %d requests, want 1", got)
1482 }
1483 }
1484
1485 func TestFetchBytesRejectsOversizeResponsesWithoutRetry(t *testing.T) {
1486 fastRetry(t)
1487 t.Run("declared content length", func(t *testing.T) {
1488 var calls atomic.Int32
1489 client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1490 calls.Add(1)
1491 return &http.Response{
1492 StatusCode: http.StatusOK,
1493 Status: "200 OK",
1494 ContentLength: 9,
1495 Body: io.NopCloser(strings.NewReader("ignored")),
1496 Header: make(http.Header),
1497 }, nil
1498 })}
1499 if _, err := fetchBytesFallbackForChannelSized(
1500 context.Background(),
1501 client,
1502 nil,
1503 "stable",
1504 "https://example.invalid/latest.json",
1505 8,
1506 ); !errors.Is(err, errUpdateResponseTooLarge) {
1507 t.Fatalf("declared oversize error = %v, want errUpdateResponseTooLarge", err)
1508 }
1509 if got := calls.Load(); got != 1 {
1510 t.Fatalf("declared oversize response made %d requests, want 1", got)
1511 }
1512 })
1513
1514 t.Run("chunked body", func(t *testing.T) {
1515 client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
1516 return &http.Response{
1517 StatusCode: http.StatusOK,
1518 Status: "200 OK",
1519 ContentLength: -1,
1520 Body: io.NopCloser(strings.NewReader("123456789")),
1521 Header: make(http.Header),
1522 }, nil
1523 })}
1524 if _, err := fetchBytesFallbackForChannelSized(
1525 context.Background(),
1526 client,
1527 nil,
1528 "stable",
1529 "https://example.invalid/latest.json",
1530 8,
1531 ); !errors.Is(err, errUpdateResponseTooLarge) {
1532 t.Fatalf("chunked oversize error = %v, want errUpdateResponseTooLarge", err)
1533 }
1534 })
1535 }
1536
1537 func TestDownloadRejectsAssetSizeAboveMaximum(t *testing.T) {
1538 if _, err := download(
1539 context.Background(),
1540 &http.Client{},
1541 nil,
1542 "https://dl.reasonix.io/file",
1543 maxDesktopReleaseAssetSize+1,
1544 nil,
1545 ); err == nil {
1546 t.Fatal("download accepted an asset size above the release maximum")
1547 }
1548 }
1549
1550 type rtFunc func(*http.Request) (*http.Response, error)
1551
1552 func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
1553
1553 lines GO