package delivery import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "os" "sort" "go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/forge" ) type AssetState string const ( AssetMatching AssetState = "matching" AssetMissing AssetState = "missing" AssetConflicting AssetState = "conflicting" AssetUnverifiable AssetState = "unverifiable" ) type AssetIdentity struct { Filename string SHA256 string } type AssetInspection struct { Expected artifact.ExpectedAsset State AssetState Existing []forge.Asset Err error } type Inspection struct { Assets []AssetInspection Unrelated []forge.Asset } type UploadResult struct { State State Matching []AssetIdentity Uploaded []AssetIdentity Pending []AssetIdentity Conflicting []AssetIdentity Uncertain []AssetIdentity } func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHandle any, expected []artifact.ExpectedAsset) (Inspection, error) { if err := artifact.ValidateExpectedAssets(expected); err != nil { return Inspection{}, &forge.Failure{Kind: forge.FailureInvalid, Err: err} } listed, err := backend.ListAssets(ctx, releaseHandle) if err != nil { inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))} for _, item := range expected { inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: AssetUnverifiable, Err: err}) } return inspection, err } byName := make(map[string][]forge.Asset, len(listed)) for _, item := range listed { byName[item.Filename] = append(byName[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 := byName[item.Filename] observation := AssetInspection{Expected: item, Existing: matches} switch len(matches) { case 0: observation.State = AssetMissing case 1: digest, err := remoteAssetDigest(ctx, backend, matches[0].Handle) if err != nil { observation.State = AssetUnverifiable observation.Err = err } else if digest == item.SHA256 { observation.State = AssetMatching } else { observation.State = AssetConflicting } default: observation.State = AssetConflicting } inspection.Assets = append(inspection.Assets, observation) } for _, item := range listed { if _, ok := declared[item.Filename]; !ok { inspection.Unrelated = append(inspection.Unrelated, item) } } return inspection, nil } func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any) (string, error) { stream, err := backend.OpenAsset(ctx, handle) if err != nil { return "", err } hasher := sha256.New() _, readErr := io.Copy(hasher, stream) closeErr := stream.Close() if err := errors.Join(readErr, closeErr); err != nil { return "", &forge.Failure{Kind: forge.FailureUnverifiable, Err: err} } return hex.EncodeToString(hasher.Sum(nil)), nil } func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (UploadResult, error) { expected := orderedExpected(inspection.Assets) if err := artifact.ValidateExpectedAssets(expected); err != nil { return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err} } result := UploadResult{State: Partial} preflightConflict := false preflightUnverifiable := false missing := make(map[string]artifact.ExpectedAsset, len(expected)) for _, observation := range inspection.Assets { item := identity(observation.Expected) switch observation.State { case AssetMatching: result.Matching = append(result.Matching, item) case AssetMissing: result.Pending = append(result.Pending, item) missing[observation.Expected.Filename] = observation.Expected case AssetConflicting: preflightConflict = true result.Conflicting = append(result.Conflicting, item) case AssetUnverifiable: preflightUnverifiable = true result.Uncertain = append(result.Uncertain, item) default: return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename)} } } if preflightConflict { result.State = Conflict return result, &forge.Failure{Kind: forge.FailureConflict, Err: errors.New("one or more existing attachments conflict")} } if preflightUnverifiable { result.State = NotAttempted return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.New("one or more existing attachments could not be verified")} } if len(missing) == 0 { result.State = Complete return result, nil } for _, item := range orderMissing(expected) { if _, needsUpload := missing[item.Filename]; !needsUpload { continue } content, err := assetContent(item) if err != nil { return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("open local release asset %q: %w", item.Filename, err)} } evidence, uploadErr := backend.UploadAsset(ctx, releaseHandle, item.Filename, content) closeErr := content.Close() if uploadErr == nil { result.Uploaded = append(result.Uploaded, identity(item)) result.Pending = removeIdentity(result.Pending, item.Filename) continue } operationErr := errors.Join(uploadErr, closeErr) if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain { return result, operationErr } if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil { return result, err } } if len(result.Pending) == 0 { result.State = Complete } return result, nil } func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHandle any, item artifact.ExpectedAsset, evidence forge.WriteEvidence, uploadErr error, result *UploadResult) error { inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item}) if inspectErr != nil || len(inspection.Assets) != 1 { result.State = Unknown result.Pending = removeIdentity(result.Pending, item.Filename) result.Uncertain = append(result.Uncertain, identity(item)) return &forge.Failure{Kind: forge.FailureUnknown, Err: 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) return nil case AssetConflicting: result.State = Conflict result.Pending = removeIdentity(result.Pending, item.Filename) result.Conflicting = append(result.Conflicting, identity(item)) return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr} case AssetMissing: result.Pending = removeIdentity(result.Pending, item.Filename) if evidence.Disposition == forge.WriteConflict { result.State = Conflict result.Conflicting = append(result.Conflicting, identity(item)) return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr} } result.State = Unknown result.Uncertain = append(result.Uncertain, identity(item)) return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr} default: result.State = Unknown result.Pending = removeIdentity(result.Pending, item.Filename) result.Uncertain = append(result.Uncertain, identity(item)) return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)} } } func orderMissing(expected []artifact.ExpectedAsset) []artifact.ExpectedAsset { result := append([]artifact.ExpectedAsset(nil), expected...) sort.Slice(result, func(i, j int) bool { if result[i].Filename == artifact.ManifestFilename { return false } if result[j].Filename == artifact.ManifestFilename { return true } return result[i].Filename < result[j].Filename }) return result } func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) { if len(asset.Content) > 0 { return io.NopCloser(bytes.NewReader(asset.Content)), nil } return os.Open(asset.Path) } func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset { result := make([]artifact.ExpectedAsset, 0, len(observations)) for _, observation := range observations { result = append(result, observation.Expected) } return orderMissing(result) } func identity(asset artifact.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 }