344 lines
13 KiB
Go
344 lines
13 KiB
Go
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type DeliveryState string
|
|
|
|
const (
|
|
DeliveryNotAttempted DeliveryState = "not_attempted"
|
|
DeliveryComplete DeliveryState = "complete"
|
|
DeliveryPartial DeliveryState = "partial"
|
|
DeliveryConflict DeliveryState = "conflict"
|
|
DeliveryUnknown DeliveryState = "unknown"
|
|
)
|
|
|
|
type AssetIdentity struct {
|
|
Filename string
|
|
SHA256 string
|
|
}
|
|
|
|
// UploadResult reports the current asset observations from one upload attempt.
|
|
// Each identity appears in at most one category; early failures can leave some
|
|
// declared assets unreported.
|
|
type UploadResult struct {
|
|
State DeliveryState
|
|
// Matching contains existing attachments with the declared content.
|
|
Matching []AssetIdentity
|
|
// Uploaded contains assets whose upload was confirmed during this invocation.
|
|
Uploaded []AssetIdentity
|
|
// Pending contains assets known to be missing.
|
|
Pending []AssetIdentity
|
|
// Conflicting contains assets with detected content conflicts.
|
|
Conflicting []AssetIdentity
|
|
// Uncertain contains assets whose remote state could not be resolved.
|
|
Uncertain []AssetIdentity
|
|
}
|
|
|
|
// VerifyLocalAssets checks that every expected file still matches its declared
|
|
// size and digest before delivery begins.
|
|
func VerifyLocalAssets(assets []ExpectedAsset) error {
|
|
if err := validateExpectedAssets(assets); err != nil {
|
|
return err
|
|
}
|
|
for _, asset := range assets {
|
|
if err := verifyLocalAsset(asset); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
|
// before the first write if any declared existing asset conflicts or cannot be
|
|
// verified, and never replaces an existing attachment.
|
|
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
|
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
|
}
|
|
|
|
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
|
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
|
// inspection, conflict handling, and upload reconciliation.
|
|
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
|
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
|
}
|
|
|
|
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
|
if releaseID <= 0 {
|
|
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
|
}
|
|
if strings.TrimSpace(destination.Token) == "" {
|
|
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
|
}
|
|
base, err := parseBaseURL(destination.BaseURL)
|
|
if err != nil {
|
|
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
|
}
|
|
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
|
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
|
}
|
|
expected := orderedExpected(inspection.Assets)
|
|
if err := validateExpectedAssets(expected); err != nil {
|
|
return UploadResult{State: DeliveryPartial}, err
|
|
}
|
|
result := UploadResult{State: DeliveryPartial}
|
|
preflightConflict := false
|
|
preflightUnknown := false
|
|
for _, observation := range inspection.Assets {
|
|
switch observation.State {
|
|
case AssetMatching:
|
|
result.Matching = append(result.Matching, identity(observation.Expected))
|
|
case AssetMissing:
|
|
result.Pending = append(result.Pending, identity(observation.Expected))
|
|
case AssetConflicting:
|
|
preflightConflict = true
|
|
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
|
case AssetUnverifiable:
|
|
preflightUnknown = true
|
|
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
|
default:
|
|
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
|
}
|
|
}
|
|
if preflightConflict {
|
|
result.State = DeliveryConflict
|
|
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
|
}
|
|
if preflightUnknown {
|
|
result.State = DeliveryNotAttempted
|
|
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
|
}
|
|
if len(expected) == 0 {
|
|
result.State = DeliveryComplete
|
|
return result, nil
|
|
}
|
|
if len(result.Pending) == 0 {
|
|
result.State = DeliveryComplete
|
|
return result, nil
|
|
}
|
|
|
|
missing := make(map[string]ExpectedAsset, len(result.Pending))
|
|
for _, observation := range inspection.Assets {
|
|
if observation.State == AssetMissing {
|
|
missing[observation.Expected.Filename] = observation.Expected
|
|
}
|
|
}
|
|
if verifyLocal {
|
|
for _, item := range expected {
|
|
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
|
continue
|
|
}
|
|
if err := verifyLocalAsset(item); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
}
|
|
|
|
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
|
client := c.httpClient()
|
|
for _, item := range expected {
|
|
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
|
continue
|
|
}
|
|
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
|
if uploadErr == nil {
|
|
result.Uploaded = append(result.Uploaded, identity(item))
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
continue
|
|
}
|
|
if !shouldReconcileWrite(status, uploadErr) {
|
|
return result, uploadErr
|
|
}
|
|
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item})
|
|
if inspectErr != nil || len(inspection.Assets) != 1 {
|
|
result.State = DeliveryUnknown
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
|
}
|
|
observation := inspection.Assets[0]
|
|
switch observation.State {
|
|
case AssetMatching:
|
|
result.Matching = append(result.Matching, identity(item))
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
case AssetConflicting:
|
|
result.State = DeliveryConflict
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
result.Conflicting = append(result.Conflicting, identity(item))
|
|
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
|
case AssetMissing:
|
|
if status == http.StatusConflict {
|
|
result.State = DeliveryConflict
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
result.Conflicting = append(result.Conflicting, identity(item))
|
|
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
|
}
|
|
result.State = DeliveryUnknown
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
|
default:
|
|
result.State = DeliveryUnknown
|
|
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
|
}
|
|
}
|
|
if len(result.Pending) == 0 {
|
|
result.State = DeliveryComplete
|
|
} else {
|
|
result.State = DeliveryPartial
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset ExpectedAsset) (int, error) {
|
|
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
|
parsed, err := url.Parse(uploadURL)
|
|
if err != nil {
|
|
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
|
}
|
|
query := parsed.Query()
|
|
query.Set("name", asset.Filename)
|
|
parsed.RawQuery = query.Encode()
|
|
reader, writer := io.Pipe()
|
|
multipartWriter := multipart.NewWriter(writer)
|
|
writeDone := make(chan error, 1)
|
|
go func() {
|
|
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
|
if partErr == nil {
|
|
var source io.Reader
|
|
if len(asset.Content) > 0 {
|
|
source = bytes.NewReader(asset.Content)
|
|
} else {
|
|
file, openErr := os.Open(asset.Path)
|
|
if openErr != nil {
|
|
partErr = openErr
|
|
} else {
|
|
defer file.Close()
|
|
source = file
|
|
}
|
|
}
|
|
if partErr == nil {
|
|
_, partErr = io.Copy(part, source)
|
|
}
|
|
}
|
|
closeErr := multipartWriter.Close()
|
|
if partErr == nil {
|
|
partErr = closeErr
|
|
}
|
|
_ = writer.CloseWithError(partErr)
|
|
writeDone <- partErr
|
|
}()
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), reader)
|
|
if err != nil {
|
|
_ = reader.CloseWithError(err)
|
|
<-writeDone
|
|
return 0, failure(FailureInvalid, "upload release asset", 0, err)
|
|
}
|
|
request.Header.Set("Authorization", "token "+token)
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
|
response, requestErr := client.Do(request)
|
|
if requestErr != nil {
|
|
_ = reader.CloseWithError(requestErr)
|
|
<-writeDone
|
|
return 0, failure(FailureTransport, "upload release asset", 0, requestErr)
|
|
}
|
|
defer response.Body.Close()
|
|
writeErr := <-writeDone
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return response.StatusCode, responseFailure("upload release asset", response.StatusCode)
|
|
}
|
|
var uploaded assetResponse
|
|
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
|
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
|
}
|
|
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
|
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
|
}
|
|
if writeErr != nil {
|
|
return response.StatusCode, failure(FailureMalformed, "stream uploaded asset", response.StatusCode, writeErr)
|
|
}
|
|
return response.StatusCode, nil
|
|
}
|
|
|
|
func verifyLocalAsset(asset ExpectedAsset) error {
|
|
if len(asset.Content) > 0 {
|
|
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
|
}
|
|
return nil
|
|
}
|
|
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename))
|
|
}
|
|
file, err := os.Open(asset.Path)
|
|
if err != nil {
|
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err))
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err))
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename))
|
|
}
|
|
hasher := sha256.New()
|
|
size, err := io.Copy(hasher, file)
|
|
if err != nil {
|
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err))
|
|
}
|
|
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
|
result := make([]ExpectedAsset, 0, len(observations))
|
|
for _, observation := range observations {
|
|
result = append(result, observation.Expected)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Filename == ManifestFilename {
|
|
return false
|
|
}
|
|
if result[j].Filename == ManifestFilename {
|
|
return true
|
|
}
|
|
return result[i].Filename < result[j].Filename
|
|
})
|
|
return result
|
|
}
|
|
|
|
func identity(asset ExpectedAsset) AssetIdentity {
|
|
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
|
}
|
|
|
|
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
|
for index, item := range identities {
|
|
if item.Filename == filename {
|
|
return append(identities[:index], identities[index+1:]...)
|
|
}
|
|
}
|
|
return identities
|
|
}
|