From 201af83d9579a3a70801dc06da869b65da3d9f74 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 17:57:57 +0000 Subject: [PATCH] Add Gitea read operations to the forge boundary --- docs/roadmap/implementation.md | 4 +- internal/gitea/assets.go | 78 ++++++++++------ internal/gitea/backend.go | 164 +++++++++++++++++++++++++++++++++ internal/gitea/backend_test.go | 116 +++++++++++++++++++++++ 4 files changed, 332 insertions(+), 30 deletions(-) create mode 100644 internal/gitea/backend.go create mode 100644 internal/gitea/backend_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cc4d104..39c3878 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–2 are complete and Stages 3–10 +Status: implementation in progress; Stages 1–3 are complete and Stages 4–10 are pending. ## Purpose and execution @@ -266,7 +266,7 @@ adding a guarantee against concurrent file replacement. ## Stage 3: Extract Gitea read operations -Status: pending. +Status: complete. 1. Adapt the Gitea client into a backend configured with its private destination and optional HTTP client. Implement `TagCommit`, `FindRelease`, `ListAssets`, diff --git a/internal/gitea/assets.go b/internal/gitea/assets.go index 30f7a85..7165961 100644 --- a/internal/gitea/assets.go +++ b/internal/gitea/assets.go @@ -69,22 +69,13 @@ func (c Client) InspectAssets(ctx context.Context, destination Destination, rele return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, err) } apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository) - assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" - var listed []assetResponse - if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil { + listed, err := c.listAssets(ctx, c.httpClient(), destination.Token, releaseID, apiRoot) + if err != nil { return Inspection{}, err } - if listed == nil { - return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array")) - } - remoteByName := make(map[string][]RemoteAsset, len(listed)) for _, item := range listed { - if item.ID <= 0 || item.Name == "" || item.Size < 0 { - return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size")) - } - remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL} - remoteByName[item.Name] = append(remoteByName[item.Name], remote) + remoteByName[item.Filename] = append(remoteByName[item.Filename], item) } inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))} declared := make(map[string]struct{}, len(expected)) @@ -111,32 +102,68 @@ func (c Client) InspectAssets(ctx context.Context, destination Destination, rele inspection.Assets = append(inspection.Assets, observation) } for _, item := range listed { - if _, expected := declared[item.Name]; !expected { - inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}) + if _, expected := declared[item.Filename]; !expected { + inspection.Unrelated = append(inspection.Unrelated, item) } } return inspection, nil } +func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) { + if releaseID <= 0 { + return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive")) + } + assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" + var listed []assetResponse + if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil { + return nil, err + } + if listed == nil { + return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array")) + } + assets := make([]RemoteAsset, 0, len(listed)) + for _, item := range listed { + if item.ID <= 0 || item.Name == "" || item.Size < 0 { + return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size")) + } + assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}) + } + return assets, nil +} + func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) { + stream, err := c.openAsset(ctx, client, base, token, rawURL) + if err != nil { + return "", 0, err + } + defer stream.Close() + hasher := sha256.New() + size, err := io.Copy(hasher, stream) + if err != nil { + return "", size, failure(FailureUnverifiable, "read release asset", 0, err) + } + return hex.EncodeToString(hasher.Sum(nil)), size, nil +} + +func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) { if strings.TrimSpace(rawURL) == "" { - return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL")) + return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL")) } assetURL, err := base.Parse(rawURL) if err != nil || assetURL.User != nil { - return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL")) + return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL")) } assetURL.Fragment = "" visited := make(map[string]struct{}) for { key := assetURL.String() if _, exists := visited[key]; exists { - return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop")) + return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop")) } visited[key] = struct{}{} request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil) if err != nil { - return "", 0, failure(FailureUnverifiable, "download release asset", 0, err) + return nil, failure(FailureUnverifiable, "download release asset", 0, err) } if sameOrigin(base, assetURL) { request.Header.Set("Authorization", "token "+token) @@ -144,28 +171,23 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url request.Header.Set("Accept", "application/octet-stream") response, err := client.Do(request) if err != nil { - return "", 0, failure(FailureUnverifiable, "download release asset", 0, err) + return nil, failure(FailureUnverifiable, "download release asset", 0, err) } if isRedirect(response.StatusCode) { location, locationErr := response.Location() _ = response.Body.Close() if locationErr != nil || location.User != nil { - return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect")) + return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect")) } location.Fragment = "" assetURL = location continue } - defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { - return "", 0, responseFailure("download release asset", response.StatusCode) + _ = response.Body.Close() + return nil, responseFailure("download release asset", response.StatusCode) } - hasher := sha256.New() - size, err := io.Copy(hasher, response.Body) - if err != nil { - return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err) - } - return hex.EncodeToString(hasher.Sum(nil)), size, nil + return response.Body, nil } } diff --git a/internal/gitea/backend.go b/internal/gitea/backend.go new file mode 100644 index 0000000..2c1929b --- /dev/null +++ b/internal/gitea/backend.go @@ -0,0 +1,164 @@ +package gitea + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "go.maximumdirect.net/releasekit/internal/forge" +) + +// Backend adapts a configured Gitea destination to the hosted delivery +// operations. Its destination and client are private so callers cannot bypass +// Gitea's validation or replace opaque handles. +type Backend struct { + destination Destination + client Client +} + +// NewBackend creates a Gitea backend with an optional HTTP client. +func NewBackend(destination Destination, client *http.Client) *Backend { + return &Backend{destination: destination, client: Client{HTTPClient: client}} +} + +type releaseHandle struct { + id int64 +} + +type assetHandle struct { + id int64 + downloadURL string +} + +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")} + } + _, apiRoot, err := b.destinationAPI("get hosted tag") + if err != nil { + return "", err + } + var observed tagResponse + endpoint := apiRoot + "/tags/" + url.PathEscape(tag) + if err := b.client.getJSON(ctx, b.client.httpClient(), b.destination.Token, "get hosted tag", endpoint, &observed); err != nil { + return "", neutralFailure(err) + } + if observed.Commit.SHA == "" { + return "", &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("tag response does not identify a commit")} + } + return observed.Commit.SHA, nil +} + +func (b *Backend) FindRelease(ctx context.Context, tag string) (forge.Release, error) { + if strings.TrimSpace(tag) == "" { + return forge.Release{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")} + } + _, apiRoot, err := b.destinationAPI("find release") + if err != nil { + return forge.Release{}, err + } + endpoint := apiRoot + "/releases/tags/" + url.PathEscape(tag) + release, _, err := b.client.getRelease(ctx, b.client.httpClient(), b.destination.Token, endpoint) + if err != nil { + return forge.Release{}, neutralFailure(err) + } + return forge.Release{ + URL: release.URL, + Tag: release.TagName, + Title: release.Title, + Body: release.Body, + Draft: release.Draft, + Prerelease: release.Prerelease, + Handle: releaseHandle{id: release.ID}, + }, nil +} + +func (b *Backend) ListAssets(ctx context.Context, handle any) ([]forge.Asset, error) { + release, ok := handle.(releaseHandle) + if !ok { + return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")} + } + _, apiRoot, err := b.destinationAPI("list release assets") + if err != nil { + return nil, err + } + remote, err := b.client.listAssets(ctx, b.client.httpClient(), b.destination.Token, release.id, apiRoot) + if err != nil { + return nil, neutralFailure(err) + } + assets := make([]forge.Asset, 0, len(remote)) + for _, item := range remote { + assets = append(assets, forge.Asset{ + Filename: item.Filename, + Size: item.Size, + Handle: assetHandle{id: item.ID, downloadURL: item.DownloadURL}, + }) + } + return assets, nil +} + +func (b *Backend) OpenAsset(ctx context.Context, handle any) (io.ReadCloser, error) { + asset, ok := handle.(assetHandle) + if !ok { + return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset handle was not created by the Gitea backend")} + } + base, _, err := b.destinationAPI("download release asset") + if err != nil { + return nil, err + } + stream, err := b.client.openAsset(ctx, b.client.httpClient(), base, b.destination.Token, asset.downloadURL) + if err != nil { + return nil, neutralFailure(err) + } + return stream, nil +} + +func (b *Backend) destinationAPI(operation string) (*url.URL, string, error) { + base, err := parseBaseURL(b.destination.BaseURL) + if err != nil { + return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, err)) + } + if strings.TrimSpace(b.destination.Token) == "" { + return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea token is required"))) + } + if strings.TrimSpace(b.destination.Owner) == "" || strings.ContainsAny(b.destination.Owner, "/\\") { + return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea owner must be one path segment"))) + } + if strings.TrimSpace(b.destination.Repository) == "" || strings.ContainsAny(b.destination.Repository, "/\\") { + return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea repository must be one path segment"))) + } + apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(b.destination.Owner) + "/" + url.PathEscape(b.destination.Repository) + return base, apiRoot, nil +} + +func neutralFailure(err error) error { + var giteaFailure *Failure + if !errors.As(err, &giteaFailure) { + return err + } + kind := forge.FailureUnknown + switch giteaFailure.Kind { + case FailureInvalid: + kind = forge.FailureInvalid + case FailureNotFound: + kind = forge.FailureNotFound + case FailureUnauthorized: + kind = forge.FailureUnauthorized + case FailureConflict: + kind = forge.FailureConflict + case FailureMalformed: + kind = forge.FailureMalformed + case FailureUnverifiable: + kind = forge.FailureUnverifiable + case FailureTransport: + kind = forge.FailureTransport + case FailureRejected: + kind = forge.FailureRejected + case FailureUnknown: + kind = forge.FailureUnknown + } + return &forge.Failure{Kind: kind, Err: err} +} diff --git a/internal/gitea/backend_test.go b/internal/gitea/backend_test.go new file mode 100644 index 0000000..0bd557d --- /dev/null +++ b/internal/gitea/backend_test.go @@ -0,0 +1,116 @@ +package gitea + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.maximumdirect.net/releasekit/internal/forge" +) + +func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want { + t.Errorf("request URI = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + _ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}}) + })) + defer server.Close() + + backend := NewBackend(testDestination(server.URL), server.Client()) + commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1") + if err != nil || commit != "abcdef" { + t.Fatalf("TagCommit() = %q, %v", commit, err) + } +} + +func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + _, err := backend.FindRelease(context.Background(), "v1.2.3") + var failure *forge.Failure + if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound { + t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err) + } + var cause *Failure + if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound { + t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err) + } +} + +func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + release, err := backend.FindRelease(context.Background(), "v1.2.3") + if err != nil { + t.Fatal(err) + } + if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil { + t.Fatalf("FindRelease() = %#v", release) + } +} + +func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + switch r.URL.Path { + case "/api/v1/repos/owner/project/releases/tags/v1.2.3": + _ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"}) + case "/api/v1/repos/owner/project/releases/12/assets": + _ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}}) + case "/files/app": + _, _ = io.WriteString(w, "data") + default: + t.Errorf("unexpected path %q", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + + release, err := backend.FindRelease(context.Background(), "v1.2.3") + if err != nil { + t.Fatal(err) + } + assets, err := backend.ListAssets(context.Background(), release.Handle) + if err != nil { + t.Fatal(err) + } + if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil { + t.Fatalf("ListAssets() = %#v", assets) + } + stream, err := backend.OpenAsset(context.Background(), assets[0].Handle) + if err != nil { + t.Fatal(err) + } + content, readErr := io.ReadAll(stream) + closeErr := stream.Close() + if readErr != nil || closeErr != nil || string(content) != "data" { + t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr) + } +} + +func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) { + backend := NewBackend(testDestination("http://example.test"), nil) + _, err := backend.OpenAsset(context.Background(), strings.NewReader("asset")) + var failure *forge.Failure + if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid { + t.Fatalf("OpenAsset() error = %v, want invalid handle", err) + } +}