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