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 }