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
+}