kubo/core/commands/update_github_test.go
Marcin Rataj 706aab385b 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
2026-02-16 19:41:29 +01:00

429 lines
14 KiB
Go

package commands
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha512"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- SHA-512 verification ---
//
// These tests verify the integrity-checking code that protects users from
// tampered or corrupted downloads. A broken hash check could allow
// installing a malicious binary, so each failure mode must be covered.
// TestVerifySHA512 exercises the low-level hash comparison function.
func TestVerifySHA512(t *testing.T) {
t.Parallel()
data := []byte("hello world")
sum := sha512.Sum512(data)
validHex := fmt.Sprintf("%x", sum[:])
t.Run("accepts matching hash", func(t *testing.T) {
t.Parallel()
err := verifySHA512(data, validHex)
assert.NoError(t, err)
})
t.Run("rejects data that does not match hash", func(t *testing.T) {
t.Parallel()
err := verifySHA512([]byte("tampered"), validHex)
assert.ErrorContains(t, err, "SHA-512 mismatch",
"must reject data whose hash differs from the expected value")
})
t.Run("rejects malformed hex string", func(t *testing.T) {
t.Parallel()
err := verifySHA512(data, "not-valid-hex")
assert.ErrorContains(t, err, "invalid hex in SHA-512 checksum")
})
}
// TestDownloadAndVerifySHA512 tests the complete download-and-verify flow:
// fetching a .sha512 sidecar file from alongside the archive URL, parsing
// the standard sha512sum format ("<hex> <filename>\n"), and comparing
// against the archive data. This is the function called by "ipfs update install".
func TestDownloadAndVerifySHA512(t *testing.T) {
t.Parallel()
archiveData := []byte("fake-archive-content")
sum := sha512.Sum512(archiveData)
checksumBody := fmt.Sprintf("%x kubo_v0.41.0_linux-amd64.tar.gz\n", sum[:])
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.tar.gz.sha512":
_, _ = w.Write([]byte(checksumBody))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
t.Run("accepts archive matching sidecar hash", func(t *testing.T) {
t.Parallel()
err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/archive.tar.gz")
assert.NoError(t, err)
})
t.Run("rejects archive with wrong content", func(t *testing.T) {
t.Parallel()
err := downloadAndVerifySHA512(t.Context(), []byte("tampered"), srv.URL+"/archive.tar.gz")
assert.ErrorContains(t, err, "SHA-512 mismatch",
"must hard-fail when downloaded archive doesn't match the published checksum")
})
t.Run("fails when sidecar file is missing", func(t *testing.T) {
t.Parallel()
err := downloadAndVerifySHA512(t.Context(), archiveData, srv.URL+"/no-such-file.tar.gz")
assert.ErrorContains(t, err, "downloading checksum file",
"must fail if the .sha512 sidecar can't be fetched")
})
}
// --- GitHub API layer ---
// TestGitHubGet verifies the low-level GitHub API helper that adds
// authentication headers and translates HTTP errors into actionable
// messages (especially rate-limit hints for unauthenticated users).
func TestGitHubGet(t *testing.T) {
t.Parallel()
t.Run("sets Accept and User-Agent headers", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept"),
"must request GitHub's v3 JSON format")
assert.Contains(t, r.Header.Get("User-Agent"), "kubo/",
"User-Agent must identify the kubo version for debugging")
_, _ = w.Write([]byte("{}"))
}))
t.Cleanup(srv.Close)
resp, err := githubGet(t.Context(), srv.URL)
require.NoError(t, err)
resp.Body.Close()
})
t.Run("returns rate-limit error on HTTP 403", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
_, err := githubGet(t.Context(), srv.URL)
assert.ErrorContains(t, err, "rate limit exceeded")
})
t.Run("returns rate-limit error on HTTP 429", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
t.Cleanup(srv.Close)
_, err := githubGet(t.Context(), srv.URL)
assert.ErrorContains(t, err, "rate limit exceeded")
})
t.Run("returns HTTP status on server error", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
_, err := githubGet(t.Context(), srv.URL)
assert.ErrorContains(t, err, "HTTP 500")
})
}
// TestGitHubListReleases verifies that release listing correctly filters
// prereleases and respects the count limit. Uses a mock GitHub API server
// to avoid network dependencies and rate limits in CI.
//
// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
func TestGitHubListReleases(t *testing.T) {
allReleases := []ghRelease{
{TagName: "v0.42.0-rc1", Prerelease: true},
{TagName: "v0.41.0"},
{TagName: "v0.40.0"},
}
body, err := json.Marshal(allReleases)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
saved := githubReleaseFmt
githubReleaseFmt = srv.URL
t.Cleanup(func() { githubReleaseFmt = saved })
t.Run("excludes prereleases by default", func(t *testing.T) {
got, err := githubListReleases(t.Context(), 10, false)
require.NoError(t, err)
assert.Len(t, got, 2, "the rc1 prerelease should be filtered out")
assert.Equal(t, "v0.41.0", got[0].TagName)
assert.Equal(t, "v0.40.0", got[1].TagName)
})
t.Run("includes prereleases when requested", func(t *testing.T) {
got, err := githubListReleases(t.Context(), 10, true)
require.NoError(t, err)
assert.Len(t, got, 3)
assert.Equal(t, "v0.42.0-rc1", got[0].TagName)
})
t.Run("respects count limit", func(t *testing.T) {
got, err := githubListReleases(t.Context(), 1, false)
require.NoError(t, err)
assert.Len(t, got, 1, "should return at most 1 release")
})
}
// TestGitHubLatestRelease verifies that the "find latest release" logic
// skips releases that don't have a binary for the current OS/arch.
// This handles the real-world case where a release tag is created but
// CI hasn't finished uploading build artifacts yet.
//
// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
func TestGitHubLatestRelease(t *testing.T) {
releases := []ghRelease{
{
TagName: "v0.42.0",
Assets: []ghAsset{{Name: "kubo_v0.42.0_some-other-arch.tar.gz"}},
},
{
TagName: "v0.41.0",
Assets: []ghAsset{{Name: assetNameForPlatformTag("v0.41.0")}},
},
}
body, err := json.Marshal(releases)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
saved := githubReleaseFmt
githubReleaseFmt = srv.URL
t.Cleanup(func() { githubReleaseFmt = saved })
rel, err := githubLatestRelease(t.Context(), false)
require.NoError(t, err)
assert.Equal(t, "v0.41.0", rel.TagName,
"should skip v0.42.0 (no binary for %s/%s) and return v0.41.0",
runtime.GOOS, runtime.GOARCH)
}
// TestFindReleaseAsset verifies that findReleaseAsset locates the correct
// platform-specific asset in a release, and returns a clear error when the
// release exists but has no binary for the current OS/arch.
//
// Not parallel: temporarily overrides the package-level githubReleaseFmt var.
func TestFindReleaseAsset(t *testing.T) {
wantAsset := assetNameForPlatformTag("v0.50.0")
release := ghRelease{
TagName: "v0.50.0",
Assets: []ghAsset{
{Name: "kubo_v0.50.0_some-other-arch.tar.gz", BrowserDownloadURL: "https://example.com/other"},
{Name: wantAsset, BrowserDownloadURL: "https://example.com/correct"},
},
}
body, err := json.Marshal(release)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
saved := githubReleaseFmt
githubReleaseFmt = srv.URL
t.Cleanup(func() { githubReleaseFmt = saved })
t.Run("returns matching asset for current platform", func(t *testing.T) {
rel, asset, err := findReleaseAsset(t.Context(), "v0.50.0")
require.NoError(t, err)
assert.Equal(t, "v0.50.0", rel.TagName)
assert.Equal(t, wantAsset, asset.Name)
assert.Equal(t, "https://example.com/correct", asset.BrowserDownloadURL)
})
t.Run("returns error when no asset matches current platform", func(t *testing.T) {
// Serve a release that only has an asset for a different arch.
noMatch := ghRelease{
TagName: "v0.51.0",
Assets: []ghAsset{{Name: "kubo_v0.51.0_plan9-mips.tar.gz"}},
}
noMatchBody, err := json.Marshal(noMatch)
require.NoError(t, err)
noMatchSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(noMatchBody)
}))
t.Cleanup(noMatchSrv.Close)
githubReleaseFmt = noMatchSrv.URL
_, _, err = findReleaseAsset(t.Context(), "v0.51.0")
assert.ErrorContains(t, err, "has no binary for",
"should explain that the release exists but lacks a matching asset")
})
}
// --- Asset download ---
// TestDownloadAsset verifies the HTTP download helper that fetches release
// archives from GitHub's CDN. Tests both the happy path and HTTP error
// reporting.
func TestDownloadAsset(t *testing.T) {
t.Parallel()
t.Run("downloads content successfully", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary-content"))
}))
t.Cleanup(srv.Close)
data, err := downloadAsset(t.Context(), srv.URL)
require.NoError(t, err)
assert.Equal(t, []byte("binary-content"), data)
})
t.Run("returns clear error on HTTP failure", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
_, err := downloadAsset(t.Context(), srv.URL)
assert.ErrorContains(t, err, "HTTP 404")
})
}
// --- Archive extraction ---
// TestExtractBinaryFromArchive verifies that the ipfs binary can be
// extracted from release archives. Kubo releases use tar.gz on Unix
// and zip on Windows, with the binary at "kubo/ipfs" inside the archive.
func TestExtractBinaryFromArchive(t *testing.T) {
t.Parallel()
t.Run("extracts binary from valid tar.gz", func(t *testing.T) {
t.Parallel()
wantContent := []byte("#!/bin/fake-ipfs-binary")
archive := makeTarGz(t, "kubo/ipfs", wantContent)
got, err := extractBinaryFromArchive(archive)
require.NoError(t, err)
assert.Equal(t, wantContent, got)
})
t.Run("rejects archive without kubo/ipfs entry", func(t *testing.T) {
t.Parallel()
// A valid tar.gz that contains a file at the wrong path.
archive := makeTarGz(t, "wrong-path/ipfs", []byte("binary"))
_, err := extractBinaryFromArchive(archive)
assert.ErrorContains(t, err, "could not find ipfs binary")
})
t.Run("rejects non-archive data", func(t *testing.T) {
t.Parallel()
_, err := extractBinaryFromArchive([]byte("not an archive"))
assert.ErrorContains(t, err, "could not find ipfs binary")
})
}
// makeTarGz creates an in-memory tar.gz archive containing a single file.
func makeTarGz(t *testing.T, path string, content []byte) []byte {
t.Helper()
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: path,
Mode: 0o755,
Size: int64(len(content)),
}))
_, err := tw.Write(content)
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gzw.Close())
return buf.Bytes()
}
// --- Asset name and version helpers ---
// TestAssetNameForPlatformTag ensures the archive filename matches the
// naming convention used by Kubo's CI release pipeline:
//
// kubo_<tag>_<os>-<arch>.<ext>
func TestAssetNameForPlatformTag(t *testing.T) {
t.Parallel()
name := assetNameForPlatformTag("v0.41.0")
assert.Contains(t, name, fmt.Sprintf("kubo_v0.41.0_%s-%s.", runtime.GOOS, runtime.GOARCH))
if runtime.GOOS == "windows" {
assert.Contains(t, name, ".zip")
} else {
assert.Contains(t, name, ".tar.gz")
}
}
// TestVersionHelpers exercises the version string utilities used throughout
// the update command. These handle the mismatch between Go's semver
// (no "v" prefix) and GitHub's tag convention ("v" prefix).
func TestVersionHelpers(t *testing.T) {
t.Parallel()
t.Run("trimVPrefix strips leading v", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "0.41.0", trimVPrefix("v0.41.0"))
assert.Equal(t, "0.41.0", trimVPrefix("0.41.0"), "no-op when v is absent")
})
t.Run("normalizeVersion adds v prefix for GitHub tags", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "v0.41.0", normalizeVersion("0.41.0"))
assert.Equal(t, "v0.41.0", normalizeVersion("v0.41.0"), "no-op when v is present")
assert.Equal(t, "v0.41.0", normalizeVersion(" v0.41.0 "), "trims whitespace")
})
t.Run("isNewerVersion compares semver correctly", func(t *testing.T) {
t.Parallel()
tests := []struct {
current, target string
wantNewer bool
desc string
}{
{"0.40.0", "0.41.0", true, "newer minor version"},
{"0.41.0", "0.40.0", false, "older minor version"},
{"0.41.0", "0.41.0", false, "same version"},
{"0.41.0-dev", "0.41.0", true, "release is newer than dev pre-release"},
}
for _, tt := range tests {
got, err := isNewerVersion(tt.current, tt.target)
require.NoError(t, err)
assert.Equal(t, tt.wantNewer, got, tt.desc)
}
})
}