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