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

@@ -1,7 +1,7 @@
# Implementation plan: neutral forge delivery and credential preflight # Implementation plan: neutral forge delivery and credential preflight
Status: implementation in progress; Stage 1 is complete and Stages 2–10 are Status: implementation in progress; Stages 1–2 are complete and Stages 3–10
pending. are pending.
## Purpose and execution ## Purpose and execution
@@ -242,7 +242,7 @@ error behavior where useful; recovery is tested through the shared workflow.
## Stage 2: Move local asset and manifest mechanics ## Stage 2: Move local asset and manifest mechanics
Status: pending. Status: complete.
1. Move `ExpectedAsset` and the generic work in `BuildManifest`, 1. Move `ExpectedAsset` and the generic work in `BuildManifest`,
`validateExpectedAssets`, and local verification from `internal/gitea` to `validateExpectedAssets`, and local verification from `internal/gitea` to

141
internal/artifact/assets.go Normal file
View File

@@ -0,0 +1,141 @@
package artifact
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
var (
// ErrInvalidAsset identifies an invalid asset declaration or unreadable
// local path.
ErrInvalidAsset = errors.New("invalid asset")
// ErrAssetConflict identifies local content that does not match its
// declared identity.
ErrAssetConflict = errors.New("asset content conflict")
)
// ExpectedAsset describes the local content declared for hosted delivery.
// Content is populated for inline assets such as the generated checksum
// manifest; Path identifies file content for later streaming uploads.
type ExpectedAsset struct {
Filename string
SHA256 string
Size int64
Path string
Content []byte
}
// 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{}, fmt.Errorf("%w: invalid binary identity for %q", ErrInvalidAsset, binary.Filename)
}
if _, exists := seen[binary.Filename]; exists {
return ExpectedAsset{}, fmt.Errorf("%w: duplicate binary filename %q", ErrInvalidAsset, 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
}
// ValidateExpectedAssets checks the declarations before they are compared
// with remote content or uploaded.
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 fmt.Errorf("%w: %q", ErrInvalidAsset, item.Filename)
}
if _, duplicate := seen[item.Filename]; duplicate {
return fmt.Errorf("%w: duplicate expected asset %q", ErrInvalidAsset, item.Filename)
}
seen[item.Filename] = struct{}{}
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
return fmt.Errorf("%w: expected content does not match identity for %q", ErrInvalidAsset, item.Filename)
}
}
return nil
}
// VerifyLocalAssets checks that every expected file still matches its declared
// size and digest before hosted writes begin.
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
}
func verifyLocalAsset(asset ExpectedAsset) error {
if len(asset.Content) > 0 {
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
}
return nil
}
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
return fmt.Errorf("%w: local path for %q does not match its filename", ErrInvalidAsset, asset.Filename)
}
file, err := os.Open(asset.Path)
if err != nil {
return fmt.Errorf("%w: open %q: %w", ErrInvalidAsset, asset.Filename, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return fmt.Errorf("%w: stat %q: %w", ErrInvalidAsset, asset.Filename, err)
}
if !info.Mode().IsRegular() || info.Size() != asset.Size {
return fmt.Errorf("%w: local file does not match declared size for %q", ErrAssetConflict, asset.Filename)
}
hasher := sha256.New()
size, err := io.Copy(hasher, file)
if err != nil {
return fmt.Errorf("%w: read %q: %w", ErrInvalidAsset, asset.Filename, err)
}
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.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[:])
}

View File

