@cryptotaxi247 / kubo / commits / 1f54f1daa

feat: add built-in `ipfs update` command (#11203)

* feat: add built-in `ipfs update` command adds `ipfs update` command tree that downloads pre-built Kubo binaries from GitHub Releases, verifies SHA-512 checksums, and replaces the running binary in place. subcommands: - `ipfs update check` -- query GitHub for newer versions - `ipfs update versions` -- list available releases - `ipfs update install [version]` -- download, verify, backup, and atomically replace the current binary - `ipfs update revert` -- restore the previously backed up binary from `$IPFS_PATH/old-bin/` read-only subcommands (check, versions) work while the daemon is running. install and revert require the daemon to be stopped first. design decisions: - uses GitHub Releases API instead of dist.ipfs.tech because GitHub is harder to censor in regions that block IPFS infrastructure - honors GITHUB_TOKEN/GH_TOKEN to avoid unauthenticated rate limits - backs up the current binary before replacing, with permission-error fallback that saves to a temp dir with manual `sudo mv` instructions - `KUBO_UPDATE_GITHUB_URL` env var redirects API calls for integration testing; `IPFS_VERSION_FAKE` overrides the reported version - unit tests use mock HTTP servers and the var override; CLI tests use the env vars with a temp binary copy so the real build is never touched resolves https://github.com/ipfs/kubo/issues/10937 * fix(update): harden download and extraction - cap decompressed binary at 1 GB to block zip/tar bombs - propagate tar.gz/zip errors instead of swallowing them - fall back to 1h context timeout when --timeout is not set - warn on stderr when daemon lock check fails - clarify that fetch+verify+extract complete before touching binary * fix(update): resolve binary path on windows The test harness hardcodes the binary path as `cmd/ipfs/ipfs` without the `.exe` suffix. On Windows the built binary is `ipfs.exe`, so copyBuiltBinary needs to append the extension. * fix(update): handle windows binary locking in install test On Windows the OS locks the running executable, so atomicfile cannot rename over it. The install command falls back to saving the new binary to a temp path. Accept both outcomes in TestUpdateInstall: in-place replacement (Unix) or permission-denied fallback (Windows). Also fix stash path to include .exe suffix on Windows. - test/cli/update_test.go: branch on runtime.GOOS for install assertions - test/sharness/t0063-external.sh: remove, tested the old ExternalBinary delegation which is replaced by the built-in update command - .github/workflows/test-migrations.yml: pass GITHUB_TOKEN to avoid rate limits * fix(test): handle windows EINVAL on process signal after wait On Windows, Process.Wait() sets the handle state to "released" rather than "done", so a subsequent Signal() returns syscall.EINVAL instead of os.ErrProcessDone. This caused StopDaemon cleanup to panic on Windows CI. Treat both errors as "process already exited". * feat(update): add 'clean' subcommand Drops every backed-up Kubo binary from $IPFS_PATH/old-bin/ so users can reclaim disk space without hand-deleting files. Safe with the daemon running, only touches the backup directory. - update.go: extract stashDirName const, factor out listStashes() helper, add updateCleanCmd - commands_test.go: register /update/clean - test/cli/update_test.go: TestUpdateClean covers removal, empty dir, json output, and preservation of unrelated files * docs(changelog): tighten ipfs update entry Drop the marketing opener, the duplicate install example, and the revert/versions sentence; all are covered by 'ipfs update --help'. Mention the new 'clean' subcommand in the trailing pointer. * fix(test): skip fuse cli tests on non-unix Both fuse_test.go and realworld_test.go rely on Unix-only APIs (syscall.Truncate, POSIX tools). The sibling xattr_*_test.go files were already gated, but these two compiled everywhere, so any workflow running 'go test ./test/cli/...' on Windows hit 'undefined: syscall.Truncate'. Use the same '(linux || darwin || freebsd) && !nofuse' constraint that the fuse/ packages already use so platform gating is consistent. * fix(test): run install/revert sequentially TestUpdateInstall and TestUpdateRevert write a copy of the ipfs binary and then exec it. When other tests run in parallel, a concurrent fork() can inherit the still-open write fd into its child, leaving the freshly written file 'text file busy' for exec until the sibling child execs. Dropping t.Parallel() on these two tests ensures no other goroutine is mid-fork while the binary is being written, which is the only reliable way to avoid the ETXTBSY race without clever fd tricks. * ci(update): use cloudflare/google DNS on macos GitHub's macOS runners intermittently lose DNS for api.github.com, which fails the real-network subtests in TestUpdate. Point the resolver at 1.1.1.1 and 8.8.8.8 on every active network service and flush the DNS cache before running the update tests. * fix(update): fsync before close in atomicfile and stash * fix(update): use unique temp file in permission fallback The previous fallback wrote to a predictable path (/tmp/ipfs-<ver>), which on shared systems lets a local attacker pre-create the path as a symlink and steer the user's subsequent 'sudo mv' anywhere. Switch to os.CreateTemp so the path is unique and exclusively owned by this process. * refactor(update): rename test env vars to TEST_KUBO_* IPFS_VERSION_FAKE and KUBO_UPDATE_GITHUB_URL are test-only escape hatches with no production use case. The TEST_ prefix signals this clearly and reduces the chance of accidental use in production. - IPFS_VERSION_FAKE -> TEST_KUBO_VERSION - KUBO_UPDATE_GITHUB_URL -> TEST_KUBO_UPDATE_GITHUB_URL * style(update): unshadow err in stashBinary * fix(update): warn when IPFS path can't be resolved silently skipping the daemon lock check on path-resolution failure can mask a misconfigured IPFS_PATH; print a warning so the user notices before the install proceeds. * fix(update): revert atomicfile Sync, use errors.Is for EOF Revert the Sync() addition in atomicfile.Close() to avoid widening the failure surface for existing migration callers that panic on Close errors (Must(out.Close()) in WithBackup). The stashBinary fsync in update.go is kept since that code path is new. - revert repo/fsrepo/migrations/atomicfile/atomicfile.go to master - use errors.Is(err, io.EOF) in extractFromTarGz

Marcin Rataj committed Apr 10, 2026 at 23:49 UTC 1f54f1daac0dd9bdb7ac119aca8491827f592166
12 files changed +2125 -53
.github/workflows/test-migrations.yml
+29 -1
@@ -1,4 +1,4 @@
1 -name: Migrations
1 +name: Migrations & Update
2
3 on:
4 workflow_dispatch:
@@ -9,6 +9,9 @@ on:
9 - 'test/cli/migrations/**'
10 # Config and repo handling
11 - 'repo/fsrepo/**'
12 + # Update command
13 + - 'core/commands/update*.go'
14 + - 'test/cli/update_test.go'
15 # This workflow file itself
16 - '.github/workflows/test-migrations.yml'
17 push:
@@ -19,6 +22,8 @@ on:
22 - 'repo/fsrepo/migrations/**'
23 - 'test/cli/migrations/**'
24 - 'repo/fsrepo/**'
25 + - 'core/commands/update*.go'
26 + - 'test/cli/update_test.go'
27 - '.github/workflows/test-migrations.yml'
28
29 concurrency:
@@ -75,6 +80,28 @@ jobs:
80 ipfs version || echo "Failed to run ipfs version"
81 go test ./test/cli/migrations/...
82
83 + # GitHub's macOS runners occasionally lose DNS for api.github.com,
84 + # which breaks the real-network subtests in TestUpdate (see run
85 + # 24222365595). Point the resolver at Cloudflare and Google so the
86 + # runner is insulated from flaky upstream DNS.
87 + - name: Configure DNS (macOS)
88 + if: runner.os == 'macOS'
89 + run: |
90 + networksetup -listallnetworkservices | tail -n +2 | while read -r svc; do
91 + sudo networksetup -setdnsservers "$svc" 1.1.1.1 8.8.8.8 || true
92 + done
93 + sudo dscacheutil -flushcache
94 + sudo killall -HUP mDNSResponder || true
95 + scutil --dns | head -20 || true
96 +
97 + - name: Run CLI update tests
98 + env:
99 + IPFS_PATH: ${{ runner.temp }}/ipfs-update-test
100 + GITHUB_TOKEN: ${{ github.token }}
101 + run: |
102 + export PATH="${{ github.workspace }}/cmd/ipfs:$PATH"
103 + go test -run "TestUpdate" ./test/cli/...
104 +
105 - name: Upload test results
106 if: always()
107 uses: actions/upload-artifact@v7
@@ -83,3 +110,4 @@ jobs:
110 path: |
111 test/**/*.log
112 ${{ runner.temp }}/ipfs-test/
113 + ${{ runner.temp }}/ipfs-update-test/
core/commands/commands_test.go
+5
@@ -215,6 +215,11 @@ func TestCommands(t *testing.T) {
215 "/swarm/peering/rm",
216 "/swarm/resources",
217 "/update",
218 + "/update/check",
219 + "/update/clean",
220 + "/update/install",
221 + "/update/revert",
222 + "/update/versions",
223 "/version",
224 "/version/check",
225 "/version/deps",
core/commands/root.go
+2 -2
@@ -81,7 +81,7 @@ TOOL COMMANDS
81 config Manage configuration
82 version Show IPFS version information
83 diag Generate diagnostic reports
84 - update Download and apply go-ipfs updates
84 + update Update Kubo to a different version
85 commands List all available commands
86 log Manage and show logs of running daemon
87
@@ -157,7 +157,7 @@ var rootSubcommands = map[string]*cmds.Command{
157 "refs": RefsCmd,
158 "resolve": ResolveCmd,
159 "swarm": SwarmCmd,
160 - "update": ExternalBinary("Please see https://github.com/ipfs/ipfs-update/blob/master/README.md#install for installation instructions."),
160 + "update": UpdateCmd,
161 "version": VersionCmd,
162 "shutdown": daemonShutdownCmd,
163 "cid": CidCmd,
core/commands/update.go new
+848
@@ -0,0 +1,848 @@
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 +}
core/commands/update_github.go new
+278
@@ -0,0 +1,278 @@
1 +package commands
2 +
3 +// This file implements fetching Kubo release binaries from GitHub Releases.
4 +//
5 +// We use GitHub Releases instead of dist.ipfs.tech because GitHub is harder
6 +// to censor. Many networks and regions block or interfere with IPFS-specific
7 +// infrastructure, but GitHub is widely accessible and its TLS-protected API
8 +// is difficult to selectively block without breaking many other services.
9 +
10 +import (
11 + "bytes"
12 + "context"
13 + "crypto/sha512"
14 + "encoding/hex"
15 + "encoding/json"
16 + "fmt"
17 + "io"
18 + "net/http"
19 + "os"
20 + "runtime"
21 + "strings"
22 +
23 + version "github.com/ipfs/kubo"
24 +)
25 +
26 +const (
27 + githubOwner = "ipfs"
28 + githubRepo = "kubo"
29 +
30 + githubAPIBase = "https://api.github.com"
31 +
32 + // maxDownloadSize is the maximum allowed binary archive size (200 MB).
33 + maxDownloadSize = 200 << 20
34 +)
35 +
36 +// githubReleaseFmt is the default GitHub Releases API URL prefix.
37 +// It is a var (not const) so unit tests can point API calls at a mock server.
38 +var githubReleaseFmt = githubAPIBase + "/repos/" + githubOwner + "/" + githubRepo + "/releases"
39 +
40 +// githubReleaseBaseURL returns the Releases API base URL. It normally
41 +// returns githubReleaseFmt.
42 +//
43 +// If TEST_KUBO_UPDATE_GITHUB_URL is set, that value is used instead.
44 +// This is a test-only escape hatch -- the TEST_ prefix is the gate,
45 +// signaling that production users should never set it. The integration
46 +// tests in test/cli/update_test.go use it to redirect API calls to a
47 +// local httptest mock server so the install pipeline can be exercised
48 +// without hitting real GitHub.
49 +func githubReleaseBaseURL() string {
50 + if u := os.Getenv("TEST_KUBO_UPDATE_GITHUB_URL"); u != "" {
51 + return u
52 + }
53 + return githubReleaseFmt
54 +}
55 +
56 +// ghRelease represents a GitHub release.
57 +type ghRelease struct {
58 + TagName string `json:"tag_name"`
59 + Prerelease bool `json:"prerelease"`
60 + Assets []ghAsset `json:"assets"`
61 +}
62 +
63 +// ghAsset represents a release asset on GitHub.
64 +type ghAsset struct {
65 + Name string `json:"name"`
66 + Size int64 `json:"size"`
67 + BrowserDownloadURL string `json:"browser_download_url"`
68 +}
69 +
70 +// githubGet performs an authenticated GET request to the GitHub API.
71 +// It honors GITHUB_TOKEN or GH_TOKEN env vars to avoid the 60 req/hr
72 +// unauthenticated rate limit.
73 +func githubGet(ctx context.Context, url string) (*http.Response, error) {
74 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
75 + if err != nil {
76 + return nil, err
77 + }
78 +
79 + req.Header.Set("Accept", "application/vnd.github+json")
80 + req.Header.Set("User-Agent", "kubo/"+version.CurrentVersionNumber)
81 +
82 + if token := githubToken(); token != "" {
83 + req.Header.Set("Authorization", "Bearer "+token)
84 + }
85 +
86 + resp, err := http.DefaultClient.Do(req)
87 + if err != nil {
88 + return nil, err
89 + }
90 +
91 + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
92 + resp.Body.Close()
93 + hint := ""
94 + if githubToken() == "" {
95 + hint = " (hint: set GITHUB_TOKEN or GH_TOKEN to avoid rate limits)"
96 + }
97 + return nil, fmt.Errorf("GitHub API rate limit exceeded%s", hint)
98 + }
99 +
100 + if resp.StatusCode != http.StatusOK {
101 + resp.Body.Close()
102 + return nil, fmt.Errorf("GitHub API returned HTTP %d for %s", resp.StatusCode, url)
103 + }
104 +
105 + return resp, nil
106 +}
107 +
108 +func githubToken() string {
109 + if t := os.Getenv("GITHUB_TOKEN"); t != "" {
110 + return t
111 + }
112 + return os.Getenv("GH_TOKEN")
113 +}
114 +
115 +// githubLatestRelease returns the newest release that has a platform asset
116 +// for the current GOOS/GOARCH. This avoids false positives when a release
117 +// tag exists but artifacts haven't been uploaded yet.
118 +func githubLatestRelease(ctx context.Context, includePre bool) (*ghRelease, error) {
119 + releases, err := githubListReleases(ctx, 10, includePre)
120 + if err != nil {
121 + return nil, err
122 + }
123 +
124 + for i := range releases {
125 + want := assetNameForPlatformTag(releases[i].TagName)
126 + for _, a := range releases[i].Assets {
127 + if a.Name == want {
128 + return &releases[i], nil
129 + }
130 + }
131 + }
132 + return nil, fmt.Errorf("no release found with a binary for %s/%s", runtime.GOOS, runtime.GOARCH)
133 +}
134 +
135 +// githubListReleases fetches up to count releases, optionally including prereleases.
136 +func githubListReleases(ctx context.Context, count int, includePre bool) ([]ghRelease, error) {
137 + // Fetch more than needed so we can filter prereleases and still return count results.
138 + perPage := count
139 + if !includePre {
140 + perPage = count * 3
141 + }
142 + if perPage > 100 {
143 + perPage = 100
144 + }
145 +
146 + url := fmt.Sprintf("%s?per_page=%d", githubReleaseBaseURL(), perPage)
147 + resp, err := githubGet(ctx, url)
148 + if err != nil {
149 + return nil, err
150 + }
151 + defer resp.Body.Close()
152 +
153 + var all []ghRelease
154 + if err := json.NewDecoder(resp.Body).Decode(&all); err != nil {
155 + return nil, fmt.Errorf("decoding GitHub releases: %w", err)
156 + }
157 +
158 + var filtered []ghRelease
159 + for _, r := range all {
160 + if !includePre && r.Prerelease {
161 + continue
162 + }
163 + filtered = append(filtered, r)
164 + if len(filtered) >= count {
165 + break
166 + }
167 + }
168 + return filtered, nil
169 +}
170 +
171 +// githubReleaseByTag fetches a single release by its git tag.
172 +func githubReleaseByTag(ctx context.Context, tag string) (*ghRelease, error) {
173 + url := fmt.Sprintf("%s/tags/%s", githubReleaseBaseURL(), tag)
174 + resp, err := githubGet(ctx, url)
175 + if err != nil {
176 + return nil, err
177 + }
178 + defer resp.Body.Close()
179 +
180 + var rel ghRelease
181 + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
182 + return nil, fmt.Errorf("decoding GitHub release: %w", err)
183 + }
184 + return &rel, nil
185 +}
186 +
187 +// findReleaseAsset locates the platform-appropriate asset in a release.
188 +// It fails immediately with a clear message if:
189 +// - the release tag does not exist on GitHub (typo, unreleased version)
190 +// - the release exists but has no binary for this OS/arch (CI still building)
191 +func findReleaseAsset(ctx context.Context, tag string) (*ghRelease, *ghAsset, error) {
192 + rel, err := githubReleaseByTag(ctx, tag)
193 + if err != nil {
194 + return nil, nil, fmt.Errorf("release %s not found on GitHub: %w", tag, err)
195 + }
196 +
197 + want := assetNameForPlatformTag(tag)
198 + for i := range rel.Assets {
199 + if rel.Assets[i].Name == want {
200 + return rel, &rel.Assets[i], nil
201 + }
202 + }
203 +
204 + return nil, nil, fmt.Errorf(
205 + "release %s exists but has no binary for %s/%s yet; build artifacts may still be uploading, try again in a few hours",
206 + tag, runtime.GOOS, runtime.GOARCH)
207 +}
208 +
209 +// downloadAsset downloads a release asset by its browser_download_url.
210 +// This hits GitHub's CDN directly, not the API, so no auth headers are needed.
211 +func downloadAsset(ctx context.Context, url string) ([]byte, error) {
212 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
213 + if err != nil {
214 + return nil, err
215 + }
216 + req.Header.Set("User-Agent", "kubo/"+version.CurrentVersionNumber)
217 +
218 + resp, err := http.DefaultClient.Do(req)
219 + if err != nil {
220 + return nil, fmt.Errorf("downloading asset: %w", err)
221 + }
222 + defer resp.Body.Close()
223 +
224 + if resp.StatusCode != http.StatusOK {
225 + return nil, fmt.Errorf("download returned HTTP %d", resp.StatusCode)
226 + }
227 +
228 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize+1))
229 + if err != nil {
230 + return nil, fmt.Errorf("reading download: %w", err)
231 + }
232 + if int64(len(data)) > maxDownloadSize {
233 + return nil, fmt.Errorf("download exceeds maximum size of %d bytes", maxDownloadSize)
234 + }
235 + return data, nil
236 +}
237 +
238 +// downloadAndVerifySHA512 downloads the .sha512 sidecar file for the given
239 +// archive URL and verifies the archive data against it.
240 +func downloadAndVerifySHA512(ctx context.Context, data []byte, archiveURL string) error {
241 + sha512URL := archiveURL + ".sha512"
242 + checksumData, err := downloadAsset(ctx, sha512URL)
243 + if err != nil {
244 + return fmt.Errorf("downloading checksum file: %w", err)
245 + }
246 +
247 + // Parse "<hex> <filename>\n" format (standard sha512sum output).
248 + fields := strings.Fields(string(checksumData))
249 + if len(fields) < 1 {
250 + return fmt.Errorf("empty or malformed .sha512 file")
251 + }
252 + wantHex := fields[0]
253 +
254 + return verifySHA512(data, wantHex)
255 +}
256 +
257 +// verifySHA512 checks that data matches the given hex-encoded SHA-512 hash.
258 +func verifySHA512(data []byte, wantHex string) error {
259 + want, err := hex.DecodeString(wantHex)
260 + if err != nil {
261 + return fmt.Errorf("invalid hex in SHA-512 checksum: %w", err)
262 + }
263 + got := sha512.Sum512(data)
264 + if !bytes.Equal(got[:], want) {
265 + return fmt.Errorf("SHA-512 mismatch: expected %s, got %x", wantHex, got[:])
266 + }
267 + return nil
268 +}
269 +
270 +// assetNameForPlatformTag returns the expected archive filename for a given
271 +// release tag and the current GOOS/GOARCH.
272 +func assetNameForPlatformTag(tag string) string {
273 + ext := "tar.gz"
274 + if runtime.GOOS == "windows" {
275 + ext = "zip"
276 + }
277 + return fmt.Sprintf("kubo_%s_%s-%s.%s", tag, runtime.GOOS, runtime.GOARCH, ext)
278 +}
core/commands/update_github_test.go new
+428
@@ -0,0 +1,428 @@
1 +package commands
2 +
3 +import (
4 + "archive/tar"
5 + "bytes"
6 + "compress/gzip"
7 + "crypto/sha512"
8 + "encoding/json"
9 + "fmt"
10 + "net/http"
11 + "net/http/httptest"
12 + "runtime"
13 + "testing"
14 +
15 + "github.com/stretchr/testify/assert"
16 + "github.com/stretchr/testify/require"
17 +)
18 +
19 +// --- SHA-512 verification ---
20 +//
21 +// These tests verify the integrity-checking code that protects users from
22 +// tampered or corrupted downloads. A broken hash check could allow
23 +// installing a malicious binary, so each failure mode must be covered.
24 +
25 +// TestVerifySHA512 exercises the low-level hash comparison function.
26 +func TestVerifySHA512(t *testing.T) {
27 + t.Parallel()
28 + data := []byte("hello world")
29 + sum := sha512.Sum512(data)
30 + validHex := fmt.Sprintf("%x", sum[:])
31 +
32 + t.Run("accepts matching hash", func(t *testing.T) {
33 + t.Parallel()
34 + err := verifySHA512(data, validHex)
35 + assert.NoError(t, err)
36 + })
37 +
38 + t.Run("rejects data that does not match hash", func(t *testing.T) {
39 + t.Parallel()
40 + err := verifySHA512([]byte("tampered"), validHex)
41 + assert.ErrorContains(t, err, "SHA-512 mismatch",
42 + "must reject data whose hash differs from the expected value")
43 + })
44 +
45 + t.Run("rejects malformed hex string", func(t *testing.T) {
46 + t.Parallel()
47 + err := verifySHA512(data, "not-valid-hex")
48 + assert.ErrorContains(t, err, "invalid hex in SHA-512 checksum")
49 + })
50 +}
51 +
52 +// TestDownloadAndVerifySHA512 tests the complete download-and-verify flow:
53 +// fetching a .sha512 sidecar file from alongside the archive URL, parsing
54 +// the standard sha512sum format ("<hex> <filename>\n"), and comparing
55 +// against the archive data. This is the function called by "ipfs update install".
56 +func TestDownloadAndVerifySHA512(t *testing.T) {
57 + t.Parallel()
58 + archiveData := []byte("fake-archive-content")
59 + sum := sha512.Sum512(archiveData)
60 + checksumBody := fmt.Sprintf("%x kubo_v0.41.0_linux-amd64.tar.gz\n", sum[:])
61 +
62 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 + switch r.URL.Path {
64 + case "/archive.tar.gz.sha512":
65 + _, _ = w.Write([]byte(checksumBody))
66 + default:
67 + w.WriteHeader(http.StatusNotFound)
68 + }
69 + }))
70 + t.Cleanup(srv.Close)
71 +
72 + t.Run("accepts archive matching sidecar hash", func(t *testing.T) {
73 + t.Parallel()
74 + err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/archive.tar.gz")
75 + assert.NoError(t, err)
76 + })
77 +
78 + t.Run("rejects archive with wrong content", func(t *testing.T) {
79 + t.Parallel()
80 + err := downloadAndVerifySHA512(t.Context(), []byte("tampered"), srv.URL+"/archive.tar.gz")
81 + assert.ErrorContains(t, err, "SHA-512 mismatch",
82 + "must hard-fail when downloaded archive doesn't match the published checksum")
83 + })
84 +
85 + t.Run("fails when sidecar file is missing", func(t *testing.T) {
86 + t.Parallel()
87 + err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/no-such-file.tar.gz")
88 + assert.ErrorContains(t, err, "downloading checksum file",
89 + "must fail if the .sha512 sidecar can't be fetched")
90 + })
91 +}
92 +
93 +// --- GitHub API layer ---
94 +
95 +// TestGitHubGet verifies the low-level GitHub API helper that adds
96 +// authentication headers and translates HTTP errors into actionable
97 +// messages (especially rate-limit hints for unauthenticated users).
98 +func TestGitHubGet(t *testing.T) {
99 + t.Parallel()
100 +
101 + t.Run("sets Accept and User-Agent headers", func(t *testing.T) {
102 + t.Parallel()
103 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 + assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept"),
105 + "must request GitHub's v3 JSON format")
106 + assert.Contains(t, r.Header.Get("User-Agent"), "kubo/",
107 + "User-Agent must identify the kubo version for debugging")
108 + _, _ = w.Write([]byte("{}"))
109 + }))
110 + t.Cleanup(srv.Close)
111 +
112 + resp, err := githubGet(t.Context(), srv.URL)
113 + require.NoError(t, err)
114 + resp.Body.Close()
115 + })
116 +
117 + t.Run("returns rate-limit error on HTTP 403", func(t *testing.T) {
118 + t.Parallel()
119 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
120 + w.WriteHeader(http.StatusForbidden)
121 + }))
122 + t.Cleanup(srv.Close)
123 +
124 + _, err := githubGet(t.Context(), srv.URL)
125 + assert.ErrorContains(t, err, "rate limit exceeded")
126 + })
127 +
128 + t.Run("returns rate-limit error on HTTP 429", func(t *testing.T) {
129 + t.Parallel()
130 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131 + w.WriteHeader(http.StatusTooManyRequests)
132 + }))
133 + t.Cleanup(srv.Close)
134 +
135 + _, err := githubGet(t.Context(), srv.URL)
136 + assert.ErrorContains(t, err, "rate limit exceeded")
137 + })
138 +
139 + t.Run("returns HTTP status on server error", func(t *testing.T) {
140 + t.Parallel()
141 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
142 + w.WriteHeader(http.StatusInternalServerError)
143 + }))
144 + t.Cleanup(srv.Close)
145 +
146 + _, err := githubGet(t.Context(), srv.URL)
147 + assert.ErrorContains(t, err, "HTTP 500")
148 + })
149 +}
150 +
151 +// TestGitHubListReleases verifies that release listing correctly filters
152 +// prereleases and respects the count limit. Uses a mock GitHub API server
153 +// to avoid network dependencies and rate limits in CI.
154 +//
155 +// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
156 +func TestGitHubListReleases(t *testing.T) {
157 + allReleases := []ghRelease{
158 + {TagName: "v0.42.0-rc1", Prerelease: true},
159 + {TagName: "v0.41.0"},
160 + {TagName: "v0.40.0"},
161 + }
162 + body, err := json.Marshal(allReleases)
163 + require.NoError(t, err)
164 +
165 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
166 + _, _ = w.Write(body)
167 + }))
168 + t.Cleanup(srv.Close)
169 +
170 + saved := githubReleaseFmt
171 + githubReleaseFmt = srv.URL
172 + t.Cleanup(func() { githubReleaseFmt = saved })
173 +
174 + t.Run("excludes prereleases by default", func(t *testing.T) {
175 + got, err := githubListReleases(t.Context(), 10, false)
176 + require.NoError(t, err)
177 + assert.Len(t, got, 2, "the rc1 prerelease should be filtered out")
178 + assert.Equal(t, "v0.41.0", got[0].TagName)
179 + assert.Equal(t, "v0.40.0", got[1].TagName)
180 + })
181 +
182 + t.Run("includes prereleases when requested", func(t *testing.T) {
183 + got, err := githubListReleases(t.Context(), 10, true)
184 + require.NoError(t, err)
185 + assert.Len(t, got, 3)
186 + assert.Equal(t, "v0.42.0-rc1", got[0].TagName)
187 + })
188 +
189 + t.Run("respects count limit", func(t *testing.T) {
190 + got, err := githubListReleases(t.Context(), 1, false)
191 + require.NoError(t, err)
192 + assert.Len(t, got, 1, "should return at most 1 release")
193 + })
194 +}
195 +
196 +// TestGitHubLatestRelease verifies that the "find latest release" logic
197 +// skips releases that don't have a binary for the current OS/arch.
198 +// This handles the real-world case where a release tag is created but
199 +// CI hasn't finished uploading build artifacts yet.
200 +//
201 +// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
202 +func TestGitHubLatestRelease(t *testing.T) {
203 + releases := []ghRelease{
204 + {
205 + TagName: "v0.42.0",
206 + Assets: []ghAsset{{Name: "kubo_v0.42.0_some-other-arch.tar.gz"}},
207 + },
208 + {
209 + TagName: "v0.41.0",
210 + Assets: []ghAsset{{Name: assetNameForPlatformTag("v0.41.0")}},
211 + },
212 + }
213 + body, err := json.Marshal(releases)
214 + require.NoError(t, err)
215 +
216 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
217 + _, _ = w.Write(body)
218 + }))
219 + t.Cleanup(srv.Close)
220 +
221 + saved := githubReleaseFmt
222 + githubReleaseFmt = srv.URL
223 + t.Cleanup(func() { githubReleaseFmt = saved })
224 +
225 + rel, err := githubLatestRelease(t.Context(), false)
226 + require.NoError(t, err)
227 + assert.Equal(t, "v0.41.0", rel.TagName,
228 + "should skip v0.42.0 (no binary for %s/%s) and return v0.41.0",
229 + runtime.GOOS, runtime.GOARCH)
230 +}
231 +
232 +// TestFindReleaseAsset verifies that findReleaseAsset locates the correct
233 +// platform-specific asset in a release, and returns a clear error when the
234 +// release exists but has no binary for the current OS/arch.
235 +//
236 +// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
237 +func TestFindReleaseAsset(t *testing.T) {
238 + wantAsset := assetNameForPlatformTag("v0.50.0")
239 +
240 + release := ghRelease{
241 + TagName: "v0.50.0",
242 + Assets: []ghAsset{
243 + {Name: "kubo_v0.50.0_some-other-arch.tar.gz", BrowserDownloadURL: "https://example.com/other"},
244 + {Name: wantAsset, BrowserDownloadURL: "https://example.com/correct"},
245 + },
246 + }
247 + body, err := json.Marshal(release)
248 + require.NoError(t, err)
249 +
250 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
251 + _, _ = w.Write(body)
252 + }))
253 + t.Cleanup(srv.Close)
254 +
255 + saved := githubReleaseFmt
256 + githubReleaseFmt = srv.URL
257 + t.Cleanup(func() { githubReleaseFmt = saved })
258 +
259 + t.Run("returns matching asset for current platform", func(t *testing.T) {
260 + rel, asset, err := findReleaseAsset(t.Context(), "v0.50.0")
261 + require.NoError(t, err)
262 + assert.Equal(t, "v0.50.0", rel.TagName)
263 + assert.Equal(t, wantAsset, asset.Name)
264 + assert.Equal(t, "https://example.com/correct", asset.BrowserDownloadURL)
265 + })
266 +
267 + t.Run("returns error when no asset matches current platform", func(t *testing.T) {
268 + // Serve a release that only has an asset for a different arch.
269 + noMatch := ghRelease{
270 + TagName: "v0.51.0",
271 + Assets: []ghAsset{{Name: "kubo_v0.51.0_plan9-mips.tar.gz"}},
272 + }
273 + noMatchBody, err := json.Marshal(noMatch)
274 + require.NoError(t, err)
275 +
276 + noMatchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
277 + _, _ = w.Write(noMatchBody)
278 + }))
279 + t.Cleanup(noMatchSrv.Close)
280 +
281 + githubReleaseFmt = noMatchSrv.URL
282 +
283 + _, _, err = findReleaseAsset(t.Context(), "v0.51.0")
284 + assert.ErrorContains(t, err, "has no binary for",
285 + "should explain that the release exists but lacks a matching asset")
286 + })
287 +}
288 +
289 +// --- Asset download ---
290 +
291 +// TestDownloadAsset verifies the HTTP download helper that fetches release
292 +// archives from GitHub's CDN. Tests both the happy path and HTTP error
293 +// reporting.
294 +func TestDownloadAsset(t *testing.T) {
295 + t.Parallel()
296 +
297 + t.Run("downloads content successfully", func(t *testing.T) {
298 + t.Parallel()
299 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
300 + _, _ = w.Write([]byte("binary-content"))
301 + }))
302 + t.Cleanup(srv.Close)
303 +
304 + data, err := downloadAsset(t.Context(), srv.URL)
305 + require.NoError(t, err)
306 + assert.Equal(t, []byte("binary-content"), data)
307 + })
308 +
309 + t.Run("returns clear error on HTTP failure", func(t *testing.T) {
310 + t.Parallel()
311 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
312 + w.WriteHeader(http.StatusNotFound)
313 + }))
314 + t.Cleanup(srv.Close)
315 +
316 + _, err := downloadAsset(t.Context(), srv.URL)
317 + assert.ErrorContains(t, err, "HTTP 404")
318 + })
319 +}
320 +
321 +// --- Archive extraction ---
322 +
323 +// TestExtractBinaryFromArchive verifies that the ipfs binary can be
324 +// extracted from release archives. Kubo releases use tar.gz on Unix
325 +// and zip on Windows, with the binary at "kubo/ipfs" inside the archive.
326 +func TestExtractBinaryFromArchive(t *testing.T) {
327 + t.Parallel()
328 +
329 + t.Run("extracts binary from valid tar.gz", func(t *testing.T) {
330 + t.Parallel()
331 + wantContent := []byte("#!/bin/fake-ipfs-binary")
332 + archive := makeTarGz(t, "kubo/ipfs", wantContent)
333 +
334 + got, err := extractBinaryFromArchive(archive)
335 + require.NoError(t, err)
336 + assert.Equal(t, wantContent, got)
337 + })
338 +
339 + t.Run("rejects archive without kubo/ipfs entry", func(t *testing.T) {
340 + t.Parallel()
341 + // A valid tar.gz that contains a file at the wrong path.
342 + archive := makeTarGz(t, "wrong-path/ipfs", []byte("binary"))
343 +
344 + _, err := extractBinaryFromArchive(archive)
345 + assert.ErrorContains(t, err, "could not find ipfs binary")
346 + })
347 +
348 + t.Run("rejects non-archive data", func(t *testing.T) {
349 + t.Parallel()
350 + _, err := extractBinaryFromArchive([]byte("not an archive"))
351 + assert.ErrorContains(t, err, "could not find ipfs binary")
352 + })
353 +}
354 +
355 +// makeTarGz creates an in-memory tar.gz archive containing a single file.
356 +func makeTarGz(t *testing.T, path string, content []byte) []byte {
357 + t.Helper()
358 + var buf bytes.Buffer
359 + gzw := gzip.NewWriter(&buf)
360 + tw := tar.NewWriter(gzw)
361 + require.NoError(t, tw.WriteHeader(&tar.Header{
362 + Name: path,
363 + Mode: 0o755,
364 + Size: int64(len(content)),
365 + }))
366 + _, err := tw.Write(content)
367 + require.NoError(t, err)
368 + require.NoError(t, tw.Close())
369 + require.NoError(t, gzw.Close())
370 + return buf.Bytes()
371 +}
372 +
373 +// --- Asset name and version helpers ---
374 +
375 +// TestAssetNameForPlatformTag ensures the archive filename matches the
376 +// naming convention used by Kubo's CI release pipeline:
377 +//
378 +// kubo_<tag>_<os>-<arch>.<ext>
379 +func TestAssetNameForPlatformTag(t *testing.T) {
380 + t.Parallel()
381 + name := assetNameForPlatformTag("v0.41.0")
382 + assert.Contains(t, name, fmt.Sprintf("kubo_v0.41.0_%s-%s.", runtime.GOOS, runtime.GOARCH))
383 +
384 + if runtime.GOOS == "windows" {
385 + assert.Contains(t, name, ".zip")
386 + } else {
387 + assert.Contains(t, name, ".tar.gz")
388 + }
389 +}
390 +
391 +// TestVersionHelpers exercises the version string utilities used throughout
392 +// the update command. These handle the mismatch between Go's semver
393 +// (no "v" prefix) and GitHub's tag convention ("v" prefix).
394 +func TestVersionHelpers(t *testing.T) {
395 + t.Parallel()
396 +
397 + t.Run("trimVPrefix strips leading v", func(t *testing.T) {
398 + t.Parallel()
399 + assert.Equal(t, "0.41.0", trimVPrefix("v0.41.0"))
400 + assert.Equal(t, "0.41.0", trimVPrefix("0.41.0"), "no-op when v is absent")
401 + })
402 +
403 + t.Run("normalizeVersion adds v prefix for GitHub tags", func(t *testing.T) {
404 + t.Parallel()
405 + assert.Equal(t, "v0.41.0", normalizeVersion("0.41.0"))
406 + assert.Equal(t, "v0.41.0", normalizeVersion("v0.41.0"), "no-op when v is present")
407 + assert.Equal(t, "v0.41.0", normalizeVersion(" v0.41.0 "), "trims whitespace")
408 + })
409 +
410 + t.Run("isNewerVersion compares semver correctly", func(t *testing.T) {
411 + t.Parallel()
412 + tests := []struct {
413 + current, target string
414 + wantNewer bool
415 + desc string
416 + }{
417 + {"0.40.0", "0.41.0", true, "newer minor version"},
418 + {"0.41.0", "0.40.0", false, "older minor version"},
419 + {"0.41.0", "0.41.0", false, "same version"},
420 + {"0.41.0-dev", "0.41.0", true, "release is newer than dev pre-release"},
421 + }
422 + for _, tt := range tests {
423 + got, err := isNewerVersion(tt.current, tt.target)
424 + require.NoError(t, err)
425 + assert.Equal(t, tt.wantNewer, got, tt.desc)
426 + }
427 + })
428 +}
docs/changelogs/v0.41.md
+13
@@ -12,6 +12,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
12 - [🔦 Highlights](#-highlights)
13 - [🗑️ Faster Provide Queue Disk Reclamation](#-faster-provide-queue-disk-reclamation)
14 - [✨ New `ipfs cid inspect` command](#-new-ipfs-cid-inspect-command)
15 + - [🔄 Built-in `ipfs update` command](#-built-in-ipfs-update-command)
16 - [🖥️ WebUI Improvements](#-webui-improvements)
17 - [🔧 Correct provider addresses for custom HTTP routing](#-correct-provider-addresses-for-custom-http-routing)
18 - [🔀 `Provide.Strategy` modifiers: `+unique` and `+entities`](#-providestrategy-modifiers-unique-and-entities)
@@ -71,6 +72,18 @@ CIDv1: bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi
72
73 See `ipfs cid --help` for all CID-related commands.
74
75 +#### 🔄 Built-in `ipfs update` command
76 +
77 +Kubo now ships with a built-in `ipfs update` command that downloads release binaries from GitHub and swaps the current one in place. It supersedes the external [`ipfs-update`](https://github.com/ipfs/ipfs-update) tool, deprecated since [v0.37](https://github.com/ipfs/kubo/blob/master/docs/changelogs/v0.37.md#-repository-migration-from-v16-to-v17-with-embedded-tooling).
78 +
79 +```console
80 +$ ipfs update check
81 +Update available: 0.40.0 -> 0.41.0
82 +Run 'ipfs update install' to install the latest version.
83 +```
84 +
85 +See `ipfs update --help` for the available subcommands (`check`, `versions`, `install`, `revert`, `clean`).
86 +
87 #### 🖥️ WebUI Improvements
88
89 IPFS Web UI has been updated to [v4.12.0](https://github.com/ipfs/ipfs-webui/releases/tag/v4.12.0).
test/cli/fuse/fuse_test.go
+2
@@ -1,3 +1,5 @@
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 // Package fuse contains end-to-end FUSE integration tests that exercise
4 // mount/unmount and filesystem operations through a real ipfs daemon.
5 //
test/cli/fuse/realworld_test.go
+2
@@ -1,3 +1,5 @@
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 // End-to-end FUSE coverage with real POSIX tools.
4 //
5 // TestFUSERealWorld spins up one ipfs daemon, mounts /ipfs, /ipns, and
test/cli/harness/node.go
+4 -1
@@ -303,7 +303,10 @@ func (n *Node) StartDaemonWithAuthorization(secret string, ipfsArgs ...string) *
303 func (n *Node) signalAndWait(watch <-chan struct{}, signal os.Signal, t time.Duration) bool {
304 err := n.Daemon.Cmd.Process.Signal(signal)
305 if err != nil {
306 - if errors.Is(err, os.ErrProcessDone) {
306 + // On Windows, Process.Wait() sets the handle state to "released"
307 + // rather than "done", so a subsequent Signal() returns EINVAL
308 + // instead of ErrProcessDone. Treat both as "already exited".
309 + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.EINVAL) {
310 log.Debugf("process for node %d has already finished", n.ID)
311 return true
312 }
test/cli/update_test.go new
+514
@@ -0,0 +1,514 @@
1 +package cli
2 +
3 +import (
4 + "archive/tar"
5 + "archive/zip"
6 + "bytes"
7 + "compress/gzip"
8 + "crypto/sha512"
9 + "encoding/json"
10 + "fmt"
11 + "net/http"
12 + "net/http/httptest"
13 + "os"
14 + "path/filepath"
15 + "runtime"
16 + "strings"
17 + "testing"
18 +
19 + "github.com/ipfs/kubo/test/cli/harness"
20 + "github.com/stretchr/testify/assert"
21 + "github.com/stretchr/testify/require"
22 +)
23 +
24 +// TestUpdate exercises the built-in "ipfs update" command tree against
25 +// the real GitHub Releases API. Network access is required.
26 +//
27 +// The node is created without Init or daemon, so install/revert error
28 +// paths that don't depend on a running daemon can be tested.
29 +func TestUpdate(t *testing.T) {
30 + t.Parallel()
31 + h := harness.NewT(t)
32 + node := h.NewNode()
33 +
34 + t.Run("help text describes the command", func(t *testing.T) {
35 + t.Parallel()
36 + res := node.IPFS("update", "--help")
37 + assert.Contains(t, res.Stdout.String(), "Update Kubo to a different version")
38 + })
39 +
40 + // check and versions are read-only GitHub API queries. They must work
41 + // regardless of daemon state, since users need to check for updates
42 + // before deciding whether to stop the daemon and install.
43 + t.Run("check", func(t *testing.T) {
44 + t.Parallel()
45 +
46 + t.Run("text output reports update availability", func(t *testing.T) {
47 + t.Parallel()
48 + res := node.IPFS("update", "check")
49 + out := res.Stdout.String()
50 + assert.True(t,
51 + strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"),
52 + "expected update status message, got: %s", out)
53 + })
54 +
55 + t.Run("json output includes version fields", func(t *testing.T) {
56 + t.Parallel()
57 + res := node.IPFS("update", "check", "--enc=json")
58 + var result struct {
59 + CurrentVersion string
60 + LatestVersion string
61 + UpdateAvailable bool
62 + }
63 + err := json.Unmarshal(res.Stdout.Bytes(), &result)
64 + require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
65 + assert.NotEmpty(t, result.CurrentVersion, "must report current version")
66 + assert.NotEmpty(t, result.LatestVersion, "must report latest version")
67 + })
68 + })
69 +
70 + t.Run("versions", func(t *testing.T) {
71 + t.Parallel()
72 +
73 + t.Run("lists available versions", func(t *testing.T) {
74 + t.Parallel()
75 + res := node.IPFS("update", "versions")
76 + lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
77 + assert.Greater(t, len(lines), 0, "should list at least one version")
78 + })
79 +
80 + t.Run("respects --count flag", func(t *testing.T) {
81 + t.Parallel()
82 + res := node.IPFS("update", "versions", "--count=5")
83 + lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
84 + assert.LessOrEqual(t, len(lines), 5)
85 + })
86 +
87 + t.Run("json output includes current version and list", func(t *testing.T) {
88 + t.Parallel()
89 + res := node.IPFS("update", "versions", "--count=3", "--enc=json")
90 + var result struct {
91 + Current string
92 + Versions []string
93 + }
94 + err := json.Unmarshal(res.Stdout.Bytes(), &result)
95 + require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
96 + assert.NotEmpty(t, result.Current, "must report current version")
97 + assert.NotEmpty(t, result.Versions, "must list at least one version")
98 + })
99 +
100 + t.Run("--pre includes prerelease versions", func(t *testing.T) {
101 + t.Parallel()
102 + res := node.IPFS("update", "versions", "--count=5", "--pre")
103 + lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
104 + assert.Greater(t, len(lines), 0, "should list at least one version")
105 + })
106 + })
107 +
108 + // install and revert mutate the binary on disk, so they have stricter
109 + // preconditions. These tests verify the error paths.
110 + t.Run("install rejects same version", func(t *testing.T) {
111 + t.Parallel()
112 + vRes := node.IPFS("version", "-n")
113 + current := strings.TrimSpace(vRes.Stdout.String())
114 +
115 + res := node.RunIPFS("update", "install", current)
116 + assert.Error(t, res.Err)
117 + assert.Contains(t, res.Stderr.String(), "already running version",
118 + "should refuse to re-install the current version")
119 + })
120 +
121 + t.Run("revert fails when no backup exists", func(t *testing.T) {
122 + t.Parallel()
123 + res := node.RunIPFS("update", "revert")
124 + assert.Error(t, res.Err)
125 + assert.Contains(t, res.Stderr.String(), "no stashed binaries",
126 + "should explain there is no previous version to restore")
127 + })
128 +}
129 +
130 +// TestUpdateWhileDaemonRuns verifies that read-only update subcommands
131 +// (check, versions) work while the IPFS daemon holds the repo lock.
132 +// These commands only query the GitHub API and never touch the repo,
133 +// so they must succeed regardless of daemon state.
134 +func TestUpdateWhileDaemonRuns(t *testing.T) {
135 + t.Parallel()
136 + node := harness.NewT(t).NewNode().Init().StartDaemon()
137 + defer node.StopDaemon()
138 +
139 + t.Run("check succeeds with daemon running", func(t *testing.T) {
140 + t.Parallel()
141 + res := node.IPFS("update", "check")
142 + out := res.Stdout.String()
143 + assert.True(t,
144 + strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"),
145 + "check must work while daemon runs, got: %s", out)
146 + })
147 +
148 + t.Run("versions succeeds with daemon running", func(t *testing.T) {
149 + t.Parallel()
150 + res := node.IPFS("update", "versions", "--count=3")
151 + lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
152 + assert.Greater(t, len(lines), 0,
153 + "versions must work while daemon runs")
154 + })
155 +}
156 +
157 +// TestUpdateInstall exercises the full install flow end-to-end:
158 +// API query, archive download, SHA-512 verification, tar.gz extraction,
159 +// binary stash (backup), and atomic replace.
160 +//
161 +// A local mock HTTP server replaces GitHub so the test is fast, offline,
162 +// and deterministic. The built ipfs binary is copied to a temp directory
163 +// so the install replaces the copy, not the real build artifact.
164 +//
165 +// The env var TEST_KUBO_UPDATE_GITHUB_URL redirects the binary's GitHub
166 +// API calls to the mock server. TEST_KUBO_VERSION makes the binary
167 +// report a specific version so the "upgrade" to v0.99.0 is deterministic.
168 +func TestUpdateInstall(t *testing.T) {
169 + // Not t.Parallel(): this test writes a copy of the ipfs binary and
170 + // then exec's it. Running in parallel with other tests exposes the
171 + // ETXTBSY race where a concurrent fork() in another test goroutine
172 + // inherits our still-open write fd, leaving the freshly written
173 + // file "text file busy" for exec until the sibling child execs.
174 + // Running sequentially guarantees no other goroutine is mid-fork
175 + // while we're writing.
176 +
177 + // Build a fake binary to put inside the archive. After install, the
178 + // file at tmpBinPath should contain exactly these bytes.
179 + fakeBinary := []byte("#!/bin/sh\necho fake-ipfs-v0.99.0\n")
180 +
181 + // Archive entry path: extractBinaryFromArchive looks for "kubo/<exename>".
182 + binName := "ipfs"
183 + if runtime.GOOS == "windows" {
184 + binName = "ipfs.exe"
185 + }
186 + var archive []byte
187 + if runtime.GOOS == "windows" {
188 + archive = buildTestZip(t, "kubo/"+binName, fakeBinary)
189 + } else {
190 + archive = buildTestTarGz(t, "kubo/"+binName, fakeBinary)
191 + }
192 +
193 + // Compute SHA-512 of the archive for the .sha512 sidecar file.
194 + sum := sha512.Sum512(archive)
195 +
196 + // Asset name must match what findReleaseAsset expects for the
197 + // current OS/arch (e.g., kubo_v0.99.0_linux-amd64.tar.gz).
198 + ext := "tar.gz"
199 + if runtime.GOOS == "windows" {
200 + ext = "zip"
201 + }
202 + assetName := fmt.Sprintf("kubo_v0.99.0_%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext)
203 + checksumBody := fmt.Sprintf("%x %s\n", sum[:], assetName)
204 +
205 + // Mock server: serves GitHub Releases API, archive, and .sha512 sidecar.
206 + // srvURL is captured after the server starts, so the handler can build
207 + // browser_download_url values pointing back to itself.
208 + var srvURL string
209 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210 + switch r.URL.Path {
211 + // githubReleaseByTag: GET /tags/v0.99.0
212 + case "/tags/v0.99.0":
213 + rel := map[string]any{
214 + "tag_name": "v0.99.0",
215 + "prerelease": false,
216 + "assets": []map[string]any{{
217 + "name": assetName,
218 + "browser_download_url": srvURL + "/download/" + assetName,
219 + }},
220 + }
221 + w.Header().Set("Content-Type", "application/json")
222 + _ = json.NewEncoder(w).Encode(rel)
223 +
224 + // downloadAsset: GET /download/<asset>.tar.gz
225 + case "/download/" + assetName:
226 + _, _ = w.Write(archive)
227 +
228 + // downloadAndVerifySHA512: GET /download/<asset>.tar.gz.sha512
229 + case "/download/" + assetName + ".sha512":
230 + _, _ = w.Write([]byte(checksumBody))
231 +
232 + default:
233 + http.NotFound(w, r)
234 + }
235 + }))
236 + t.Cleanup(srv.Close)
237 + srvURL = srv.URL
238 +
239 + // Copy the real built binary to a temp directory. The install command
240 + // uses os.Executable() to find the binary to replace, so the subprocess
241 + // will replace this copy instead of the real build artifact.
242 + tmpBinDir := t.TempDir()
243 + tmpBinPath := filepath.Join(tmpBinDir, binName)
244 + copyBuiltBinary(t, tmpBinPath)
245 +
246 + // Create a harness that uses the temp binary copy.
247 + h := harness.NewT(t, func(h *harness.Harness) {
248 + h.IPFSBin = tmpBinPath
249 + })
250 + node := h.NewNode()
251 +
252 + // Make the binary report v0.30.0 so the "upgrade" to v0.99.0 has a
253 + // deterministic from-version. Point API calls at the mock server.
254 + node.Runner.Env["TEST_KUBO_VERSION"] = "0.30.0"
255 + node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srvURL
256 +
257 + // Run: ipfs update install v0.99.0
258 + res := node.RunIPFS("update", "install", "v0.99.0")
259 + require.NoError(t, res.Err, "install failed; stderr:\n%s", res.Stderr.String())
260 +
261 + // Verify progress messages on stderr.
262 + stderr := res.Stderr.String()
263 + assert.Contains(t, stderr, "Downloading Kubo 0.99.0",
264 + "should show download progress")
265 + assert.Contains(t, stderr, "Checksum verified (SHA-512)",
266 + "should confirm checksum passed")
267 + assert.Contains(t, stderr, "Backed up current binary to",
268 + "should report where the old binary was stashed")
269 +
270 + // Verify the stash: the original binary should be saved to
271 + // $IPFS_PATH/old-bin/ipfs-0.30.0 (with .exe on Windows).
272 + stashName := "ipfs-0.30.0"
273 + if runtime.GOOS == "windows" {
274 + stashName += ".exe"
275 + }
276 + stashPath := filepath.Join(node.Dir, "old-bin", stashName)
277 + _, err := os.Stat(stashPath)
278 + require.NoError(t, err, "stash file should exist at %s", stashPath)
279 +
280 + // On Windows the OS locks the executable of a running process, so
281 + // atomicfile cannot rename over it. The install command falls back
282 + // to saving the new binary to a temp path with manual move instructions.
283 + if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") {
284 + assert.Contains(t, stderr, "Could not replace",
285 + "should explain why in-place replacement failed")
286 + assert.Contains(t, stderr, "New binary saved to:",
287 + "should print where the new binary was saved")
288 +
289 + // Extract the temp path from stderr and verify the file exists
290 + // with the expected content.
291 + for line := range strings.SplitSeq(stderr, "\n") {
292 + if savedPath, ok := strings.CutPrefix(line, "New binary saved to: "); ok {
293 + savedPath = strings.TrimSpace(savedPath)
294 + got, err := os.ReadFile(savedPath)
295 + require.NoError(t, err, "new binary should exist at %s", savedPath)
296 + assert.Equal(t, fakeBinary, got,
297 + "binary at %s should contain the extracted archive content", savedPath)
298 + break
299 + }
300 + }
301 + } else {
302 + // Non-Windows (or Windows where in-place replace succeeded):
303 + // binary was replaced atomically.
304 + assert.Contains(t, stderr, "Successfully updated Kubo 0.30.0 -> 0.99.0",
305 + "should confirm the version change")
306 + got, err := os.ReadFile(tmpBinPath)
307 + require.NoError(t, err)
308 + assert.Equal(t, fakeBinary, got,
309 + "binary at %s should contain the extracted archive content", tmpBinPath)
310 + }
311 +}
312 +
313 +// TestUpdateRevert exercises the full revert flow end-to-end: reading
314 +// a stashed binary from $IPFS_PATH/old-bin/, atomically replacing the
315 +// current binary, and cleaning up the stash file.
316 +//
317 +// The stash is created manually (rather than via install) so this test
318 +// is self-contained and does not depend on network access or a mock server.
319 +//
320 +// How it works: the subprocess runs from tmpBinPath, so os.Executable()
321 +// inside the subprocess returns tmpBinPath. The revert command reads the
322 +// stash and atomically replaces the file at tmpBinPath with stash content.
323 +func TestUpdateRevert(t *testing.T) {
324 + // Not t.Parallel(): same ETXTBSY rationale as TestUpdateInstall.
325 + // This test writes a binary copy and exec's it, which must not
326 + // overlap with concurrent fork() calls from other test goroutines.
327 +
328 + binName := "ipfs"
329 + if runtime.GOOS == "windows" {
330 + binName = "ipfs.exe"
331 + }
332 +
333 + // Copy the real built binary to a temp directory. Revert will replace
334 + // this copy with the stash content via os.Executable() -> tmpBinPath.
335 + tmpBinDir := t.TempDir()
336 + tmpBinPath := filepath.Join(tmpBinDir, binName)
337 + copyBuiltBinary(t, tmpBinPath)
338 +
339 + h := harness.NewT(t, func(h *harness.Harness) {
340 + h.IPFSBin = tmpBinPath
341 + })
342 + node := h.NewNode()
343 +
344 + // Create a stash directory with known content that differs from the
345 + // current binary. findLatestStash looks for ipfs-<semver> files.
346 + stashDir := filepath.Join(node.Dir, "old-bin")
347 + require.NoError(t, os.MkdirAll(stashDir, 0o755))
348 + stashName := "ipfs-0.30.0"
349 + if runtime.GOOS == "windows" {
350 + stashName = "ipfs-0.30.0.exe"
351 + }
352 + stashPath := filepath.Join(stashDir, stashName)
353 + stashContent := []byte("#!/bin/sh\necho reverted-to-0.30.0\n")
354 + require.NoError(t, os.WriteFile(stashPath, stashContent, 0o755))
355 +
356 + // Run: ipfs update revert
357 + // The subprocess executes from tmpBinPath (a real ipfs binary).
358 + // os.Executable() returns tmpBinPath, so revert replaces that file
359 + // with stashContent and removes the stash file.
360 + res := node.RunIPFS("update", "revert")
361 + require.NoError(t, res.Err, "revert failed; stderr:\n%s", res.Stderr.String())
362 +
363 + stderr := res.Stderr.String()
364 +
365 + // On Windows the OS locks the running binary, so the revert falls
366 + // back to saving to a temp path with manual move instructions.
367 + if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") {
368 + assert.Contains(t, stderr, "Could not replace",
369 + "should explain why in-place replacement failed")
370 + assert.Contains(t, stderr, "Reverted binary saved to:",
371 + "should print where the reverted binary was saved")
372 +
373 + // Verify the saved binary has the stash content.
374 + for line := range strings.SplitSeq(stderr, "\n") {
375 + if savedPath, ok := strings.CutPrefix(line, "Reverted binary saved to: "); ok {
376 + savedPath = strings.TrimSpace(savedPath)
377 + got, err := os.ReadFile(savedPath)
378 + require.NoError(t, err, "reverted binary should exist at %s", savedPath)
379 + assert.Equal(t, stashContent, got,
380 + "binary at %s should contain the stash content", savedPath)
381 + break
382 + }
383 + }
384 + } else {
385 + // Non-Windows: binary was replaced in place.
386 + assert.Contains(t, stderr, "Reverted to Kubo 0.30.0",
387 + "should confirm which version was restored")
388 +
389 + // Verify the stash file was cleaned up after successful revert.
390 + _, err := os.Stat(stashPath)
391 + assert.True(t, os.IsNotExist(err),
392 + "stash file should be removed after revert, but still exists at %s", stashPath)
393 +
394 + // Verify the binary was replaced with the stash content.
395 + got, err := os.ReadFile(tmpBinPath)
396 + require.NoError(t, err)
397 + assert.Equal(t, stashContent, got,
398 + "binary at %s should contain the stash content after revert", tmpBinPath)
399 + }
400 +}
401 +
402 +// TestUpdateClean exercises the cleanup command that drops every backed-up
403 +// Kubo binary from $IPFS_PATH/old-bin/. The test stages a stash directory
404 +// directly so it doesn't need network access or a real install.
405 +func TestUpdateClean(t *testing.T) {
406 + t.Parallel()
407 + h := harness.NewT(t)
408 + node := h.NewNode()
409 +
410 + stashDir := filepath.Join(node.Dir, "old-bin")
411 + require.NoError(t, os.MkdirAll(stashDir, 0o755))
412 +
413 + binSuffix := ""
414 + if runtime.GOOS == "windows" {
415 + binSuffix = ".exe"
416 + }
417 + stashFiles := []string{
418 + "ipfs-0.30.0" + binSuffix,
419 + "ipfs-0.31.0" + binSuffix,
420 + "ipfs-0.32.0" + binSuffix,
421 + }
422 + for _, name := range stashFiles {
423 + require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("fake"), 0o755))
424 + }
425 + // A file that does not match ipfs-<version> must be left alone so users
426 + // can store unrelated notes or scripts in old-bin/ without losing them.
427 + unrelated := filepath.Join(stashDir, "notes.txt")
428 + require.NoError(t, os.WriteFile(unrelated, []byte("keep me"), 0o644))
429 +
430 + t.Run("removes all stashed binaries", func(t *testing.T) {
431 + res := node.IPFS("update", "clean")
432 + out := res.Stdout.String()
433 + for _, name := range stashFiles {
434 + assert.Contains(t, out, name, "should report removing %s", name)
435 + _, err := os.Stat(filepath.Join(stashDir, name))
436 + assert.True(t, os.IsNotExist(err), "%s should be removed from disk", name)
437 + }
438 + _, err := os.Stat(unrelated)
439 + require.NoError(t, err, "unrelated files in old-bin/ must not be touched")
440 + })
441 +
442 + t.Run("reports nothing on empty stash", func(t *testing.T) {
443 + res := node.IPFS("update", "clean")
444 + assert.Contains(t, res.Stdout.String(), "No stashed binaries to remove")
445 + })
446 +
447 + t.Run("json output lists removed files and bytes freed", func(t *testing.T) {
448 + // Re-create one stash file to verify the JSON encoder.
449 + name := "ipfs-0.33.0" + binSuffix
450 + require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("data"), 0o755))
451 +
452 + res := node.IPFS("update", "clean", "--enc=json")
453 + var result struct {
454 + Removed []string
455 + BytesFreed int64
456 + }
457 + err := json.Unmarshal(res.Stdout.Bytes(), &result)
458 + require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
459 + assert.Equal(t, []string{name}, result.Removed)
460 + assert.Equal(t, int64(4), result.BytesFreed)
461 + })
462 +}
463 +
464 +// --- test helpers ---
465 +
466 +// copyBuiltBinary copies the built ipfs binary (cmd/ipfs/ipfs) to dst.
467 +// It locates the project root the same way the test harness does.
468 +func copyBuiltBinary(t *testing.T, dst string) {
469 + t.Helper()
470 + // Use a throwaway harness to resolve the default binary path,
471 + // reusing the same project-root lookup the harness already has.
472 + h := harness.NewT(t)
473 + srcBin := h.IPFSBin
474 + // The harness hardcodes "ipfs" without .exe suffix, but on Windows
475 + // the built binary is "ipfs.exe".
476 + if runtime.GOOS == "windows" && !strings.HasSuffix(srcBin, ".exe") {
477 + srcBin += ".exe"
478 + }
479 + data, err := os.ReadFile(srcBin)
480 + require.NoError(t, err, "failed to read built binary at %s (did you run 'make build'?)", srcBin)
481 + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755))
482 + require.NoError(t, os.WriteFile(dst, data, 0o755))
483 +}
484 +
485 +// buildTestTarGz creates an in-memory tar.gz archive with a single file entry.
486 +func buildTestTarGz(t *testing.T, path string, content []byte) []byte {
487 + t.Helper()
488 + var buf bytes.Buffer
489 + gzw := gzip.NewWriter(&buf)
490 + tw := tar.NewWriter(gzw)
491 + require.NoError(t, tw.WriteHeader(&tar.Header{
492 + Name: path,
493 + Mode: 0o755,
494 + Size: int64(len(content)),
495 + }))
496 + _, err := tw.Write(content)
497 + require.NoError(t, err)
498 + require.NoError(t, tw.Close())
499 + require.NoError(t, gzw.Close())
500 + return buf.Bytes()
501 +}
502 +
503 +// buildTestZip creates an in-memory zip archive with a single file entry.
504 +func buildTestZip(t *testing.T, path string, content []byte) []byte {
505 + t.Helper()
506 + var buf bytes.Buffer
507 + zw := zip.NewWriter(&buf)
508 + fw, err := zw.Create(path)
509 + require.NoError(t, err)
510 + _, err = fw.Write(content)
511 + require.NoError(t, err)
512 + require.NoError(t, zw.Close())
513 + return buf.Bytes()
514 +}
test/sharness/t0063-external.sh deleted
-49
@@ -1,49 +0,0 @@
1 -#!/usr/bin/env bash
2 -#
3 -# Copyright (c) 2015 Jeromy Johnson
4 -# MIT Licensed; see the LICENSE file in this repository.
5 -#
6 -
7 -test_description="test external command functionality"
8 -
9 -. lib/test-lib.sh
10 -
11 -
12 -# set here so daemon launches with it
13 -PATH=`pwd`/bin:$PATH
14 -
15 -test_init_ipfs
16 -
17 -test_expect_success "create fake ipfs-update bin" '
18 - mkdir bin &&
19 - echo "#!/bin/sh" > bin/ipfs-update &&
20 - echo "pwd" >> bin/ipfs-update &&
21 - echo "test -e \"$IPFS_PATH/repo.lock\" || echo \"repo not locked\" " >> bin/ipfs-update &&
22 - chmod +x bin/ipfs-update &&
23 - mkdir just_for_test
24 -'
25 -
26 -test_expect_success "external command runs from current user directory and doesn't lock repo" '
27 - (cd just_for_test && ipfs update) > actual
28 -'
29 -
30 -test_expect_success "output looks good" '
31 - echo `pwd`/just_for_test > exp &&
32 - echo "repo not locked" >> exp &&
33 - test_cmp exp actual
34 -'
35 -
36 -test_launch_ipfs_daemon
37 -
38 -test_expect_success "external command runs from current user directory when daemon is running" '
39 - (cd just_for_test && ipfs update) > actual
40 -'
41 -
42 -test_expect_success "output looks good" '
43 - echo `pwd`/just_for_test > exp &&
44 - test_cmp exp actual
45 -'
46 -
47 -test_kill_ipfs_daemon
48 -
49 -test_done