From 02bd120aed140fdba4134fe80602a0bcdf6c8e68 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 17:59:43 +0000 Subject: [PATCH] Preflight Gitea credentials with repository reads --- docs/roadmap/implementation.md | 4 +- internal/gitea/backend.go | 75 +++++++++++++++++++ internal/gitea/backend_test.go | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 39c3878..77f337d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation plan: neutral forge delivery and credential preflight -Status: implementation in progress; Stages 1–3 are complete and Stages 4–10 +Status: implementation in progress; Stages 1–4 are complete and Stages 5–10 are pending. ## Purpose and execution @@ -290,7 +290,7 @@ comparison, page acceptance, and delivery outcomes belong to shared policy. ## Stage 4: Implement read-only Gitea credential preflight -Status: pending. +Status: complete. 1. Implement backend `Preflight` with local destination/token validation and authenticated repository-scoped reads. Perform no source/tag checks here so diff --git a/internal/gitea/backend.go b/internal/gitea/backend.go index 2c1929b..502137a 100644 --- a/internal/gitea/backend.go +++ b/internal/gitea/backend.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "go.maximumdirect.net/releasekit/internal/forge" @@ -33,6 +34,80 @@ type assetHandle struct { downloadURL string } +type repositoryResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +func (b *Backend) Preflight(ctx context.Context) error { + if strings.TrimSpace(b.destination.Token) == "" { + return &forge.PreflightFailure{Kind: forge.PreflightMissingCredential, Err: errors.New("Gitea token is required")} + } + base, apiRoot, err := b.destinationAPI("preflight repository access") + if err != nil { + return err + } + client := b.client.httpClient() + var resolved repositoryResponse + endpoint := apiRoot + if err := b.client.getJSON(ctx, client, b.destination.Token, "resolve repository", endpoint, &resolved); err != nil { + return preflightFailure(err) + } + if !resolved.matches(b.destination) { + return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("repository response does not identify the configured destination")} + } + + var authenticated repositoryResponse + apiPrefix := strings.TrimRight(base.String(), "/") + "/api/v1" + endpoint = apiPrefix + "/repositories/" + strconv.FormatInt(resolved.ID, 10) + if err := b.client.getJSON(ctx, client, b.destination.Token, "verify authenticated repository access", endpoint, &authenticated); err != nil { + return preflightFailure(err) + } + if !authenticated.matches(b.destination) || authenticated.ID != resolved.ID { + return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("authenticated repository response does not match the resolved destination")} + } + // Gitea's permissions.push describes Git code pushes, not release or asset + // writes. That metadata cannot establish a delivery-permission deficiency. + return nil +} + +func (repository repositoryResponse) matches(destination Destination) bool { + if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) { + return false + } + if !strings.EqualFold(repository.FullName, destination.Owner+"/"+destination.Repository) { + return false + } + return strings.EqualFold(repository.Owner.Login, destination.Owner) +} + +func preflightFailure(err error) error { + var backendFailure *Failure + if !errors.As(err, &backendFailure) { + return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: err} + } + kind := forge.PreflightUnavailable + switch backendFailure.Kind { + case FailureUnauthorized: + if backendFailure.StatusCode == http.StatusUnauthorized { + kind = forge.PreflightInvalidCredential + } else { + kind = forge.PreflightInaccessibleDestination + } + case FailureNotFound: + kind = forge.PreflightInaccessibleDestination + case FailureRejected: + if backendFailure.StatusCode >= http.StatusInternalServerError { + kind = forge.PreflightUnavailable + } + } + return &forge.PreflightFailure{Kind: kind, Err: neutralFailure(err)} +} + func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) { if strings.TrimSpace(tag) == "" { return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")} diff --git a/internal/gitea/backend_test.go b/internal/gitea/backend_test.go index 0bd557d..1c466eb 100644 --- a/internal/gitea/backend_test.go +++ b/internal/gitea/backend_test.go @@ -114,3 +114,134 @@ func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) { t.Fatalf("OpenAsset() error = %v, want invalid handle", err) } } + +func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) + } + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + requests = append(requests, r.URL.Path) + writeRepository := func() { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 31, "name": "project", "full_name": "owner/project", + "owner": map[string]string{"login": "owner"}, + "permissions": map[string]bool{"admin": false, "push": false, "pull": true}, + }) + } + switch r.URL.Path { + case "/api/v1/repos/owner/project", "/api/v1/repositories/31": + writeRepository() + default: + t.Errorf("unexpected path %q", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil { + t.Fatal(err) + } + if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" { + t.Fatalf("preflight requests = %v, want both repository reads", requests) + } +} + +func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + defer server.Close() + destination := testDestination(server.URL) + destination.Token = " \t" + + err := NewBackend(destination, server.Client()).Preflight(context.Background()) + var failure *forge.PreflightFailure + if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential { + t.Fatalf("Preflight() error = %v, want missing credential", err) + } + if requests != 0 { + t.Fatalf("made %d requests without a credential", requests) + } +} + +func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + if r.URL.Path == "/api/v1/repos/owner/project" { + _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}}) + return + } + http.Error(w, "token rejected", http.StatusUnauthorized) + })) + defer server.Close() + + err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) + var failure *forge.PreflightFailure + if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential { + t.Fatalf("Preflight() error = %v, want invalid credential", err) + } + if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" { + t.Fatalf("preflight requests = %v, want authenticated repository read", requests) + } + if strings.Contains(err.Error(), testDestination(server.URL).Token) { + t.Fatalf("preflight error exposed the credential: %v", err) + } +} + +func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) { + for _, status := range []int{http.StatusForbidden, http.StatusNotFound} { + t.Run(http.StatusText(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "repository unavailable", status) + })) + defer server.Close() + + err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) + var failure *forge.PreflightFailure + if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination { + t.Fatalf("Preflight() error = %v, want inaccessible destination", err) + } + }) + } +} + +func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) { + for _, test := range []struct { + name string + respond func(http.ResponseWriter) + }{ + {name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }}, + {name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }}, + {name: "wrong repository identity", respond: func(w http.ResponseWriter) { + _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}}) + }}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) })) + defer server.Close() + + err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) + var failure *forge.PreflightFailure + if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable { + t.Fatalf("Preflight() error = %v, want unavailable", err) + } + }) + } +} + +func TestBackendPreflightPreservesCancellation(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx) + var failure *forge.PreflightFailure + if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) { + t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err) + } +}