@@ -0,0 +1,93 @@
package artifact
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
manifest, err := BuildManifest([]ExpectedAsset{
assetFor("zeta.bin", []byte("zeta")),
assetFor("alpha.bin", []byte("alpha")),
})
if err != nil {
t.Fatal(err)
}
want := assetDigest([]byte("alpha")) + " alpha.bin\n" + assetDigest([]byte("zeta")) + " zeta.bin\n"
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != assetDigest([]byte(want)) || manifest.Size != int64(len(want)) {
t.Fatalf("manifest = %#v, want content %q", manifest, want)
}
empty, err := BuildManifest(nil)
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
}
}
func TestBuildManifestRejectsInvalidOrDuplicateDeclarations(t *testing.T) {
for _, test := range []struct {
name string
assets []ExpectedAsset
}{
{name: "invalid filename", assets: []ExpectedAsset{assetFor("../app.bin", []byte("app"))}},
{name: "manifest filename", assets: []ExpectedAsset{assetFor(ManifestFilename, []byte("app"))}},
{name: "invalid digest", assets: []ExpectedAsset{{Filename: "app.bin", SHA256: "not-a-digest"}}},
{name: "duplicate filename", assets: []ExpectedAsset{assetFor("app.bin", []byte("one")), assetFor("app.bin", []byte("two"))}},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := BuildManifest(test.assets); err == nil {
t.Fatal("BuildManifest() succeeded for invalid declarations")
}
})
}
}
func TestVerifyLocalAssetsRejectsSameSizeTampering(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.bin")
original := []byte("alpha")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
asset := assetFor(filepath.Base(path), original)
asset.Content = nil
asset.Path = path
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err != nil {
t.Fatal(err)
}
tampered := []byte("bravo")
if err := os.WriteFile(path, tampered, 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match declared asset") {
t.Fatalf("VerifyLocalAssets() error = %v, want content mismatch", err)
}
}
func TestValidateExpectedAssetsRejectsDuplicateAndMismatchedInlineContent(t *testing.T) {
asset := assetFor("app.bin", []byte("alpha"))
if err := ValidateExpectedAssets([]ExpectedAsset{asset, asset}); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("ValidateExpectedAssets() error = %v, want duplicate", err)
}
asset.Content = []byte("bravo")
if err := ValidateExpectedAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match identity") {
t.Fatalf("ValidateExpectedAssets() error = %v, want content mismatch", err)
}
}
func assetFor(filename string, content []byte) ExpectedAsset {
return ExpectedAsset{
Filename: filename,
SHA256: assetDigest(content),
Size: int64(len(content)),
Content: append([]byte(nil), content...),
}
}
func assetDigest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

View File

@@ -7,10 +7,9 @@ import (
"go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "go.maximumdirect.net/releasekit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"go.maximumdirect.net/releasekit/internal/gitea"
) )
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) { func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, error) {
settings := result.Settings settings := result.Settings
if len(settings.Targets) == 0 { if len(settings.Targets) == 0 {
if len(result.Artifacts) != 0 { if len(result.Artifacts) != 0 {
@@ -33,32 +32,32 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
} }
wantByTarget[target] = declaration.Filename wantByTarget[target] = declaration.Filename
} }
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1) assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts)) seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
for _, artifact := range result.Artifacts { for _, builtArtifact := range result.Artifacts {
wantName, ok := wantByTarget[artifact.Target] wantName, ok := wantByTarget[builtArtifact.Target]
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName { if !ok || seenTargets[builtArtifact.Target] || builtArtifact.Filename != wantName {
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename) return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", builtArtifact.Filename)
} }
seenTargets[artifact.Target] = true seenTargets[builtArtifact.Target] = true
if filepath.Base(artifact.Path) != artifact.Filename { if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename) return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
} }
info, err := os.Stat(artifact.Path) info, err := os.Stat(builtArtifact.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err) return nil, fmt.Errorf("inspect build artifact %q: %w", builtArtifact.Filename, err)
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename) return nil, fmt.Errorf("build artifact %q is not a regular file", builtArtifact.Filename)
} }
assets = append(assets, gitea.ExpectedAsset{ assets = append(assets, artifact.ExpectedAsset{
Filename: artifact.Filename, Filename: builtArtifact.Filename,
SHA256: artifact.SHA256, SHA256: builtArtifact.SHA256,
Size: info.Size(), Size: info.Size(),
Path: artifact.Path, Path: builtArtifact.Path,
}) })
} }
manifest, err := gitea.BuildManifest(assets) manifest, err := artifact.BuildManifest(assets)
if err != nil { if err != nil {
return nil, fmt.Errorf("build checksum manifest: %w", err) return nil, fmt.Errorf("build checksum manifest: %w", err)
} }

View File

