Files
releasekit/internal/gitea/assets.go

219 lines
7.6 KiB
Go

package gitea
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"go.maximumdirect.net/releasekit/internal/artifact"
)
type AssetState string
const (
AssetMatching AssetState = "matching"
AssetMissing AssetState = "missing"
AssetConflicting AssetState = "conflicting"
AssetUnverifiable AssetState = "unverifiable"
)
type RemoteAsset struct {
ID int64
Filename string
Size int64
DownloadURL string
}
type AssetInspection struct {
Expected artifact.ExpectedAsset
State AssetState
Existing []RemoteAsset
Err error
}
type Inspection struct {
Assets []AssetInspection
Unrelated []RemoteAsset
}
type assetResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// InspectAssets lists the release's attachments and compares every declared
// asset by streamed SHA-256 content. It does not create or modify remote data.
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []artifact.ExpectedAsset) (Inspection, error) {
if releaseID <= 0 {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
}
if strings.TrimSpace(destination.Token) == "" {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
}
base, err := parseBaseURL(destination.BaseURL)
if err != nil {
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
}
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
}
if err := artifact.ValidateExpectedAssets(expected); err != nil {
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)
listed, err := c.listAssets(ctx, c.httpClient(), destination.Token, releaseID, apiRoot)
if err != nil {
return Inspection{}, err
}
remoteByName := make(map[string][]RemoteAsset, len(listed))
for _, item := range listed {
remoteByName[item.Filename] = append(remoteByName[item.Filename], item)
}
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
declared := make(map[string]struct{}, len(expected))
for _, item := range expected {
declared[item.Filename] = struct{}{}
matches := remoteByName[item.Filename]
observation := AssetInspection{Expected: item, Existing: matches}
switch len(matches) {
case 0:
observation.State = AssetMissing
case 1:
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
if err != nil {
observation.State = AssetUnverifiable
observation.Err = err
} else if remoteHash == item.SHA256 {
observation.State = AssetMatching
} else {
observation.State = AssetConflicting
}
default:
observation.State = AssetConflicting
}
inspection.Assets = append(inspection.Assets, observation)
}
for _, item := range listed {
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 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 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 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 nil, failure(FailureUnverifiable, "download release asset", 0, err)
}
if sameOrigin(base, assetURL) {
request.Header.Set("Authorization", "token "+token)
}
request.Header.Set("Accept", "application/octet-stream")
response, err := client.Do(request)
if err != nil {
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 nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
}
location.Fragment = ""
assetURL = location
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
_ = response.Body.Close()
return nil, responseFailure("download release asset", response.StatusCode)
}
return response.Body, nil
}
}
func sameOrigin(base, target *url.URL) bool {
if target == nil || target.User != nil || target.Host == "" {
return false
}
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Hostname(), target.Hostname()) && effectivePort(base) == effectivePort(target)
}
func effectivePort(parsed *url.URL) string {
if port := parsed.Port(); port != "" {
return port
}
if strings.EqualFold(parsed.Scheme, "https") {
return "443"
}
return "80"
}
func isRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return true
default:
return false
}
}