diff --git a/.gitignore b/.gitignore index 7978ffab..068dd1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only .superpowers/ docs/superpowers/ +zero-linux-sandbox diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 9447d643..4030ccec 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -42,3 +42,19 @@ Endpoint resolution order: Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, run `zero update --check` before reinstalling. + +## Authentication + +Unauthenticated GitHub API requests are subject to strict +[rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). +If you see `403 Forbidden` during update checks, set a GitHub personal access token: + +| Environment variable | Role | +|---|---| +| `ZERO_GITHUB_TOKEN` | Used for update checks (takes precedence) | +| `GITHUB_TOKEN` | Fallback when `ZERO_GITHUB_TOKEN` is not set | + +Tokens are **only** sent to `https://api.github.com`. Custom endpoints (set via +`--endpoint` or `ZERO_UPDATE_RELEASE_URL`) and plain HTTP URLs never receive +credentials, so you can safely point Zero at a private mirror without leaking +your token. diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 054148ad..012add53 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -1291,7 +1291,7 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target"} { + for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target", "ZERO_GITHUB_TOKEN", "GITHUB_TOKEN"} { if !strings.Contains(stdout.String(), want) { t.Fatalf("expected update help to document %s, got %q", want, stdout.String()) } diff --git a/internal/cli/update.go b/internal/cli/update.go index 8d2c5d0c..1fccd634 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -207,6 +207,11 @@ Flags: --timeout Release check timeout (default 5s) --target Release target to verify with --check (for example windows-x64); not valid with --apply -h, --help Show this help + +Environment: + ZERO_GITHUB_TOKEN Token for update checks (takes precedence over GITHUB_TOKEN) + Only sent to https://api.github.com; never sent to custom endpoints + ZERO_UPDATE_RELEASE_URL Override the release API URL (same as --endpoint) `) return err } diff --git a/internal/update/auth_test.go b/internal/update/auth_test.go new file mode 100644 index 00000000..adc7e6a4 --- /dev/null +++ b/internal/update/auth_test.go @@ -0,0 +1,128 @@ +package update + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +// authTransport intercepts HTTP requests and records the Authorization header +// so tests can verify whether fetchRelease sent credentials. +type authTransport struct { + t *testing.T + receivedAuth string + responseBody string + responseCode int +} + +func (at *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + at.receivedAuth = req.Header.Get("Authorization") + at.t.Logf("authTransport: %s %s → Authorization: %q", req.Method, req.URL.String(), at.receivedAuth) + if at.responseBody == "" { + at.responseBody = `{"tag_name":"v0.2.0","html_url":"https://example.test/release","assets":[{"name":"zero-v0.2.0-linux-x64.tar.gz","browser_download_url":"https://example.test/zero-v0.2.0-linux-x64.tar.gz"},{"name":"zero-v0.2.0-linux-x64.tar.gz.sha256","browser_download_url":"https://example.test/zero-v0.2.0-linux-x64.tar.gz.sha256"}]}` + } + if at.responseCode == 0 { + at.responseCode = 200 + } + return &http.Response{ + StatusCode: at.responseCode, + Body: io.NopCloser(strings.NewReader(at.responseBody)), + Header: make(http.Header), + }, nil +} + +func TestFetchReleaseSendsAuthToHttpsGithub(t *testing.T) { + // ZERO_GITHUB_TOKEN → sent to https://api.github.com + at := &authTransport{t: t} + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: at} + t.Cleanup(func() { http.DefaultClient = oldClient }) + + t.Setenv("ZERO_GITHUB_TOKEN", "zero_token") + t.Setenv("GITHUB_TOKEN", "github_fallback") + + _, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest") + if err != nil { + t.Logf("fetchRelease returned error (expected for fake response): %v", err) + } + if at.receivedAuth != "Bearer zero_token" { + t.Fatalf("ZERO_GITHUB_TOKEN should take precedence: got Authorization %q, want %q", at.receivedAuth, "Bearer zero_token") + } +} + +func TestFetchReleaseFallsBackToGithubToken(t *testing.T) { + // Only GITHUB_TOKEN set → sent to https://api.github.com + at := &authTransport{t: t} + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: at} + t.Cleanup(func() { http.DefaultClient = oldClient }) + + t.Setenv("GITHUB_TOKEN", "fallback_token") + t.Setenv("ZERO_GITHUB_TOKEN", "") // clear ambient precedence var + + _, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest") + if err != nil { + t.Logf("fetchRelease returned error (expected for fake response): %v", err) + } + if at.receivedAuth != "Bearer fallback_token" { + t.Fatalf("GITHUB_TOKEN should be used as fallback: got Authorization %q, want %q", at.receivedAuth, "Bearer fallback_token") + } +} + +func TestFetchReleaseNoAuthToCustomEndpoint(t *testing.T) { + // Token set but endpoint is custom host → no auth sent + at := &authTransport{t: t} + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: at} + t.Cleanup(func() { http.DefaultClient = oldClient }) + + t.Setenv("ZERO_GITHUB_TOKEN", "secret") + t.Setenv("GITHUB_TOKEN", "fallback") + + _, err := fetchRelease(context.Background(), "https://internal.mirror.example.com/releases/latest") + if err != nil { + t.Logf("fetchRelease returned error (expected for fake response): %v", err) + } + if at.receivedAuth != "" { + t.Fatalf("auth should not be sent to custom endpoint: got Authorization %q", at.receivedAuth) + } +} + +func TestFetchReleaseNoAuthToHttpGithub(t *testing.T) { + // Token set but scheme is HTTP → no auth sent even to api.github.com + at := &authTransport{t: t} + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: at} + t.Cleanup(func() { http.DefaultClient = oldClient }) + + t.Setenv("ZERO_GITHUB_TOKEN", "secret") + + _, err := fetchRelease(context.Background(), "http://api.github.com/repos/Gitlawb/zero/releases/latest") + if err != nil { + t.Logf("fetchRelease returned error (expected for fake response): %v", err) + } + if at.receivedAuth != "" { + t.Fatalf("auth should not be sent over HTTP: got Authorization %q", at.receivedAuth) + } +} + +func TestFetchReleaseNoAuthWhenTokensNotSet(t *testing.T) { + // No env vars set → no auth sent (authenticated fallback) + at := &authTransport{t: t} + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: at} + t.Cleanup(func() { http.DefaultClient = oldClient }) + + t.Setenv("ZERO_GITHUB_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + + _, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest") + if err != nil { + t.Logf("fetchRelease returned error (expected for fake response): %v", err) + } + if at.receivedAuth != "" { + t.Fatalf("no auth should be sent when no tokens set: got Authorization %q", at.receivedAuth) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index e3eb5c9b..f8807445 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,6 +17,9 @@ import ( const ( DefaultRepository = "Gitlawb/zero" DefaultTimeout = 5 * time.Second + // EnvUpdateToken is read by fetchRelease to authenticate GitHub API + // requests. ZERO_GITHUB_TOKEN takes precedence over GITHUB_TOKEN. + EnvUpdateToken = "ZERO_GITHUB_TOKEN" ) type Release struct { @@ -251,6 +254,11 @@ func fetchRelease(ctx context.Context, endpoint string) (release Release, err er } request.Header.Set("Accept", "application/vnd.github+json") request.Header.Set("User-Agent", "zero/update") + if token := os.Getenv(EnvUpdateToken); token != "" && request.URL.Scheme == "https" && strings.EqualFold(request.URL.Hostname(), "api.github.com") { + request.Header.Set("Authorization", "Bearer "+token) + } else if token := os.Getenv("GITHUB_TOKEN"); token != "" && request.URL.Scheme == "https" && strings.EqualFold(request.URL.Hostname(), "api.github.com") { + request.Header.Set("Authorization", "Bearer "+token) + } response, err := http.DefaultClient.Do(request) if err != nil { return Release{}, err