mirror of
https://github.com/ipfs/kubo.git
synced 2026-02-21 18:37:45 +08:00
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
429 lines
14 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|