Move asset verification into the artifact package

This commit is contained in:
2026-11-01 17:55:53 +00:00
parent aa03a78476
commit 61a07bc950
11 changed files with 321 additions and 216 deletions

View File

@@ -5,20 +5,15 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
"go.maximumdirect.net/releasekit/internal/artifact"
)
const ManifestFilename = artifact.ManifestFilename
type AssetState string
const (
@@ -28,17 +23,6 @@ const (
AssetUnverifiable AssetState = "unverifiable"
)
// ExpectedAsset describes one declared file. Content is populated for the
// generated checksum manifest; Path identifies local binary content for later
// upload operations.
type ExpectedAsset struct {
Filename string
SHA256 string
Size int64
Path string
Content []byte
}
type RemoteAsset struct {
ID int64
Filename string
@@ -47,7 +31,7 @@ type RemoteAsset struct {
}
type AssetInspection struct {
Expected ExpectedAsset
Expected artifact.ExpectedAsset
State AssetState
Existing []RemoteAsset
Err error
@@ -65,33 +49,9 @@ type assetResponse struct {
BrowserDownloadURL string `json:"browser_download_url"`
}
// BuildManifest creates the deterministic checksum file for the declared
// binaries. An empty binary set produces no manifest.
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
if len(binaries) == 0 {
return ExpectedAsset{}, nil
}
ordered := append([]ExpectedAsset(nil), binaries...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
var body strings.Builder
seen := make(map[string]struct{}, len(ordered))
for _, binary := range ordered {
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("invalid binary identity for %q", binary.Filename))
}
if _, exists := seen[binary.Filename]; exists {
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("duplicate binary filename %q", binary.Filename))
}
seen[binary.Filename] = struct{}{}
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
}
content := []byte(body.String())
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
}
// 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 []ExpectedAsset) (Inspection, error) {
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"))
}
@@ -105,8 +65,8 @@ func (c Client) InspectAssets(ctx context.Context, destination Destination, rele
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 := validateExpectedAssets(expected); err != nil {
return Inspection{}, err
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)
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
@@ -209,40 +169,6 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
}
}
func validateExpectedAssets(expected []ExpectedAsset) error {
seen := make(map[string]struct{}, len(expected))
for _, item := range expected {
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("invalid expected asset %q", item.Filename))
}
if _, duplicate := seen[item.Filename]; duplicate {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("duplicate expected asset %q", item.Filename))
}
seen[item.Filename] = struct{}{}
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("expected content does not match identity for %q", item.Filename))
}
}
return nil
}
func validAssetName(name string) bool {
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil && strings.ToLower(value) == value
}
func digest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}
func sameOrigin(base, target *url.URL) bool {
if target == nil || target.User != nil || target.Host == "" {
return false