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