master
go 563 lines 20.4 KB
Raw
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.
25 //
26 // A local httptest server replaces GitHub Releases so the test does not
27 // depend on network reachability or rate limits. The node is created
28 // without Init or daemon, so install/revert error paths that don't
29 // depend on a running daemon can be tested.
30 func TestUpdate(t *testing.T) {
31 t.Parallel()
32 h := harness.NewT(t)
33 node := h.NewNode()
34
35 srv := newMockGitHubReleases(t)
36 node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srv.URL
37
38 t.Run("help text describes the command", func(t *testing.T) {
39 t.Parallel()
40 res := node.IPFS("update", "--help")
41 assert.Contains(t, res.Stdout.String(), "Update Kubo to a different version")
42 })
43
44 // check and versions are read-only GitHub API queries. They must work
45 // regardless of daemon state, since users need to check for updates
46 // before deciding whether to stop the daemon and install.
47 t.Run("check", func(t *testing.T) {
48 t.Parallel()
49
50 t.Run("text output reports update availability", func(t *testing.T) {
51 t.Parallel()
52 res := node.IPFS("update", "check")
53 out := res.Stdout.String()
54 assert.True(t,
55 strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"),
56 "expected update status message, got: %s", out)
57 })
58
59 t.Run("json output includes version fields", func(t *testing.T) {
60 t.Parallel()
61 res := node.IPFS("update", "check", "--enc=json")
62 var result struct {
63 CurrentVersion string
64 LatestVersion string
65 UpdateAvailable bool
66 }
67 err := json.Unmarshal(res.Stdout.Bytes(), &result)
68 require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
69 assert.NotEmpty(t, result.CurrentVersion, "must report current version")
70 assert.NotEmpty(t, result.LatestVersion, "must report latest version")
71 })
72 })
73
74 t.Run("versions", func(t *testing.T) {
75 t.Parallel()
76
77 t.Run("lists available versions", func(t *testing.T) {
78 t.Parallel()
79 res := node.IPFS("update", "versions")
80 lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
81 assert.Greater(t, len(lines), 0, "should list at least one version")
82 })
83
84 t.Run("respects --count flag", func(t *testing.T) {
85 t.Parallel()
86 res := node.IPFS("update", "versions", "--count=5")
87 lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
88 assert.LessOrEqual(t, len(lines), 5)
89 })
90
91 t.Run("json output includes current version and list", func(t *testing.T) {
92 t.Parallel()
93 res := node.IPFS("update", "versions", "--count=3", "--enc=json")
94 var result struct {
95 Current string
96 Versions []string
97 }
98 err := json.Unmarshal(res.Stdout.Bytes(), &result)
99 require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
100 assert.NotEmpty(t, result.Current, "must report current version")
101 assert.NotEmpty(t, result.Versions, "must list at least one version")
102 })
103
104 t.Run("--pre includes prerelease versions", func(t *testing.T) {
105 t.Parallel()
106 res := node.IPFS("update", "versions", "--count=5", "--pre")
107 lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
108 assert.Greater(t, len(lines), 0, "should list at least one version")
109 })
110 })
111
112 // install and revert mutate the binary on disk, so they have stricter
113 // preconditions. These tests verify the error paths.
114 t.Run("install rejects same version", func(t *testing.T) {
115 t.Parallel()
116 vRes := node.IPFS("version", "-n")
117 current := strings.TrimSpace(vRes.Stdout.String())
118
119 res := node.RunIPFS("update", "install", current)
120 assert.Error(t, res.Err)
121 assert.Contains(t, res.Stderr.String(), "already running version",
122 "should refuse to re-install the current version")
123 })
124
125 t.Run("revert fails when no backup exists", func(t *testing.T) {
126 t.Parallel()
127 res := node.RunIPFS("update", "revert")
128 assert.Error(t, res.Err)
129 assert.Contains(t, res.Stderr.String(), "no stashed binaries",
130 "should explain there is no previous version to restore")
131 })
132 }
133
134 // TestUpdateWhileDaemonRuns verifies that read-only update subcommands
135 // (check, versions) work while the IPFS daemon holds the repo lock.
136 // These commands only query the GitHub API and never touch the repo,
137 // so they must succeed regardless of daemon state.
138 //
139 // A local httptest server replaces GitHub so the test does not depend
140 // on network reachability or GitHub rate limits. The locking behavior
141 // under test is independent of which endpoint serves the release JSON.
142 func TestUpdateWhileDaemonRuns(t *testing.T) {
143 t.Parallel()
144
145 srv := newMockGitHubReleases(t)
146 node := harness.NewT(t).NewNode()
147 node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srv.URL
148 node.Init().StartDaemon()
149 defer node.StopDaemon()
150
151 t.Run("check succeeds with daemon running", func(t *testing.T) {
152 t.Parallel()
153 res := node.IPFS("update", "check")
154 out := res.Stdout.String()
155 assert.True(t,
156 strings.Contains(out, "Update available") || strings.Contains(out, "Already up to date"),
157 "check must work while daemon runs, got: %s", out)
158 })
159
160 t.Run("versions succeeds with daemon running", func(t *testing.T) {
161 t.Parallel()
162 res := node.IPFS("update", "versions", "--count=3")
163 lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n")
164 assert.Greater(t, len(lines), 0,
165 "versions must work while daemon runs")
166 })
167 }
168
169 // TestUpdateInstall exercises the full install flow end-to-end:
170 // API query, archive download, SHA-512 verification, tar.gz extraction,
171 // binary stash (backup), and atomic replace.
172 //
173 // A local mock HTTP server replaces GitHub so the test is fast, offline,
174 // and deterministic. The built ipfs binary is copied to a temp directory
175 // so the install replaces the copy, not the real build artifact.
176 //
177 // The env var TEST_KUBO_UPDATE_GITHUB_URL redirects the binary's GitHub
178 // API calls to the mock server. TEST_KUBO_VERSION makes the binary
179 // report a specific version so the "upgrade" to v0.99.0 is deterministic.
180 func TestUpdateInstall(t *testing.T) {
181 // Not t.Parallel(): this test writes a copy of the ipfs binary and
182 // then exec's it. Running in parallel with other tests exposes the
183 // ETXTBSY race where a concurrent fork() in another test goroutine
184 // inherits our still-open write fd, leaving the freshly written
185 // file "text file busy" for exec until the sibling child execs.
186 // Running sequentially guarantees no other goroutine is mid-fork
187 // while we're writing.
188
189 // Build a fake binary to put inside the archive. After install, the
190 // file at tmpBinPath should contain exactly these bytes.
191 fakeBinary := []byte("#!/bin/sh\necho fake-ipfs-v0.99.0\n")
192
193 // Archive entry path: extractBinaryFromArchive looks for "kubo/<exename>".
194 binName := "ipfs"
195 if runtime.GOOS == "windows" {
196 binName = "ipfs.exe"
197 }
198 var archive []byte
199 if runtime.GOOS == "windows" {
200 archive = buildTestZip(t, "kubo/"+binName, fakeBinary)
201 } else {
202 archive = buildTestTarGz(t, "kubo/"+binName, fakeBinary)
203 }
204
205 // Compute SHA-512 of the archive for the .sha512 sidecar file.
206 sum := sha512.Sum512(archive)
207
208 // Asset name must match what findReleaseAsset expects for the
209 // current OS/arch (e.g., kubo_v0.99.0_linux-amd64.tar.gz).
210 ext := "tar.gz"
211 if runtime.GOOS == "windows" {
212 ext = "zip"
213 }
214 assetName := fmt.Sprintf("kubo_v0.99.0_%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext)
215 checksumBody := fmt.Sprintf("%x %s\n", sum[:], assetName)
216
217 // Mock server: serves GitHub Releases API, archive, and .sha512 sidecar.
218 // srvURL is captured after the server starts, so the handler can build
219 // browser_download_url values pointing back to itself.
220 var srvURL string
221 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222 switch r.URL.Path {
223 // githubReleaseByTag: GET /tags/v0.99.0
224 case "/tags/v0.99.0":
225 rel := map[string]any{
226 "tag_name": "v0.99.0",
227 "prerelease": false,
228 "assets": []map[string]any{{
229 "name": assetName,
230 "browser_download_url": srvURL + "/download/" + assetName,
231 }},
232 }
233 w.Header().Set("Content-Type", "application/json")
234 _ = json.NewEncoder(w).Encode(rel)
235
236 // downloadAsset: GET /download/<asset>.tar.gz
237 case "/download/" + assetName:
238 _, _ = w.Write(archive)
239
240 // downloadAndVerifySHA512: GET /download/<asset>.tar.gz.sha512
241 case "/download/" + assetName + ".sha512":
242 _, _ = w.Write([]byte(checksumBody))
243
244 default:
245 http.NotFound(w, r)
246 }
247 }))
248 t.Cleanup(srv.Close)
249 srvURL = srv.URL
250
251 // Copy the real built binary to a temp directory. The install command
252 // uses os.Executable() to find the binary to replace, so the subprocess
253 // will replace this copy instead of the real build artifact.
254 tmpBinDir := t.TempDir()
255 tmpBinPath := filepath.Join(tmpBinDir, binName)
256 copyBuiltBinary(t, tmpBinPath)
257
258 // Create a harness that uses the temp binary copy.
259 h := harness.NewT(t, func(h *harness.Harness) {
260 h.IPFSBin = tmpBinPath
261 })
262 node := h.NewNode()
263
264 // Make the binary report v0.30.0 so the "upgrade" to v0.99.0 has a
265 // deterministic from-version. Point API calls at the mock server.
266 node.Runner.Env["TEST_KUBO_VERSION"] = "0.30.0"
267 node.Runner.Env["TEST_KUBO_UPDATE_GITHUB_URL"] = srvURL
268
269 // Run: ipfs update install v0.99.0
270 res := node.RunIPFS("update", "install", "v0.99.0")
271 require.NoError(t, res.Err, "install failed; stderr:\n%s", res.Stderr.String())
272
273 // Verify progress messages on stderr.
274 stderr := res.Stderr.String()
275 assert.Contains(t, stderr, "Downloading Kubo 0.99.0",
276 "should show download progress")
277 assert.Contains(t, stderr, "Checksum verified (SHA-512)",
278 "should confirm checksum passed")
279 assert.Contains(t, stderr, "Backed up current binary to",
280 "should report where the old binary was stashed")
281
282 // Verify the stash: the original binary should be saved to
283 // $IPFS_PATH/old-bin/ipfs-0.30.0 (with .exe on Windows).
284 stashName := "ipfs-0.30.0"
285 if runtime.GOOS == "windows" {
286 stashName += ".exe"
287 }
288 stashPath := filepath.Join(node.Dir, "old-bin", stashName)
289 _, err := os.Stat(stashPath)
290 require.NoError(t, err, "stash file should exist at %s", stashPath)
291
292 // On Windows the OS locks the executable of a running process, so
293 // atomicfile cannot rename over it. The install command falls back
294 // to saving the new binary to a temp path with manual move instructions.
295 if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") {
296 assert.Contains(t, stderr, "Could not replace",
297 "should explain why in-place replacement failed")
298 assert.Contains(t, stderr, "New binary saved to:",
299 "should print where the new binary was saved")
300
301 // Extract the temp path from stderr and verify the file exists
302 // with the expected content.
303 for line := range strings.SplitSeq(stderr, "\n") {
304 if savedPath, ok := strings.CutPrefix(line, "New binary saved to: "); ok {
305 savedPath = strings.TrimSpace(savedPath)
306 got, err := os.ReadFile(savedPath)
307 require.NoError(t, err, "new binary should exist at %s", savedPath)
308 assert.Equal(t, fakeBinary, got,
309 "binary at %s should contain the extracted archive content", savedPath)
310 break
311 }
312 }
313 } else {
314 // Non-Windows (or Windows where in-place replace succeeded):
315 // binary was replaced atomically.
316 assert.Contains(t, stderr, "Successfully updated Kubo 0.30.0 -> 0.99.0",
317 "should confirm the version change")
318 got, err := os.ReadFile(tmpBinPath)
319 require.NoError(t, err)
320 assert.Equal(t, fakeBinary, got,
321 "binary at %s should contain the extracted archive content", tmpBinPath)
322 }
323 }
324
325 // TestUpdateRevert exercises the full revert flow end-to-end: reading
326 // a stashed binary from $IPFS_PATH/old-bin/, atomically replacing the
327 // current binary, and cleaning up the stash file.
328 //
329 // The stash is created manually (rather than via install) so this test
330 // is self-contained and does not depend on network access or a mock server.
331 //
332 // How it works: the subprocess runs from tmpBinPath, so os.Executable()
333 // inside the subprocess returns tmpBinPath. The revert command reads the
334 // stash and atomically replaces the file at tmpBinPath with stash content.
335 func TestUpdateRevert(t *testing.T) {
336 // Not t.Parallel(): same ETXTBSY rationale as TestUpdateInstall.
337 // This test writes a binary copy and exec's it, which must not
338 // overlap with concurrent fork() calls from other test goroutines.
339
340 binName := "ipfs"
341 if runtime.GOOS == "windows" {
342 binName = "ipfs.exe"
343 }
344
345 // Copy the real built binary to a temp directory. Revert will replace
346 // this copy with the stash content via os.Executable() -> tmpBinPath.
347 tmpBinDir := t.TempDir()
348 tmpBinPath := filepath.Join(tmpBinDir, binName)
349 copyBuiltBinary(t, tmpBinPath)
350
351 h := harness.NewT(t, func(h *harness.Harness) {
352 h.IPFSBin = tmpBinPath
353 })
354 node := h.NewNode()
355
356 // Create a stash directory with known content that differs from the
357 // current binary. findLatestStash looks for ipfs-<semver> files.
358 stashDir := filepath.Join(node.Dir, "old-bin")
359 require.NoError(t, os.MkdirAll(stashDir, 0o755))
360 stashName := "ipfs-0.30.0"
361 if runtime.GOOS == "windows" {
362 stashName = "ipfs-0.30.0.exe"
363 }
364 stashPath := filepath.Join(stashDir, stashName)
365 stashContent := []byte("#!/bin/sh\necho reverted-to-0.30.0\n")
366 require.NoError(t, os.WriteFile(stashPath, stashContent, 0o755))
367
368 // Run: ipfs update revert
369 // The subprocess executes from tmpBinPath (a real ipfs binary).
370 // os.Executable() returns tmpBinPath, so revert replaces that file
371 // with stashContent and removes the stash file.
372 res := node.RunIPFS("update", "revert")
373 require.NoError(t, res.Err, "revert failed; stderr:\n%s", res.Stderr.String())
374
375 stderr := res.Stderr.String()
376
377 // On Windows the OS locks the running binary, so the revert falls
378 // back to saving to a temp path with manual move instructions.
379 if runtime.GOOS == "windows" && strings.Contains(stderr, "Move it manually") {
380 assert.Contains(t, stderr, "Could not replace",
381 "should explain why in-place replacement failed")
382 assert.Contains(t, stderr, "Reverted binary saved to:",
383 "should print where the reverted binary was saved")
384
385 // Verify the saved binary has the stash content.
386 for line := range strings.SplitSeq(stderr, "\n") {
387 if savedPath, ok := strings.CutPrefix(line, "Reverted binary saved to: "); ok {
388 savedPath = strings.TrimSpace(savedPath)
389 got, err := os.ReadFile(savedPath)
390 require.NoError(t, err, "reverted binary should exist at %s", savedPath)
391 assert.Equal(t, stashContent, got,
392 "binary at %s should contain the stash content", savedPath)
393 break
394 }
395 }
396 } else {
397 // Non-Windows: binary was replaced in place.
398 assert.Contains(t, stderr, "Reverted to Kubo 0.30.0",
399 "should confirm which version was restored")
400
401 // Verify the stash file was cleaned up after successful revert.
402 _, err := os.Stat(stashPath)
403 assert.True(t, os.IsNotExist(err),
404 "stash file should be removed after revert, but still exists at %s", stashPath)
405
406 // Verify the binary was replaced with the stash content.
407 got, err := os.ReadFile(tmpBinPath)
408 require.NoError(t, err)
409 assert.Equal(t, stashContent, got,
410 "binary at %s should contain the stash content after revert", tmpBinPath)
411 }
412 }
413
414 // TestUpdateClean exercises the cleanup command that drops every backed-up
415 // Kubo binary from $IPFS_PATH/old-bin/. The test stages a stash directory
416 // directly so it doesn't need network access or a real install.
417 func TestUpdateClean(t *testing.T) {
418 t.Parallel()
419 h := harness.NewT(t)
420 node := h.NewNode()
421
422 stashDir := filepath.Join(node.Dir, "old-bin")
423 require.NoError(t, os.MkdirAll(stashDir, 0o755))
424
425 binSuffix := ""
426 if runtime.GOOS == "windows" {
427 binSuffix = ".exe"
428 }
429 stashFiles := []string{
430 "ipfs-0.30.0" + binSuffix,
431 "ipfs-0.31.0" + binSuffix,
432 "ipfs-0.32.0" + binSuffix,
433 }
434 for _, name := range stashFiles {
435 require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("fake"), 0o755))
436 }
437 // A file that does not match ipfs-<version> must be left alone so users
438 // can store unrelated notes or scripts in old-bin/ without losing them.
439 unrelated := filepath.Join(stashDir, "notes.txt")
440 require.NoError(t, os.WriteFile(unrelated, []byte("keep me"), 0o644))
441
442 t.Run("removes all stashed binaries", func(t *testing.T) {
443 res := node.IPFS("update", "clean")
444 out := res.Stdout.String()
445 for _, name := range stashFiles {
446 assert.Contains(t, out, name, "should report removing %s", name)
447 _, err := os.Stat(filepath.Join(stashDir, name))
448 assert.True(t, os.IsNotExist(err), "%s should be removed from disk", name)
449 }
450 _, err := os.Stat(unrelated)
451 require.NoError(t, err, "unrelated files in old-bin/ must not be touched")
452 })
453
454 t.Run("reports nothing on empty stash", func(t *testing.T) {
455 res := node.IPFS("update", "clean")
456 assert.Contains(t, res.Stdout.String(), "No stashed binaries to remove")
457 })
458
459 t.Run("json output lists removed files and bytes freed", func(t *testing.T) {
460 // Re-create one stash file to verify the JSON encoder.
461 name := "ipfs-0.33.0" + binSuffix
462 require.NoError(t, os.WriteFile(filepath.Join(stashDir, name), []byte("data"), 0o755))
463
464 res := node.IPFS("update", "clean", "--enc=json")
465 var result struct {
466 Removed []string
467 BytesFreed int64
468 }
469 err := json.Unmarshal(res.Stdout.Bytes(), &result)
470 require.NoError(t, err, "invalid JSON: %s", res.Stdout.String())
471 assert.Equal(t, []string{name}, result.Removed)
472 assert.Equal(t, int64(4), result.BytesFreed)
473 })
474 }
475
476 // --- test helpers ---
477
478 // newMockGitHubReleases returns an httptest server that mimics the GitHub
479 // Releases listing API with a single stable release at v0.99.0 carrying
480 // a binary asset for the current GOOS/GOARCH. This is enough to drive
481 // "ipfs update check" and "ipfs update versions" without touching the
482 // real api.github.com.
483 //
484 // The asset name follows the same convention used by real kubo releases
485 // (see https://github.com/ipfs/kubo/releases): kubo_<tag>_<os>-<arch>.<ext>,
486 // where ext is "zip" on Windows and "tar.gz" everywhere else. This must
487 // match what assetNameForPlatformTag produces in core/commands/update_github.go,
488 // otherwise findReleaseAsset cannot locate the binary and reports
489 // "no release found with a binary for <os>/<arch>".
490 func newMockGitHubReleases(t *testing.T) *httptest.Server {
491 t.Helper()
492 ext := "tar.gz"
493 if runtime.GOOS == "windows" {
494 ext = "zip"
495 }
496 asset := fmt.Sprintf("kubo_v0.99.0_%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext)
497 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
498 // Both `update check` and `update versions` call
499 // GET /releases?per_page=N. One stable release with a matching
500 // platform asset exercises both paths.
501 rels := []map[string]any{{
502 "tag_name": "v0.99.0",
503 "prerelease": false,
504 "assets": []map[string]any{{
505 "name": asset,
506 }},
507 }}
508 w.Header().Set("Content-Type", "application/json")
509 _ = json.NewEncoder(w).Encode(rels)
510 }))
511 t.Cleanup(srv.Close)
512 return srv
513 }
514
515 // copyBuiltBinary copies the built ipfs binary (cmd/ipfs/ipfs) to dst.
516 // It locates the project root the same way the test harness does.
517 func copyBuiltBinary(t *testing.T, dst string) {
518 t.Helper()
519 // Use a throwaway harness to resolve the default binary path,
520 // reusing the same project-root lookup the harness already has.
521 h := harness.NewT(t)
522 srcBin := h.IPFSBin
523 // The harness hardcodes "ipfs" without .exe suffix, but on Windows
524 // the built binary is "ipfs.exe".
525 if runtime.GOOS == "windows" && !strings.HasSuffix(srcBin, ".exe") {
526 srcBin += ".exe"
527 }
528 data, err := os.ReadFile(srcBin)
529 require.NoError(t, err, "failed to read built binary at %s (did you run 'make build'?)", srcBin)
530 require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755))
531 require.NoError(t, os.WriteFile(dst, data, 0o755))
532 }
533
534 // buildTestTarGz creates an in-memory tar.gz archive with a single file entry.
535 func buildTestTarGz(t *testing.T, path string, content []byte) []byte {
536 t.Helper()
537 var buf bytes.Buffer
538 gzw := gzip.NewWriter(&buf)
539 tw := tar.NewWriter(gzw)
540 require.NoError(t, tw.WriteHeader(&tar.Header{
541 Name: path,
542 Mode: 0o755,
543 Size: int64(len(content)),
544 }))
545 _, err := tw.Write(content)
546 require.NoError(t, err)
547 require.NoError(t, tw.Close())
548 require.NoError(t, gzw.Close())
549 return buf.Bytes()
550 }
551
552 // buildTestZip creates an in-memory zip archive with a single file entry.
553 func buildTestZip(t *testing.T, path string, content []byte) []byte {
554 t.Helper()
555 var buf bytes.Buffer
556 zw := zip.NewWriter(&buf)
557 fw, err := zw.Create(path)
558 require.NoError(t, err)
559 _, err = fw.Write(content)
560 require.NoError(t, err)
561 require.NoError(t, zw.Close())
562 return buf.Bytes()
563 }