master
go 848 lines 24.4 KB
Raw
1 package commands
2
3 import (
4 "archive/tar"
5 "archive/zip"
6 "bytes"
7 "compress/gzip"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "path/filepath"
14 "slices"
15 "strings"
16 "time"
17
18 goversion "github.com/hashicorp/go-version"
19 cmds "github.com/ipfs/go-ipfs-cmds"
20 version "github.com/ipfs/kubo"
21 "github.com/ipfs/kubo/repo/fsrepo"
22 "github.com/ipfs/kubo/repo/fsrepo/migrations"
23 "github.com/ipfs/kubo/repo/fsrepo/migrations/atomicfile"
24 )
25
26 const (
27 updatePreOptionName = "pre"
28 updateCountOptionName = "count"
29 updateAllowDowngradeOptionName = "allow-downgrade"
30
31 // updateDefaultTimeout is the fallback timeout for update operations
32 // when the user does not pass --timeout. One hour allows for slow
33 // connections downloading ~50 MB archives.
34 updateDefaultTimeout = 1 * time.Hour
35
36 // maxBinarySize caps the decompressed binary size to prevent zip/tar
37 // bombs. Current kubo binary is ~120 MB uncompressed; 1 GB leaves
38 // room for growth while catching decompression attacks.
39 maxBinarySize = 1 << 30
40
41 // stashDirName is the directory under $IPFS_PATH where backups of
42 // previously installed Kubo binaries are kept so 'update revert' can
43 // restore them and 'update clean' can free the space.
44 stashDirName = "old-bin"
45 )
46
47 // UpdateCmd is the "ipfs update" command tree.
48 var UpdateCmd = &cmds.Command{
49 Status: cmds.Experimental,
50 Helptext: cmds.HelpText{
51 Tagline: "Update Kubo to a different version",
52 ShortDescription: `
53 Downloads pre-built Kubo binaries from GitHub Releases, verifies
54 checksums, and replaces the running binary in place. The previous
55 binary is saved so you can revert if needed.
56
57 The daemon must be stopped before installing or reverting.
58 `,
59 LongDescription: `
60 Downloads pre-built Kubo binaries from GitHub Releases, verifies
61 checksums, and replaces the running binary in place. The previous
62 binary is saved so you can revert if needed.
63
64 The daemon must be stopped before installing or reverting.
65
66 ENVIRONMENT VARIABLES
67
68 HTTPS_PROXY
69 HTTP proxy for reaching GitHub. Set this when GitHub is not
70 directly reachable from your network.
71 Example: HTTPS_PROXY=http://proxy:8080 ipfs update install
72
73 GITHUB_TOKEN
74 GitHub personal access token. Raises the API rate limit from
75 60 to 5000 requests per hour. Set this if you hit "rate limit
76 exceeded" errors. GH_TOKEN is also accepted.
77
78 IPFS_PATH
79 Determines where binary backups are stored ($IPFS_PATH/old-bin/).
80 Defaults to ~/.ipfs.
81 `,
82 },
83 NoRemote: true,
84 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
85 Subcommands: map[string]*cmds.Command{
86 "check": updateCheckCmd,
87 "versions": updateVersionsCmd,
88 "install": updateInstallCmd,
89 "revert": updateRevertCmd,
90 "clean": updateCleanCmd,
91 },
92 }
93
94 // -- check --
95
96 // UpdateCheckOutput is the output of "ipfs update check".
97 type UpdateCheckOutput struct {
98 CurrentVersion string
99 LatestVersion string
100 UpdateAvailable bool
101 }
102
103 var updateCheckCmd = &cmds.Command{
104 Status: cmds.Experimental,
105 Helptext: cmds.HelpText{
106 Tagline: "Check if a newer Kubo version is available",
107 ShortDescription: `
108 Queries GitHub Releases for the latest Kubo version and compares
109 it against the currently running binary. Only considers releases
110 with binaries available for your operating system and architecture.
111
112 Works while the daemon is running (read-only, no repo access).
113
114 ENVIRONMENT VARIABLES
115
116 HTTPS_PROXY HTTP proxy for reaching GitHub API.
117 GITHUB_TOKEN Raises the API rate limit (GH_TOKEN also accepted).
118 `,
119 },
120 NoRemote: true,
121 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
122 Options: []cmds.Option{
123 cmds.BoolOption(updatePreOptionName, "Include pre-release versions."),
124 },
125 Type: UpdateCheckOutput{},
126 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
127 ctx, cancel := updateContext(req)
128 defer cancel()
129 includePre, _ := req.Options[updatePreOptionName].(bool)
130
131 rel, err := githubLatestRelease(ctx, includePre)
132 if err != nil {
133 return fmt.Errorf("checking for updates: %w", err)
134 }
135
136 latest := trimVPrefix(rel.TagName)
137 current := currentVersion()
138
139 updateAvailable, err := isNewerVersion(current, latest)
140 if err != nil {
141 return err
142 }
143
144 return cmds.EmitOnce(res, &UpdateCheckOutput{
145 CurrentVersion: current,
146 LatestVersion: latest,
147 UpdateAvailable: updateAvailable,
148 })
149 },
150 Encoders: cmds.EncoderMap{
151 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *UpdateCheckOutput) error {
152 if out.UpdateAvailable {
153 fmt.Fprintf(w, "Update available: %s -> %s\n", out.CurrentVersion, out.LatestVersion)
154 fmt.Fprintln(w, "Run 'ipfs update install' to install the latest version.")
155 } else {
156 fmt.Fprintf(w, "Already up to date (%s)\n", out.CurrentVersion)
157 }
158 return nil
159 }),
160 },
161 }
162
163 // -- versions --
164
165 // UpdateVersionsOutput is the output of "ipfs update versions".
166 type UpdateVersionsOutput struct {
167 Current string
168 Versions []string
169 }
170
171 var updateVersionsCmd = &cmds.Command{
172 Status: cmds.Experimental,
173 Helptext: cmds.HelpText{
174 Tagline: "List available Kubo versions",
175 ShortDescription: `
176 Lists Kubo versions published on GitHub Releases. The currently
177 running version is marked with an asterisk (*).
178 `,
179 },
180 NoRemote: true,
181 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
182 Options: []cmds.Option{
183 cmds.IntOption(updateCountOptionName, "n", "Number of versions to list.").WithDefault(30),
184 cmds.BoolOption(updatePreOptionName, "Include pre-release versions."),
185 },
186 Type: UpdateVersionsOutput{},
187 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
188 ctx, cancel := updateContext(req)
189 defer cancel()
190 count, _ := req.Options[updateCountOptionName].(int)
191 if count <= 0 {
192 count = 30
193 }
194 includePre, _ := req.Options[updatePreOptionName].(bool)
195
196 releases, err := githubListReleases(ctx, count, includePre)
197 if err != nil {
198 return fmt.Errorf("listing versions: %w", err)
199 }
200
201 versions := make([]string, 0, len(releases))
202 for _, r := range releases {
203 versions = append(versions, trimVPrefix(r.TagName))
204 }
205
206 return cmds.EmitOnce(res, &UpdateVersionsOutput{
207 Current: currentVersion(),
208 Versions: versions,
209 })
210 },
211 Encoders: cmds.EncoderMap{
212 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *UpdateVersionsOutput) error {
213 for _, v := range out.Versions {
214 marker := " "
215 if v == out.Current {
216 marker = "* "
217 }
218 fmt.Fprintf(w, "%s%s\n", marker, v)
219 }
220 return nil
221 }),
222 },
223 }
224
225 // -- install --
226
227 // UpdateInstallOutput is the output of "ipfs update install".
228 type UpdateInstallOutput struct {
229 OldVersion string
230 NewVersion string
231 BinaryPath string
232 StashedTo string
233 }
234
235 var updateInstallCmd = &cmds.Command{
236 Status: cmds.Experimental,
237 Helptext: cmds.HelpText{
238 Tagline: "Download and install a Kubo update",
239 ShortDescription: `
240 Downloads the specified version (or latest) from GitHub Releases,
241 verifies the SHA-512 checksum, saves a backup of the current binary,
242 and atomically replaces it.
243
244 If replacing the binary fails due to file permissions, the new binary
245 is saved to a temporary directory and the path is printed so you can
246 move it manually (e.g. with sudo).
247
248 Previous binaries are kept in $IPFS_PATH/old-bin/ and can be
249 restored with 'ipfs update revert'.
250 `,
251 },
252 NoRemote: true,
253 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
254 Arguments: []cmds.Argument{
255 cmds.StringArg("version", false, false, "Version to install (default: latest)."),
256 },
257 Options: []cmds.Option{
258 cmds.BoolOption(updatePreOptionName, "Include pre-release versions when resolving latest."),
259 cmds.BoolOption(updateAllowDowngradeOptionName, "Allow installing an older version."),
260 },
261 Type: UpdateInstallOutput{},
262 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
263 ctx, cancel := updateContext(req)
264 defer cancel()
265
266 if err := checkDaemonNotRunning(); err != nil {
267 return err
268 }
269
270 current := currentVersion()
271 includePre, _ := req.Options[updatePreOptionName].(bool)
272 allowDowngrade, _ := req.Options[updateAllowDowngradeOptionName].(bool)
273
274 // Resolve target version.
275 var tag string
276 if len(req.Arguments) > 0 && req.Arguments[0] != "" {
277 tag = normalizeVersion(req.Arguments[0])
278 } else {
279 rel, err := githubLatestRelease(ctx, includePre)
280 if err != nil {
281 return fmt.Errorf("finding latest release: %w", err)
282 }
283 tag = rel.TagName
284 }
285 target := trimVPrefix(tag)
286
287 // Compare versions.
288 if target == current {
289 return fmt.Errorf("already running version %s", current)
290 }
291
292 newer, err := isNewerVersion(current, target)
293 if err != nil {
294 return err
295 }
296 if !newer && !allowDowngrade {
297 return fmt.Errorf("version %s is older than current %s (use --allow-downgrade to force)", target, current)
298 }
299
300 // Download, verify, and extract before touching the current binary.
301 fmt.Fprintf(os.Stderr, "Downloading Kubo %s...\n", target)
302
303 _, asset, err := findReleaseAsset(ctx, normalizeVersion(target))
304 if err != nil {
305 return err
306 }
307
308 data, err := downloadAsset(ctx, asset.BrowserDownloadURL)
309 if err != nil {
310 return err
311 }
312
313 if err := downloadAndVerifySHA512(ctx, data, asset.BrowserDownloadURL); err != nil {
314 return fmt.Errorf("checksum verification failed: %w", err)
315 }
316 fmt.Fprintln(os.Stderr, "Checksum verified (SHA-512).")
317
318 binData, err := extractBinaryFromArchive(data)
319 if err != nil {
320 return fmt.Errorf("extracting binary: %w", err)
321 }
322
323 // Resolve current binary path.
324 binPath, err := os.Executable()
325 if err != nil {
326 return fmt.Errorf("finding current binary: %w", err)
327 }
328 binPath, err = filepath.EvalSymlinks(binPath)
329 if err != nil {
330 return fmt.Errorf("resolving binary path: %w", err)
331 }
332
333 // Stash current binary, then replace it.
334 stashedTo, err := stashBinary(binPath, current)
335 if err != nil {
336 return fmt.Errorf("backing up current binary: %w", err)
337 }
338 fmt.Fprintf(os.Stderr, "Backed up current binary to %s\n", stashedTo)
339
340 if err := replaceBinary(binPath, binData); err != nil {
341 // Permission error fallback: save to a unique temp file.
342 if errors.Is(err, os.ErrPermission) {
343 tmpPath, writeErr := writeBinaryToTempFile(binData, target)
344 if writeErr != nil {
345 return fmt.Errorf("cannot write fallback binary: %w (original error: %v)", writeErr, err)
346 }
347 fmt.Fprintf(os.Stderr, "Could not replace %s (permission denied).\n", binPath)
348 fmt.Fprintf(os.Stderr, "New binary saved to: %s\n", tmpPath)
349 fmt.Fprintf(os.Stderr, "Move it manually, e.g.: sudo mv %s %s\n", tmpPath, binPath)
350 return cmds.EmitOnce(res, &UpdateInstallOutput{
351 OldVersion: current,
352 NewVersion: target,
353 BinaryPath: tmpPath,
354 StashedTo: stashedTo,
355 })
356 }
357 return fmt.Errorf("replacing binary: %w", err)
358 }
359
360 fmt.Fprintf(os.Stderr, "Successfully updated Kubo %s -> %s\n", current, target)
361
362 return cmds.EmitOnce(res, &UpdateInstallOutput{
363 OldVersion: current,
364 NewVersion: target,
365 BinaryPath: binPath,
366 StashedTo: stashedTo,
367 })
368 },
369 Encoders: cmds.EncoderMap{
370 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *UpdateInstallOutput) error {
371 // All status output goes to stderr in Run; text encoder is a no-op.
372 return nil
373 }),
374 },
375 }
376
377 // -- revert --
378
379 // UpdateRevertOutput is the output of "ipfs update revert".
380 type UpdateRevertOutput struct {
381 RestoredVersion string
382 BinaryPath string
383 }
384
385 var updateRevertCmd = &cmds.Command{
386 Status: cmds.Experimental,
387 Helptext: cmds.HelpText{
388 Tagline: "Revert to a previously installed Kubo version",
389 ShortDescription: `
390 Restores the most recently backed up binary from $IPFS_PATH/old-bin/.
391 The backup is created automatically by 'ipfs update install'.
392 `,
393 },
394 NoRemote: true,
395 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
396 Type: UpdateRevertOutput{},
397 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
398 if err := checkDaemonNotRunning(); err != nil {
399 return err
400 }
401
402 stashDir, err := getStashDir()
403 if err != nil {
404 return err
405 }
406
407 stashPath, stashVer, err := findLatestStash(stashDir)
408 if err != nil {
409 return err
410 }
411
412 stashData, err := os.ReadFile(stashPath)
413 if err != nil {
414 return fmt.Errorf("reading stashed binary: %w", err)
415 }
416
417 binPath, err := os.Executable()
418 if err != nil {
419 return fmt.Errorf("finding current binary: %w", err)
420 }
421 binPath, err = filepath.EvalSymlinks(binPath)
422 if err != nil {
423 return fmt.Errorf("resolving binary path: %w", err)
424 }
425
426 if err := replaceBinary(binPath, stashData); err != nil {
427 if errors.Is(err, os.ErrPermission) {
428 tmpPath, writeErr := writeBinaryToTempFile(stashData, stashVer)
429 if writeErr != nil {
430 return fmt.Errorf("cannot write fallback binary: %w (original error: %v)", writeErr, err)
431 }
432 fmt.Fprintf(os.Stderr, "Could not replace %s (permission denied).\n", binPath)
433 fmt.Fprintf(os.Stderr, "Reverted binary saved to: %s\n", tmpPath)
434 fmt.Fprintf(os.Stderr, "Move it manually, e.g.: sudo mv %s %s\n", tmpPath, binPath)
435 return cmds.EmitOnce(res, &UpdateRevertOutput{
436 RestoredVersion: stashVer,
437 BinaryPath: tmpPath,
438 })
439 }
440 return fmt.Errorf("replacing binary: %w", err)
441 }
442
443 // Remove the stash file that was restored.
444 os.Remove(stashPath)
445
446 fmt.Fprintf(os.Stderr, "Reverted to Kubo %s\n", stashVer)
447
448 return cmds.EmitOnce(res, &UpdateRevertOutput{
449 RestoredVersion: stashVer,
450 BinaryPath: binPath,
451 })
452 },
453 Encoders: cmds.EncoderMap{
454 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *UpdateRevertOutput) error {
455 return nil
456 }),
457 },
458 }
459
460 // -- clean --
461
462 // UpdateCleanOutput is the output of "ipfs update clean".
463 type UpdateCleanOutput struct {
464 Removed []string
465 BytesFreed int64
466 }
467
468 var updateCleanCmd = &cmds.Command{
469 Status: cmds.Experimental,
470 Helptext: cmds.HelpText{
471 Tagline: "Remove backups of previous Kubo versions",
472 ShortDescription: `
473 Deletes every backed-up Kubo binary from $IPFS_PATH/old-bin/ to free
474 disk space. After running this, 'ipfs update revert' will have nothing
475 to roll back to.
476
477 Files in $IPFS_PATH/old-bin/ that do not match the 'ipfs-<version>'
478 naming convention are left untouched.
479
480 Safe to run while the daemon is up: only the backup directory is
481 touched, never the running binary.
482 `,
483 },
484 NoRemote: true,
485 Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
486 Type: UpdateCleanOutput{},
487 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
488 repoPath, err := fsrepo.BestKnownPath()
489 if err != nil {
490 return fmt.Errorf("determining IPFS path: %w", err)
491 }
492 dir := filepath.Join(repoPath, stashDirName)
493
494 stashes, err := listStashes(dir)
495 if err != nil {
496 // A missing stash directory just means there is nothing to clean.
497 if errors.Is(err, os.ErrNotExist) {
498 return cmds.EmitOnce(res, &UpdateCleanOutput{})
499 }
500 return fmt.Errorf("reading stash directory: %w", err)
501 }
502
503 out := &UpdateCleanOutput{
504 Removed: make([]string, 0, len(stashes)),
505 }
506 for _, s := range stashes {
507 if err := os.Remove(s.path); err != nil {
508 return fmt.Errorf("removing %s: %w", s.path, err)
509 }
510 out.Removed = append(out.Removed, s.name)
511 out.BytesFreed += s.size
512 }
513 return cmds.EmitOnce(res, out)
514 },
515 Encoders: cmds.EncoderMap{
516 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *UpdateCleanOutput) error {
517 if len(out.Removed) == 0 {
518 fmt.Fprintln(w, "No stashed binaries to remove.")
519 return nil
520 }
521 for _, name := range out.Removed {
522 fmt.Fprintf(w, "Removed %s\n", name)
523 }
524 fmt.Fprintf(w, "Freed %.1f MiB across %d files.\n",
525 float64(out.BytesFreed)/(1<<20), len(out.Removed))
526 return nil
527 }),
528 },
529 }
530
531 // -- helpers --
532
533 // updateContext returns a context for update operations. If the user
534 // passed --timeout, req.Context already carries that deadline and is
535 // returned as-is. Otherwise a fallback of updateDefaultTimeout is applied
536 // so HTTP calls cannot hang indefinitely.
537 func updateContext(req *cmds.Request) (context.Context, context.CancelFunc) {
538 ctx := req.Context
539 if _, ok := ctx.Deadline(); ok {
540 return ctx, func() {}
541 }
542 return context.WithTimeout(ctx, updateDefaultTimeout)
543 }
544
545 // currentVersion returns the version string used by update commands.
546 // TEST_KUBO_VERSION overrides the reported version; the TEST_ prefix
547 // signals it is a test-only escape hatch used by integration tests in
548 // test/cli/update_test.go and should never be set in production.
549 func currentVersion() string {
550 if v := os.Getenv("TEST_KUBO_VERSION"); v != "" {
551 return v
552 }
553 return version.CurrentVersionNumber
554 }
555
556 // checkDaemonNotRunning returns an error if the IPFS daemon is running.
557 func checkDaemonNotRunning() error {
558 repoPath, err := fsrepo.BestKnownPath()
559 if err != nil {
560 // Without a repo path we can't check the lock, but we shouldn't
561 // silently proceed either. Warn so the user notices a misconfigured
562 // IPFS_PATH instead of getting an unexplained install.
563 fmt.Fprintf(os.Stderr, "Warning: could not determine IPFS path, skipping daemon check: %v\n", err)
564 return nil
565 }
566 locked, err := fsrepo.LockedByOtherProcess(repoPath)
567 if err != nil {
568 // Lock check failed (e.g. repo doesn't exist yet), not an error.
569 fmt.Fprintf(os.Stderr, "Warning: could not check daemon lock at %s: %v\n", repoPath, err)
570 return nil
571 }
572 if locked {
573 return fmt.Errorf("IPFS daemon is running (repo locked at %s). Stop it first with 'ipfs shutdown'", repoPath)
574 }
575 return nil
576 }
577
578 // getStashDir returns the path to the stash directory, creating it if needed.
579 func getStashDir() (string, error) {
580 repoPath, err := fsrepo.BestKnownPath()
581 if err != nil {
582 return "", fmt.Errorf("determining IPFS path: %w", err)
583 }
584 dir := filepath.Join(repoPath, stashDirName)
585 if err := os.MkdirAll(dir, 0o755); err != nil {
586 return "", fmt.Errorf("creating stash directory: %w", err)
587 }
588 return dir, nil
589 }
590
591 // stashBinary copies the current binary to the stash directory.
592 // Uses named returns so the deferred dst.Close() error is not silently
593 // discarded -- a failed close means the backup may be incomplete.
594 func stashBinary(binPath, ver string) (stashPath string, err error) {
595 dir, err := getStashDir()
596 if err != nil {
597 return "", err
598 }
599
600 stashName := migrations.ExeName(fmt.Sprintf("ipfs-%s", ver))
601 stashPath = filepath.Join(dir, stashName)
602
603 src, err := os.Open(binPath)
604 if err != nil {
605 return "", fmt.Errorf("opening current binary: %w", err)
606 }
607 defer src.Close()
608
609 dst, err := os.OpenFile(stashPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
610 if err != nil {
611 return "", fmt.Errorf("creating stash file: %w", err)
612 }
613 defer func() {
614 if cerr := dst.Close(); cerr != nil && err == nil {
615 err = fmt.Errorf("writing stash file: %w", cerr)
616 }
617 }()
618
619 if _, err = io.Copy(dst, src); err != nil {
620 return "", fmt.Errorf("copying binary to stash: %w", err)
621 }
622 if err = dst.Sync(); err != nil {
623 return "", fmt.Errorf("syncing stash file: %w", err)
624 }
625
626 return stashPath, nil
627 }
628
629 // stashEntry describes a single backed-up Kubo binary in the stash directory.
630 type stashEntry struct {
631 path string
632 name string
633 ver string
634 parsed *goversion.Version
635 size int64
636 }
637
638 // listStashes returns every stashed binary in dir, newest first. Files that
639 // do not match the "ipfs-<semver>" naming convention are skipped so the
640 // directory can hold unrelated user files without breaking revert/clean.
641 func listStashes(dir string) ([]stashEntry, error) {
642 entries, err := os.ReadDir(dir)
643 if err != nil {
644 return nil, err
645 }
646
647 var stashes []stashEntry
648 for _, e := range entries {
649 if e.IsDir() {
650 continue
651 }
652 name := e.Name()
653 // Expected format: ipfs-<version> or ipfs-<version>.exe
654 trimmed := strings.TrimPrefix(name, "ipfs-")
655 if trimmed == name {
656 continue // doesn't match pattern
657 }
658 trimmed = strings.TrimSuffix(trimmed, ".exe")
659 parsed, parseErr := goversion.NewVersion(trimmed)
660 if parseErr != nil {
661 continue
662 }
663 var size int64
664 if info, err := e.Info(); err == nil {
665 size = info.Size()
666 }
667 stashes = append(stashes, stashEntry{
668 path: filepath.Join(dir, name),
669 name: name,
670 ver: trimmed,
671 parsed: parsed,
672 size: size,
673 })
674 }
675
676 slices.SortFunc(stashes, func(a, b stashEntry) int {
677 // Sort newest first: if a > b return -1.
678 if a.parsed.GreaterThan(b.parsed) {
679 return -1
680 }
681 if b.parsed.GreaterThan(a.parsed) {
682 return 1
683 }
684 return 0
685 })
686
687 return stashes, nil
688 }
689
690 // findLatestStash finds the most recently versioned stash file.
691 func findLatestStash(dir string) (path, ver string, err error) {
692 stashes, err := listStashes(dir)
693 if err != nil {
694 return "", "", fmt.Errorf("reading stash directory: %w", err)
695 }
696 if len(stashes) == 0 {
697 return "", "", fmt.Errorf("no stashed binaries found in %s", dir)
698 }
699 return stashes[0].path, stashes[0].ver, nil
700 }
701
702 // replaceBinary atomically replaces the binary at targetPath with data.
703 func replaceBinary(targetPath string, data []byte) error {
704 af, err := atomicfile.New(targetPath, 0o755)
705 if err != nil {
706 return err
707 }
708
709 if _, err := af.Write(data); err != nil {
710 _ = af.Abort()
711 return err
712 }
713
714 return af.Close()
715 }
716
717 // writeBinaryToTempFile writes data to a uniquely named executable file
718 // in the system temp directory and returns its path.
719 func writeBinaryToTempFile(data []byte, ver string) (path string, err error) {
720 pattern := migrations.ExeName(fmt.Sprintf("ipfs-%s-*", ver))
721 f, err := os.CreateTemp("", pattern)
722 if err != nil {
723 return "", fmt.Errorf("creating temp file: %w", err)
724 }
725 defer func() {
726 if cerr := f.Close(); cerr != nil && err == nil {
727 err = fmt.Errorf("closing temp file: %w", cerr)
728 }
729 if err != nil {
730 os.Remove(f.Name())
731 }
732 }()
733
734 if _, err = f.Write(data); err != nil {
735 return "", fmt.Errorf("writing temp file: %w", err)
736 }
737 if err = f.Sync(); err != nil {
738 return "", fmt.Errorf("syncing temp file: %w", err)
739 }
740 if err = f.Chmod(0o755); err != nil {
741 return "", fmt.Errorf("chmod temp file: %w", err)
742 }
743 return f.Name(), nil
744 }
745
746 // extractBinaryFromArchive extracts the kubo/ipfs binary from a tar.gz or zip archive.
747 func extractBinaryFromArchive(data []byte) ([]byte, error) {
748 binName := migrations.ExeName("ipfs")
749
750 // Try tar.gz first (Unix releases), then zip (Windows releases).
751 result, tarErr := extractFromTarGz(data, binName)
752 if tarErr == nil {
753 return result, nil
754 }
755
756 result, zipErr := extractFromZip(data, binName)
757 if zipErr == nil {
758 return result, nil
759 }
760
761 return nil, fmt.Errorf("could not find ipfs binary in archive (expected kubo/%s): tar.gz: %v, zip: %v", binName, tarErr, zipErr)
762 }
763
764 func extractFromTarGz(data []byte, binName string) ([]byte, error) {
765 gzr, err := gzip.NewReader(bytes.NewReader(data))
766 if err != nil {
767 return nil, err
768 }
769 defer gzr.Close()
770
771 tr := tar.NewReader(gzr)
772 lookFor := "kubo/" + binName
773 for {
774 hdr, err := tr.Next()
775 if errors.Is(err, io.EOF) {
776 break
777 }
778 if err != nil {
779 return nil, err
780 }
781 if hdr.Name == lookFor {
782 result, readErr := io.ReadAll(io.LimitReader(tr, maxBinarySize+1))
783 if readErr != nil {
784 return nil, readErr
785 }
786 if int64(len(result)) > maxBinarySize {
787 return nil, fmt.Errorf("extracted binary exceeds maximum size of %d bytes", maxBinarySize)
788 }
789 return result, nil
790 }
791 }
792 return nil, fmt.Errorf("%s not found in tar.gz", lookFor)
793 }
794
795 func extractFromZip(data []byte, binName string) ([]byte, error) {
796 zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
797 if err != nil {
798 return nil, err
799 }
800
801 lookFor := "kubo/" + binName
802 for _, f := range zr.File {
803 if f.Name != lookFor {
804 continue
805 }
806 rc, err := f.Open()
807 if err != nil {
808 return nil, err
809 }
810 result, err := io.ReadAll(io.LimitReader(rc, maxBinarySize+1))
811 rc.Close()
812 if err != nil {
813 return nil, err
814 }
815 if int64(len(result)) > maxBinarySize {
816 return nil, fmt.Errorf("extracted binary exceeds maximum size of %d bytes", maxBinarySize)
817 }
818 return result, nil
819 }
820 return nil, fmt.Errorf("%s not found in zip", lookFor)
821 }
822
823 // trimVPrefix removes a leading "v" from a version string.
824 func trimVPrefix(s string) string {
825 return strings.TrimPrefix(s, "v")
826 }
827
828 // normalizeVersion ensures a version string has a "v" prefix (for GitHub tags).
829 func normalizeVersion(s string) string {
830 s = strings.TrimSpace(s)
831 if !strings.HasPrefix(s, "v") {
832 return "v" + s
833 }
834 return s
835 }
836
837 // isNewerVersion returns true if target is newer than current.
838 func isNewerVersion(current, target string) (bool, error) {
839 cv, err := goversion.NewVersion(current)
840 if err != nil {
841 return false, fmt.Errorf("parsing current version %q: %w", current, err)
842 }
843 tv, err := goversion.NewVersion(target)
844 if err != nil {
845 return false, fmt.Errorf("parsing target version %q: %w", target, err)
846 }
847 return tv.GreaterThan(cv), nil
848 }