返回 DeepSeek-Reasonix
upgrade.go
根目录 / internal / cli / upgrade.go
1 package cli
2
3 import (
4 "archive/tar"
5 "archive/zip"
6 "bytes"
7 "compress/gzip"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/url"
16 "os"
17 "path/filepath"
18 "regexp"
19 "runtime"
20 "strings"
21 "time"
22
23 "reasonix/internal/config"
24 "reasonix/internal/i18n"
25 "reasonix/internal/netclient"
26
27 "github.com/spf13/pflag"
28 "golang.org/x/mod/semver"
29 )
30
31 const (
32 ghOwner = "esengine"
33 ghRepo = "DeepSeek-Reasonix"
34 ghAPIReleases = "https://api.github.com/repos/" + ghOwner + "/" + ghRepo + "/releases?per_page=100"
35 ghDownloadBase = "https://github.com/" + ghOwner + "/" + ghRepo + "/releases/download"
36 cliGatewayBase = "https://crash.reasonix.io/v1/cli/releases"
37 upgradeTimeout = 60 * time.Second
38 maxCLIReleaseAssetSize = int64(1 << 30)
39 )
40
41 // ghRelease is the subset of the GitHub release API response we need.
42 type ghRelease struct {
43 TagName string `json:"tag_name"`
44 Prerelease bool `json:"prerelease"`
45 Assets []ghAsset `json:"assets"`
46 }
47
48 // ghAsset is a single release asset.
49 type ghAsset struct {
50 Name string `json:"name"`
51 BrowserDownloadURL string `json:"browser_download_url"`
52 Size int64 `json:"size"`
53 }
54
55 type cliReleaseChannel string
56
57 const (
58 cliReleaseStable cliReleaseChannel = "stable"
59 )
60
61 var (
62 stableCLITagPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
63 requiredCLIAssets = [...]string{
64 "reasonix-darwin-amd64.tar.gz",
65 "reasonix-darwin-arm64.tar.gz",
66 "reasonix-linux-amd64.tar.gz",
67 "reasonix-linux-arm64.tar.gz",
68 "reasonix-windows-amd64.zip",
69 "reasonix-windows-arm64.zip",
70 "SHA256SUMS",
71 }
72 )
73
74 func parseCLIReleaseChannel(value string) (cliReleaseChannel, error) {
75 switch strings.ToLower(strings.TrimSpace(value)) {
76 case "", string(cliReleaseStable), "preview", "canary", "beta", "next":
77 return cliReleaseStable, nil
78 default:
79 return "", fmt.Errorf("release channel %q is unsupported; Reasonix now uses the official release", value)
80 }
81 }
82
83 type cliUpgradeSyntax struct {
84 checkOnly bool
85 force bool
86 positional *cliReleaseChannel
87 flagChannel *cliReleaseChannel
88 helpRequested bool
89 helpText string
90 }
91
92 // parseCLIUpgradeSyntax accepts the ergonomic positional channel while keeping
93 // --channel available for scripts. pflag's interspersed parsing allows both
94 // `upgrade preview --check` and `upgrade --check preview`.
95 func parseCLIUpgradeSyntax(args []string) (cliUpgradeSyntax, error) {
96 fs := pflag.NewFlagSet("upgrade", pflag.ContinueOnError)
97 fs.SetInterspersed(true)
98 var parseOutput bytes.Buffer
99 fs.SetOutput(&parseOutput)
100 checkOnly := fs.Bool("check", false, "check for updates without installing")
101 force := fs.Bool("force", false, "reinstall even if already on the latest version")
102 channelValue := fs.String("channel", "", "deprecated compatibility option; updates use the official release")
103 if err := fs.Parse(args); err != nil {
104 if errors.Is(err, pflag.ErrHelp) {
105 return cliUpgradeSyntax{helpRequested: true, helpText: parseOutput.String()}, nil
106 }
107 return cliUpgradeSyntax{}, err
108 }
109
110 var positional *cliReleaseChannel
111 if rest := fs.Args(); len(rest) > 1 {
112 return cliUpgradeSyntax{}, fmt.Errorf("upgrade accepts at most one deprecated positional channel")
113 } else if len(rest) == 1 {
114 channel, err := parseCLIReleaseChannel(rest[0])
115 if err != nil || strings.TrimSpace(rest[0]) == "" {
116 if err == nil {
117 err = fmt.Errorf("channel is required")
118 }
119 return cliUpgradeSyntax{}, err
120 }
121 positional = &channel
122 }
123
124 var flagChannel *cliReleaseChannel
125 if fs.Changed("channel") {
126 if strings.TrimSpace(*channelValue) == "" {
127 return cliUpgradeSyntax{}, fmt.Errorf("--channel requires a legacy channel value")
128 }
129 channel, err := parseCLIReleaseChannel(*channelValue)
130 if err != nil {
131 return cliUpgradeSyntax{}, err
132 }
133 flagChannel = &channel
134 }
135 if positional != nil && flagChannel != nil && *positional != *flagChannel {
136 return cliUpgradeSyntax{}, fmt.Errorf("conflicting release channels: positional %q and --channel %q", *positional, *flagChannel)
137 }
138 return cliUpgradeSyntax{
139 checkOnly: *checkOnly,
140 force: *force,
141 positional: positional,
142 flagChannel: flagChannel,
143 }, nil
144 }
145
146 func resolveCLIUpgradeChannel(syntax cliUpgradeSyntax, configured string) (cliReleaseChannel, bool, error) {
147 configuredChannel, err := parseCLIReleaseChannel(config.NormalizeCLIUpdateChannel(configured))
148 if err != nil {
149 return "", false, err
150 }
151 if syntax.positional != nil {
152 return *syntax.positional, *syntax.positional != configuredChannel, nil
153 }
154 if syntax.flagChannel != nil {
155 return *syntax.flagChannel, false, nil
156 }
157 return configuredChannel, false, nil
158 }
159
160 var persistCLIReleaseChannel = func(channel cliReleaseChannel) error {
161 path := config.UserConfigPath()
162 unlock, err := config.LockConfigFileEdits(path)
163 if err != nil {
164 return err
165 }
166 defer unlock()
167 cfg, err := config.LoadForEditReadOnlyStrict(path)
168 if err != nil {
169 return err
170 }
171 if err := cfg.SetCLIUpdateChannel(string(channel)); err != nil {
172 return err
173 }
174 return cfg.SaveTo(path)
175 }
176
177 var loadCLIUpgradeConfig = config.Load
178
179 // upgradeCommand handles `reasonix upgrade` (and `reasonix update`).
180 func upgradeCommand(args []string, version string) int {
181 syntax, err := parseCLIUpgradeSyntax(args)
182 if err != nil {
183 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
184 return 2
185 }
186 if syntax.helpRequested {
187 fmt.Fprint(os.Stdout, syntax.helpText)
188 return 0
189 }
190
191 // 1. Normalize running version.
192 cur, ok := normalizeVersion(version)
193 if !ok {
194 fmt.Fprintf(os.Stderr, "%s %s\n", i18n.M.ErrorPrefix, i18n.M.UpgradeDevBuild)
195 return 1
196 }
197
198 // 2. Build HTTP client using configured proxy.
199 cfg, err := loadCLIUpgradeConfig()
200 if err != nil {
201 fmt.Fprintf(os.Stderr, "%s cannot load config: %v\n", i18n.M.ErrorPrefix, err)
202 return 1
203 }
204 if cfg == nil {
205 fmt.Fprintf(os.Stderr, "%s cannot load config: empty result\n", i18n.M.ErrorPrefix)
206 return 1
207 }
208 legacyConfigChannel := strings.TrimSpace(cfg.CLI.UpdateChannel)
209 selectedChannel, persistChannel, err := resolveCLIUpgradeChannel(syntax, cfg.CLIUpdateChannel())
210 if err != nil {
211 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
212 return 2
213 }
214 if persistChannel || legacyConfigChannel != "" {
215 if err := persistCLIReleaseChannel(selectedChannel); err != nil {
216 fmt.Fprintf(os.Stderr, "%s cannot save CLI update channel: %v\n", i18n.M.ErrorPrefix, err)
217 return 1
218 }
219 }
220 if syntax.positional != nil || syntax.flagChannel != nil || legacyConfigChannel != "" {
221 fmt.Fprintln(os.Stderr, i18n.M.UpgradeChannelDeprecated)
222 }
223 spec := cfg.NetworkProxySpec()
224 c, err := netclient.NewHTTPClient(spec, netclient.TransportOptions{
225 ResponseHeaderTimeout: upgradeTimeout,
226 })
227 if err != nil {
228 fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
229 return 1
230 }
231 c.CheckRedirect = validateCLIUpgradeRedirect
232
233 // 3. Fetch latest release from GitHub API.
234 fmt.Println(i18n.M.UpgradeChecking)
235 rel, err := fetchLatestRelease(c, selectedChannel)
236 if err != nil {
237 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeFetchFailed+"\n", i18n.M.ErrorPrefix, err)
238 return 1
239 }
240
241 // 4. Compare versions.
242 latest := rel.TagName
243 if !strings.HasPrefix(latest, "v") {
244 latest = "v" + latest
245 }
246 if !semver.IsValid(latest) {
247 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeInvalidVersion+"\n", i18n.M.ErrorPrefix, latest)
248 return 1
249 }
250 sameChannel := versionBelongsToCLIChannel(cur, selectedChannel)
251 if latest == cur {
252 if syntax.force {
253 fmt.Println(i18n.M.UpgradeForcing)
254 } else {
255 fmt.Println(i18n.M.UpgradeAlreadyLatest)
256 return 0
257 }
258 } else if !sameChannel || semver.Compare(latest, cur) > 0 {
259 fmt.Printf(i18n.M.UpgradeAvailableFmt+"\n", cur, latest)
260 } else if syntax.force {
261 fmt.Println(i18n.M.UpgradeForcing)
262 } else {
263 fmt.Println(i18n.M.UpgradeAlreadyLatest)
264 return 0
265 }
266
267 if syntax.checkOnly {
268 return 0
269 }
270
271 // 5. Find the asset for the current platform.
272 base := fmt.Sprintf("reasonix-%s-%s", runtime.GOOS, runtime.GOARCH)
273 asset := findCLIPlatformAsset(rel, runtime.GOOS, runtime.GOARCH)
274 if asset == nil {
275 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeNoAssetFmt+"\n", i18n.M.ErrorPrefix, base)
276 return 1
277 }
278
279 // 6. Find the checksum asset from the same validated release metadata. Do
280 // not synthesize a URL: the manifest's exact URL and size are part of the
281 // release trust boundary.
282 checksumAsset := findCLIReleaseAsset(rel, "SHA256SUMS")
283 if checksumAsset == nil {
284 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeChecksumFailed+"\n", i18n.M.ErrorPrefix, errors.New("release is missing a valid SHA256SUMS asset"))
285 return 1
286 }
287
288 // 7. Download archive.
289 fmt.Printf(i18n.M.UpgradeDownloadingFmt+"\n", asset.Name, humanSize(asset.Size))
290 archiveData, err := fetchBytesSized(c, asset.BrowserDownloadURL, asset.Size)
291 if err != nil {
292 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeDownloadFailed+"\n", i18n.M.ErrorPrefix, err)
293 return 1
294 }
295
296 // 8. Verify SHA256 checksum — fail closed: abort on any verification error.
297 fmt.Println(i18n.M.UpgradeVerifying)
298 checksumData, err := fetchBytesSized(c, checksumAsset.BrowserDownloadURL, checksumAsset.Size)
299 if err != nil {
300 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeChecksumFailed+"\n", i18n.M.ErrorPrefix, err)
301 return 1
302 }
303 if err := verifyChecksum(archiveData, asset.Name, checksumData); err != nil {
304 fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
305 return 1
306 }
307
308 // 9. Extract binary from archive.
309 binName := "reasonix"
310 if runtime.GOOS == "windows" {
311 binName = "reasonix.exe"
312 }
313 binary, err := extractBinary(archiveData, asset.Name, binName)
314 if err != nil {
315 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeExtractFailed+"\n", i18n.M.ErrorPrefix, err)
316 return 1
317 }
318
319 // 10. Replace the running binary.
320 fmt.Println(i18n.M.UpgradeApplying)
321 if err := replaceBinary(binary); err != nil {
322 fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeApplyFailed+"\n", i18n.M.ErrorPrefix, err)
323 return 1
324 }
325
326 fmt.Println(upgradeSuccessMessage(cur, latest))
327 return 0
328 }
329
330 func upgradeSuccessMessage(cur, latest string) string {
331 return fmt.Sprintf(i18n.M.UpgradeSuccessFmt, cur, latest)
332 }
333
334 // normalizeVersion returns v as valid semver ("vX.Y.Z") or ok=false for dev.
335 func normalizeVersion(v string) (string, bool) {
336 v = strings.TrimSpace(v)
337 if v == "" || v == "dev" {
338 return "", false
339 }
340 if !strings.HasPrefix(v, "v") {
341 v = "v" + v
342 }
343 if !semver.IsValid(v) {
344 return "", false
345 }
346 return semver.Canonical(v), true
347 }
348
349 // isCLITag reports whether a tag belongs to the CLI release namespace (v*).
350 // Tags like "desktop-v1.5.0" or "npm-v1.4.0" are excluded.
351 func isCLITag(tag string) bool {
352 tag = strings.TrimSpace(tag)
353 return len(tag) >= 2 && tag[0] == 'v' && tag[1] >= '0' && tag[1] <= '9'
354 }
355
356 func versionBelongsToCLIChannel(version string, channel cliReleaseChannel) bool {
357 return channel == cliReleaseStable && stableCLITagPattern.MatchString(version)
358 }
359
360 func releaseBelongsToCLIChannel(rel ghRelease, channel cliReleaseChannel) bool {
361 if !isCLITag(rel.TagName) || !versionBelongsToCLIChannel(rel.TagName, channel) {
362 return false
363 }
364 return !rel.Prerelease
365 }
366
367 func isHTTPSDownloadURL(raw string) bool {
368 parsed, err := url.Parse(strings.TrimSpace(raw))
369 return err == nil &&
370 parsed.Scheme == "https" &&
371 parsed.Hostname() != "" &&
372 parsed.User == nil
373 }
374
375 func isExpectedCLIAssetURL(raw, tag, name string) bool {
376 if !isHTTPSDownloadURL(raw) {
377 return false
378 }
379 parsed, err := url.Parse(strings.TrimSpace(raw))
380 if err != nil {
381 return false
382 }
383 expectedPath := fmt.Sprintf("/%s/%s/releases/download/%s/%s", ghOwner, ghRepo, tag, name)
384 return strings.EqualFold(parsed.Hostname(), "github.com") &&
385 parsed.Port() == "" &&
386 parsed.EscapedPath() == expectedPath &&
387 parsed.RawQuery == "" &&
388 parsed.Fragment == ""
389 }
390
391 func isTrustedCLIUpgradeRedirectHost(host string) bool {
392 host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
393 return host == "github.com" || strings.HasSuffix(host, ".githubusercontent.com")
394 }
395
396 func validateCLIUpgradeRedirect(req *http.Request, via []*http.Request) error {
397 if len(via) >= 10 {
398 return errors.New("upgrade: stopped after 10 redirects")
399 }
400 if req == nil || req.URL == nil {
401 return errors.New("upgrade: redirect has no target URL")
402 }
403 if !strings.EqualFold(req.URL.Scheme, "https") {
404 return fmt.Errorf("upgrade: refusing redirect to non-HTTPS URL %q", req.URL.String())
405 }
406 if req.URL.Hostname() == "" {
407 return fmt.Errorf("upgrade: refusing redirect without a hostname %q", req.URL.String())
408 }
409 if req.URL.User != nil {
410 return fmt.Errorf("upgrade: refusing redirect with userinfo %q", req.URL.String())
411 }
412 if req.URL.Port() != "" || !isTrustedCLIUpgradeRedirectHost(req.URL.Hostname()) {
413 return fmt.Errorf("upgrade: refusing redirect to untrusted host %q", req.URL.Host)
414 }
415 return nil
416 }
417
418 func validCLIAssetSize(size int64) bool {
419 return size > 0 && size <= maxCLIReleaseAssetSize
420 }
421
422 func releaseHasCompleteCLIAssets(rel ghRelease) bool {
423 required := make(map[string]struct{}, len(requiredCLIAssets))
424 for _, name := range requiredCLIAssets {
425 required[name] = struct{}{}
426 }
427 assets := make(map[string]bool, len(requiredCLIAssets))
428 seen := make(map[string]bool, len(requiredCLIAssets))
429 for _, asset := range rel.Assets {
430 if _, ok := required[asset.Name]; !ok {
431 continue
432 }
433 if seen[asset.Name] {
434 return false
435 }
436 seen[asset.Name] = true
437 if validCLIAssetSize(asset.Size) &&
438 isExpectedCLIAssetURL(asset.BrowserDownloadURL, rel.TagName, asset.Name) {
439 assets[asset.Name] = true
440 }
441 }
442 for _, name := range requiredCLIAssets {
443 if !assets[name] {
444 return false
445 }
446 }
447 return true
448 }
449
450 func cliPlatformAssetName(goos, goarch string) string {
451 suffix := ".tar.gz"
452 if goos == "windows" {
453 suffix = ".zip"
454 }
455 return fmt.Sprintf("reasonix-%s-%s%s", goos, goarch, suffix)
456 }
457
458 func findCLIPlatformAsset(rel *ghRelease, goos, goarch string) *ghAsset {
459 return findCLIReleaseAsset(rel, cliPlatformAssetName(goos, goarch))
460 }
461
462 func findCLIReleaseAsset(rel *ghRelease, name string) *ghAsset {
463 if rel == nil {
464 return nil
465 }
466 for i := range rel.Assets {
467 if rel.Assets[i].Name == name &&
468 validCLIAssetSize(rel.Assets[i].Size) &&
469 isExpectedCLIAssetURL(rel.Assets[i].BrowserDownloadURL, rel.TagName, name) {
470 return &rel.Assets[i]
471 }
472 }
473 return nil
474 }
475
476 // pickCLIRelease selects the highest strict tag in the requested public channel.
477 // Generic prereleases such as RCs remain internal and can never leak into Stable
478 // or masquerade as Preview. Incomplete releases are skipped so an interrupted
479 // publication cannot hide the previous complete release.
480 func pickCLIRelease(rels []ghRelease, channel cliReleaseChannel) *ghRelease {
481 best := -1
482 for i := range rels {
483 if !releaseBelongsToCLIChannel(rels[i], channel) || !releaseHasCompleteCLIAssets(rels[i]) {
484 continue
485 }
486 if best == -1 || semver.Compare(rels[i].TagName, rels[best].TagName) > 0 {
487 best = i
488 }
489 }
490 if best == -1 {
491 return nil
492 }
493 return &rels[best]
494 }
495
496 // githubAPIToken returns the token to authenticate release lookups with.
497 // Anonymous GitHub API requests share a 60/hour quota per IP, which a NAT or
498 // office network exhausts long before one user's upgrades do (#4449).
499 func githubAPIToken() string {
500 for _, name := range []string{"GITHUB_TOKEN", "GH_TOKEN"} {
501 if v := strings.TrimSpace(os.Getenv(name)); v != "" {
502 return v
503 }
504 }
505 return ""
506 }
507
508 // githubRateLimitHint names the fix when a refusal is the anonymous quota
509 // rather than a broken request.
510 func githubRateLimitHint(resp *http.Response) string {
511 if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests {
512 return ""
513 }
514 if resp.Header.Get("X-RateLimit-Remaining") != "0" {
515 return ""
516 }
517 if githubAPIToken() != "" {
518 return " (rate limited; retry after the window resets)"
519 }
520 return " (rate limited; set GITHUB_TOKEN to raise the quota)"
521 }
522
523 // fetchLatestRelease queries the GitHub Releases API and returns the newest
524 // strict CLI release in the selected public channel.
525 func fetchLatestRelease(c *http.Client, channel cliReleaseChannel) (*ghRelease, error) {
526 pointerURL := fmt.Sprintf("%s/%s/latest.json", cliGatewayBase, channel)
527 pointerRelease, pointerErr := fetchCLIReleasePointer(c, pointerURL, channel)
528 if pointerErr == nil {
529 return pointerRelease, nil
530 }
531
532 req, err := http.NewRequest("GET", ghAPIReleases, nil)
533 if err != nil {
534 return nil, err
535 }
536 req.Header.Set("Accept", "application/vnd.github+json")
537 req.Header.Set("User-Agent", "reasonix-cli")
538 if token := githubAPIToken(); token != "" {
539 req.Header.Set("Authorization", "Bearer "+token)
540 }
541
542 resp, err := c.Do(req)
543 if err != nil {
544 return nil, err
545 }
546 defer resp.Body.Close()
547 if resp.StatusCode != http.StatusOK {
548 return nil, fmt.Errorf("release gateway: %v; GitHub API: %s%s", pointerErr, resp.Status, githubRateLimitHint(resp))
549 }
550
551 var rels []ghRelease
552 if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
553 return nil, err
554 }
555
556 if rel := pickCLIRelease(rels, channel); rel != nil {
557 return rel, nil
558 }
559 return nil, fmt.Errorf("release gateway: %v; no %s CLI release found in recent GitHub releases", pointerErr, channel)
560 }
561
562 func fetchCLIReleasePointer(c *http.Client, pointerURL string, channel cliReleaseChannel) (*ghRelease, error) {
563 req, err := http.NewRequest("GET", pointerURL, nil)
564 if err != nil {
565 return nil, err
566 }
567 req.Header.Set("Accept", "application/json")
568 req.Header.Set("User-Agent", "reasonix-cli")
569
570 resp, err := c.Do(req)
571 if err != nil {
572 return nil, err
573 }
574 defer resp.Body.Close()
575 if resp.StatusCode != http.StatusOK {
576 return nil, fmt.Errorf("%s", resp.Status)
577 }
578
579 var rel ghRelease
580 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
581 return nil, err
582 }
583 if !releaseBelongsToCLIChannel(rel, channel) {
584 return nil, fmt.Errorf("pointer tag %q does not belong to %s", rel.TagName, channel)
585 }
586 if !releaseHasCompleteCLIAssets(rel) {
587 return nil, fmt.Errorf("pointer tag %q is missing required CLI assets", rel.TagName)
588 }
589 return &rel, nil
590 }
591
592 func fetchBytesSized(c *http.Client, url string, expectedSize int64) ([]byte, error) {
593 if !validCLIAssetSize(expectedSize) {
594 return nil, fmt.Errorf("GET %s: invalid expected asset size %d", url, expectedSize)
595 }
596 resp, err := c.Get(url)
597 if err != nil {
598 return nil, err
599 }
600 defer resp.Body.Close()
601 if resp.StatusCode != http.StatusOK {
602 return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
603 }
604 data, err := io.ReadAll(io.LimitReader(resp.Body, expectedSize+1))
605 if err != nil {
606 return nil, err
607 }
608 if int64(len(data)) != expectedSize {
609 return nil, fmt.Errorf("GET %s: downloaded size mismatch: got %d want %d", url, len(data), expectedSize)
610 }
611 return data, nil
612 }
613
614 // verifyChecksum checks that data's SHA256 matches the entry for fileName in
615 // the SHA256SUMS-format checksum file.
616 func verifyChecksum(data []byte, fileName string, checksumFile []byte) error {
617 sum := sha256.Sum256(data)
618 got := hex.EncodeToString(sum[:])
619
620 for _, line := range strings.Split(strings.TrimSpace(string(checksumFile)), "\n") {
621 line = strings.TrimSpace(line)
622 if line == "" {
623 continue
624 }
625 parts := strings.Fields(line)
626 if len(parts) >= 2 && parts[1] == fileName {
627 if !strings.EqualFold(parts[0], got) {
628 return fmt.Errorf(i18n.M.UpgradeChecksumMismatchFmt, got, parts[0])
629 }
630 return nil
631 }
632 }
633 return fmt.Errorf(i18n.M.UpgradeChecksumNotFoundFmt, fileName)
634 }
635
636 // extractBinary pulls the "reasonix" binary from a .tar.gz or .zip archive.
637 func extractBinary(data []byte, archiveName, binaryName string) ([]byte, error) {
638 if strings.HasSuffix(archiveName, ".zip") {
639 return extractFromZip(data, binaryName)
640 }
641 return extractFromTarGz(data, binaryName)
642 }
643
644 // extractFromTarGz extracts a named binary from a .tar.gz archive.
645 func extractFromTarGz(data []byte, name string) ([]byte, error) {
646 gz, err := gzip.NewReader(bytes.NewReader(data))
647 if err != nil {
648 return nil, err
649 }
650 defer gz.Close()
651 tr := tar.NewReader(gz)
652 for {
653 h, err := tr.Next()
654 if err == io.EOF {
655 break
656 }
657 if err != nil {
658 return nil, err
659 }
660 if h.Typeflag == tar.TypeReg && (h.Name == name || strings.HasSuffix(h.Name, "/"+name)) {
661 return io.ReadAll(tr)
662 }
663 }
664 return nil, fmt.Errorf("%q not found in archive", name)
665 }
666
667 // extractFromZip extracts a named binary from a .zip archive (Windows).
668 func extractFromZip(data []byte, name string) ([]byte, error) {
669 r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
670 if err != nil {
671 return nil, err
672 }
673 for _, f := range r.File {
674 if f.FileInfo().IsDir() {
675 continue
676 }
677 base := filepath.Base(f.Name)
678 if base == name {
679 rc, err := f.Open()
680 if err != nil {
681 return nil, err
682 }
683 defer rc.Close()
684 return io.ReadAll(rc)
685 }
686 }
687 return nil, fmt.Errorf("%q not found in zip archive", name)
688 }
689
690 // replaceBinary writes newBin to the running executable's path atomically.
691 //
692 // On Unix this is a simple temp-file + rename. On Windows the running
693 // executable is memory-mapped and cannot be overwritten directly, so we
694 // rename it aside to .reasonix.old first, then place the new binary.
695 // The .old file is cleaned up best-effort (Windows may still hold a lock
696 // on it; we hide it in that case).
697 func replaceBinary(newBin []byte) error {
698 exe, err := os.Executable()
699 if err != nil {
700 return fmt.Errorf("locate executable: %w", err)
701 }
702 resolved, err := resolveSymlinks(exe)
703 if err != nil {
704 return fmt.Errorf("resolve symlinks: %w", err)
705 }
706
707 dir := filepath.Dir(resolved)
708 base := filepath.Base(resolved)
709 tmpPath := filepath.Join(dir, fmt.Sprintf(".%s.new", base))
710
711 // Write new binary to .new temp file.
712 if err := os.WriteFile(tmpPath, newBin, 0o755); err != nil {
713 os.Remove(tmpPath)
714 return fmt.Errorf("write temp: %w", err)
715 }
716
717 if runtime.GOOS == "windows" {
718 return commitWindows(resolved, tmpPath, base, dir)
719 }
720
721 // Unix: atomic rename .new → target.
722 if err := os.Rename(tmpPath, resolved); err != nil {
723 os.Remove(tmpPath)
724 return fmt.Errorf("rename: %w", err)
725 }
726 return nil
727 }
728
729 // commitWindows performs the two-phase rename on Windows:
730 // 1. Rename running exe → .old (allowed while running)
731 // 2. Rename .new → target
732 // 3. Best-effort remove .old (hide if still locked)
733 func commitWindows(target, newPath, base, dir string) error {
734 oldPath := filepath.Join(dir, fmt.Sprintf(".%s.old", base))
735
736 // Remove any leftover .old from a previous update.
737 _ = os.Remove(oldPath)
738
739 // Move the running executable aside.
740 if err := os.Rename(target, oldPath); err != nil {
741 os.Remove(newPath)
742 return fmt.Errorf("rename running exe aside: %w", err)
743 }
744
745 // Move the new binary into place.
746 if err := os.Rename(newPath, target); err != nil {
747 // Rollback: try to restore the old binary.
748 if rerr := os.Rename(oldPath, target); rerr != nil {
749 return fmt.Errorf("replace failed (%v); rollback also failed: %w", err, rerr)
750 }
751 return fmt.Errorf("rename new binary: %w", err)
752 }
753
754 // Best-effort cleanup of the old binary.
755 if err := os.Remove(oldPath); err != nil {
756 // Windows may hold a lock; hide the file so it doesn't clutter the dir.
757 hideFileWindows(oldPath)
758 }
759 return nil
760 }
761
762 // resolveSymlinks follows symlinks; falls back to the original path on error.
763 func resolveSymlinks(p string) (string, error) {
764 r, err := filepath.EvalSymlinks(p)
765 if err != nil {
766 return p, nil
767 }
768 return r, nil
769 }
770
771 // humanSize returns a human-readable byte size.
772 func humanSize(b int64) string {
773 const (
774 _KiB = 1024
775 _MiB = 1024 * _KiB
776 )
777 switch {
778 case b >= _MiB:
779 return fmt.Sprintf("%.1f MiB", float64(b)/float64(_MiB))
780 case b >= _KiB:
781 return fmt.Sprintf("%.1f KiB", float64(b)/float64(_KiB))
782 default:
783 return fmt.Sprintf("%d B", b)
784 }
785 }
786
786 lines GO