@@ -10,7 +10,6 @@ import (
"go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "go.maximumdirect.net/releasekit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"go.maximumdirect.net/releasekit/internal/gitea"
) )
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) { func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.ManifestFilename { if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != artifact.ManifestFilename {
t.Fatalf("assets = %#v", result) t.Fatalf("assets = %#v", result)
} }
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n" want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"

View File

@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "go.maximumdirect.net/releasekit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"go.maximumdirect.net/releasekit/internal/candidate" "go.maximumdirect.net/releasekit/internal/candidate"
"go.maximumdirect.net/releasekit/internal/git" "go.maximumdirect.net/releasekit/internal/git"
@@ -131,7 +132,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
} }
build = &built build = &built
} }
var assets []gitea.ExpectedAsset var assets []artifact.ExpectedAsset
if build != nil { if build != nil {
assets, err = declaredBuildAssets(*build) assets, err = declaredBuildAssets(*build)
if err != nil { if err != nil {
@@ -142,8 +143,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, err return result, err
} }
if len(assets) > 0 { if len(assets) > 0 {
if err := gitea.VerifyLocalAssets(assets); err != nil { if err := artifact.VerifyLocalAssets(assets); err != nil {
return result, fmt.Errorf("verify build assets: %w", err) kind := gitea.FailureInvalid
if errors.Is(err, artifact.ErrAssetConflict) {
kind = gitea.FailureConflict
}
return result, &gitea.Failure{Kind: kind, Operation: "verify build assets", Err: err}
} }
} }
if build != nil && len(build.Settings.Targets) != 0 { if build != nil && len(build.Settings.Targets) != 0 {
@@ -196,7 +201,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, uploadErr return result, uploadErr
} }
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []artifact.ExpectedAsset) error {
if build == nil { if build == nil {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("assets require a build identity") return errors.New("assets require a build identity")
@@ -223,7 +228,7 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte
if !build.SourceVerified { if !build.SourceVerified {
return errors.New("build source was not verified as clean and unchanged during compilation") return errors.New("build source was not verified as clean and unchanged during compilation")
} }
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename { if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename {
return errors.New("build result does not contain the selected artifacts and complete checksum manifest") return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
} }
return nil return nil
@@ -245,7 +250,7 @@ func stateForError(err error, pageCreated bool) State {
return NotAttempted return NotAttempted
} }
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity { func assetIdentities(assets []artifact.ExpectedAsset) []gitea.AssetIdentity {
result := make([]gitea.AssetIdentity, 0, len(assets)) result := make([]gitea.AssetIdentity, 0, len(assets))
for _, asset := range assets { for _, asset := range assets {
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})

View File

@@ -10,7 +10,6 @@ import (
"go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "go.maximumdirect.net/releasekit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"go.maximumdirect.net/releasekit/internal/gitea"
) )
const performanceArtifactSize = 16 << 20 const performanceArtifactSize = 16 << 20
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
} }
if err := gitea.VerifyLocalAssets(assets); err != nil { if err := artifact.VerifyLocalAssets(assets); err != nil {
b.Fatal(err) b.Fatal(err)
} }
} }

View File

