Add Gitea read operations to the forge boundary
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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
164
internal/gitea/backend.go
Normal 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}
|
||||
}
|
||||
116
internal/gitea/backend_test.go
Normal file
116
internal/gitea/backend_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user