Add Gitea read operations to the forge boundary

This commit is contained in:
2026-11-01 17:57:57 +00:00
parent 61a07bc950
commit 201af83d95
4 changed files with 332 additions and 30 deletions

View File

@@ -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`,

View File

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

164
internal/gitea/backend.go Normal file
View File

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

View File

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