@@ -5,20 +5,15 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path"
"sort"
"strconv" "strconv"
"strings" "strings"
"go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
) )
const ManifestFilename = artifact.ManifestFilename
type AssetState string type AssetState string
const ( const (
@@ -28,17 +23,6 @@ const (
AssetUnverifiable AssetState = "unverifiable" 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 { type RemoteAsset struct {
ID int64 ID int64
Filename string Filename string
@@ -47,7 +31,7 @@ type RemoteAsset struct {
} }
type AssetInspection struct { type AssetInspection struct {
Expected ExpectedAsset Expected artifact.ExpectedAsset
State AssetState State AssetState
Existing []RemoteAsset Existing []RemoteAsset
Err error Err error
@@ -65,33 +49,9 @@ type assetResponse struct {
BrowserDownloadURL string `json:"browser_download_url"` 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 // InspectAssets lists the release's attachments and compares every declared
// asset by streamed SHA-256 content. It does not create or modify remote data. // 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 { if releaseID <= 0 {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive")) 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, "/\\") { 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")) return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
} }
if err := validateExpectedAssets(expected); err != nil { if err := artifact.ValidateExpectedAssets(expected); err != nil {
return Inspection{}, err 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) apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" 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 { func sameOrigin(base, target *url.URL) bool {
if target == nil || target.User != nil || target.Host == "" { if target == nil || target.User != nil || target.Host == "" {
return false return false

View File

@@ -5,33 +5,16 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic" "sync/atomic"
"testing" "testing"
"go.maximumdirect.net/releasekit/internal/artifact"
) )
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets" const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
manifest, err := BuildManifest([]ExpectedAsset{
assetFor("zeta.bin", []byte("zeta")),
assetFor("alpha.bin", []byte("alpha")),
})
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s alpha.bin\n%s zeta.bin\n", digest([]byte("alpha")), digest([]byte("zeta")))
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != digest([]byte(want)) || manifest.Size != int64(len(want)) {
t.Fatalf("manifest = %#v, content %q", manifest, manifest.Content)
}
empty, err := BuildManifest(nil)
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
}
}
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) { func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
content := []byte("selected binary") content := []byte("selected binary")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -53,7 +36,7 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -66,7 +49,7 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
} }
func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) { func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
expected := []ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))} expected := []artifact.ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))}
var appDownloads, otherDownloads atomic.Int32 var appDownloads, otherDownloads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "") assertAssetGET(t, r, "")
@@ -96,8 +79,8 @@ func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
} }
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) { func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))} binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
manifest, err := BuildManifest(binaries) manifest, err := artifact.BuildManifest(binaries)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -124,25 +107,25 @@ func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
} }
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) { func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))} binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
full, err := BuildManifest(binaries) full, err := artifact.BuildManifest(binaries)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
subset, err := BuildManifest(binaries[:1]) subset, err := artifact.BuildManifest(binaries[:1])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "") assertAssetGET(t, r, "")
if r.URL.Path == assetListPath { if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}}) writeAssetList(t, w, []assetResponse{{ID: 3, Name: artifact.ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}})
return return
} }
_, _ = w.Write(subset.Content) _, _ = w.Write(subset.Content)
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{full}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{full})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -152,7 +135,7 @@ func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
} }
func TestInspectAssetsRejectsDifferentManifest(t *testing.T) { func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
expected, err := BuildManifest([]ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))}) expected, err := artifact.BuildManifest([]artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -160,13 +143,13 @@ func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "") assertAssetGET(t, r, "")
if r.URL.Path == assetListPath { if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}}) writeAssetList(t, w, []assetResponse{{ID: 3, Name: artifact.ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}})
return return
} }
_, _ = w.Write(different) _, _ = w.Write(different)
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -186,7 +169,7 @@ func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -201,7 +184,7 @@ func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
_, _ = w.Write([]byte("null")) _, _ = w.Write([]byte("null"))
})) }))
defer server.Close() defer server.Close()
_, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app.bin", []byte("alpha"))}) _, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{assetFor("app.bin", []byte("alpha"))})
if failureKind(t, err) != FailureMalformed { if failureKind(t, err) != FailureMalformed {
t.Fatalf("error = %v", err) t.Fatalf("error = %v", err)
} }
@@ -243,7 +226,7 @@ func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
return return
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -282,7 +265,7 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
http.Redirect(w, r, external.URL+"/content", http.StatusFound) http.Redirect(w, r, external.URL+"/content", http.StatusFound)
})) }))
defer origin.Close() defer origin.Close()
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected}) inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []artifact.ExpectedAsset{expected})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -297,9 +280,9 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
} }
} }
func assetFor(filename string, content []byte) ExpectedAsset { func assetFor(filename string, content []byte) artifact.ExpectedAsset {
sum := sha256.Sum256(content) sum := sha256.Sum256(content)
return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)} return artifact.ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
} }
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) { func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) {

View File

@@ -3,8 +3,6 @@ package gitea
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -12,10 +10,11 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"go.maximumdirect.net/releasekit/internal/artifact"
) )
type DeliveryState string type DeliveryState string
@@ -50,20 +49,6 @@ type UploadResult struct {
Uncertain []AssetIdentity 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 // UploadMissing uploads assets classified missing by InspectAssets. It stops
// before the first write if any declared existing asset conflicts or cannot be // before the first write if any declared existing asset conflicts or cannot be
// verified, and never replaces an existing attachment. // verified, and never replaces an existing attachment.
@@ -93,8 +78,8 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments")) return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
} }
expected := orderedExpected(inspection.Assets) expected := orderedExpected(inspection.Assets)
if err := validateExpectedAssets(expected); err != nil { if err := artifact.ValidateExpectedAssets(expected); err != nil {
return UploadResult{State: DeliveryPartial}, err return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "preflight release assets", 0, err)
} }
result := UploadResult{State: DeliveryPartial} result := UploadResult{State: DeliveryPartial}
preflightConflict := false preflightConflict := false
@@ -132,20 +117,26 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
return result, nil return result, nil
} }
missing := make(map[string]ExpectedAsset, len(result.Pending)) missing := make(map[string]artifact.ExpectedAsset, len(result.Pending))
for _, observation := range inspection.Assets { for _, observation := range inspection.Assets {
if observation.State == AssetMissing { if observation.State == AssetMissing {
missing[observation.Expected.Filename] = observation.Expected missing[observation.Expected.Filename] = observation.Expected
} }
} }
if verifyLocal { if verifyLocal {
missingAssets := make([]artifact.ExpectedAsset, 0, len(result.Pending))
for _, item := range expected { for _, item := range expected {
if _, needsUpload := missing[item.Filename]; !needsUpload { if _, needsUpload := missing[item.Filename]; !needsUpload {
continue continue
} }
if err := verifyLocalAsset(item); err != nil { missingAssets = append(missingAssets, item)
return result, err }
if err := artifact.VerifyLocalAssets(missingAssets); err != nil {
kind := FailureInvalid
if errors.Is(err, artifact.ErrAssetConflict) {
kind = FailureConflict
} }
return result, failure(kind, "verify local assets", 0, err)
} }
} }
@@ -164,7 +155,7 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
if !shouldReconcileWrite(status, uploadErr) { if !shouldReconcileWrite(status, uploadErr) {
return result, uploadErr return result, uploadErr
} }
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item}) inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []artifact.ExpectedAsset{item})
if inspectErr != nil || len(inspection.Assets) != 1 { if inspectErr != nil || len(inspection.Assets) != 1 {
result.State = DeliveryUnknown result.State = DeliveryUnknown
result.Pending = removeIdentity(result.Pending, item.Filename) result.Pending = removeIdentity(result.Pending, item.Filename)
@@ -207,7 +198,7 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
return result, nil return result, nil
} }
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset ExpectedAsset) (int, error) { func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset artifact.ExpectedAsset) (int, error) {
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
parsed, err := url.Parse(uploadURL) parsed, err := url.Parse(uploadURL)
if err != nil { if err != nil {
@@ -279,49 +270,16 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
return response.StatusCode, nil return response.StatusCode, nil
} }
func verifyLocalAsset(asset ExpectedAsset) error { func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
if len(asset.Content) > 0 { result := make([]artifact.ExpectedAsset, 0, len(observations))
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 { for _, observation := range observations {
result = append(result, observation.Expected) result = append(result, observation.Expected)
} }
sort.Slice(result, func(i, j int) bool { sort.Slice(result, func(i, j int) bool {
if result[i].Filename == ManifestFilename { if result[i].Filename == artifact.ManifestFilename {
return false return false
} }
if result[j].Filename == ManifestFilename { if result[j].Filename == artifact.ManifestFilename {
return true return true
} }
return result[i].Filename < result[j].Filename return result[i].Filename < result[j].Filename
@@ -329,7 +287,7 @@ func orderedExpected(observations []AssetInspection) []ExpectedAsset {
return result return result
} }
func identity(asset ExpectedAsset) AssetIdentity { func identity(asset artifact.ExpectedAsset) AssetIdentity {
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256} return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
} }

View File

@@ -13,6 +13,8 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"go.maximumdirect.net/releasekit/internal/artifact"
) )
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) { func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
@@ -47,7 +49,7 @@ func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
client := testClient(server) client := testClient(server)
destination := testDestination(server.URL) destination := testDestination(server.URL)
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"}) assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
manifest, err := BuildManifest(assets) manifest, err := artifact.BuildManifest(assets)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -111,11 +113,11 @@ func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *t
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) { func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
assets := localAssets(t, []string{"app.bin"}) assets := localAssets(t, []string{"app.bin"})
manifest, err := BuildManifest(assets) manifest, err := artifact.BuildManifest(assets)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename} fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: artifact.ManifestFilename}
// The local binary is represented by its file path; the server starts with // The local binary is represented by its file path; the server starts with
// that exact content so only the checksum manifest needs uploading. // that exact content so only the checksum manifest needs uploading.
fixture.files["app.bin"] = readExpectedFile(t, assets[0]) fixture.files["app.bin"] = readExpectedFile(t, assets[0])
@@ -132,10 +134,10 @@ func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
if err != nil || result.State != DeliveryComplete { if err != nil || result.State != DeliveryComplete {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
} }
if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) { if got, want := names(result.Matching), []string{"app.bin", artifact.ManifestFilename}; !reflect.DeepEqual(got, want) {
t.Fatalf("matching = %v, want %v", got, want) t.Fatalf("matching = %v, want %v", got, want)
} }
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) { if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{artifact.ManifestFilename}) {
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
} }
assertAssetCategoriesDisjoint(t, result) assertAssetCategoriesDisjoint(t, result)
@@ -413,10 +415,10 @@ func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Reque
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))}) _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
} }
func localAssets(t *testing.T, names []string) []ExpectedAsset { func localAssets(t *testing.T, names []string) []artifact.ExpectedAsset {
t.Helper() t.Helper()
output := t.TempDir() output := t.TempDir()
assets := make([]ExpectedAsset, 0, len(names)) assets := make([]artifact.ExpectedAsset, 0, len(names))
for _, name := range names { for _, name := range names {
content := []byte("binary-" + name) content := []byte("binary-" + name)
path := filepath.Join(output, name) path := filepath.Join(output, name)
@@ -431,7 +433,7 @@ func localAssets(t *testing.T, names []string) []ExpectedAsset {
return assets return assets
} }
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte { func readExpectedFile(t *testing.T, asset artifact.ExpectedAsset) []byte {
t.Helper() t.Helper()
content, err := os.ReadFile(asset.Path) content, err := os.ReadFile(asset.Path)
if err != nil { if err != nil {