Move asset verification into the artifact package
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Implementation plan: neutral forge delivery and credential preflight
|
||||
|
||||
Status: implementation in progress; Stage 1 is complete and Stages 2–10 are
|
||||
pending.
|
||||
Status: implementation in progress; Stages 1–2 are complete and Stages 3–10
|
||||
are pending.
|
||||
|
||||
## 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
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
1. Move `ExpectedAsset` and the generic work in `BuildManifest`,
|
||||
`validateExpectedAssets`, and local verification from `internal/gitea` to
|
||||
|
||||
141
internal/artifact/assets.go
Normal file
141
internal/artifact/assets.go
Normal 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[:])
|
||||
}
|
||||
93
internal/artifact/assets_test.go
Normal file
93
internal/artifact/assets_test.go
Normal 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[:])
|
||||
}
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
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
|
||||
if len(settings.Targets) == 0 {
|
||||
if len(result.Artifacts) != 0 {
|
||||
@@ -33,32 +32,32 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
||||
}
|
||||
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))
|
||||
for _, artifact := range result.Artifacts {
|
||||
wantName, ok := wantByTarget[artifact.Target]
|
||||
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
|
||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
|
||||
for _, builtArtifact := range result.Artifacts {
|
||||
wantName, ok := wantByTarget[builtArtifact.Target]
|
||||
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", builtArtifact.Filename)
|
||||
}
|
||||
seenTargets[artifact.Target] = true
|
||||
if filepath.Base(artifact.Path) != artifact.Filename {
|
||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename)
|
||||
seenTargets[builtArtifact.Target] = true
|
||||
if filepath.Base(builtArtifact.Path) != builtArtifact.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 {
|
||||
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() {
|
||||
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{
|
||||
Filename: artifact.Filename,
|
||||
SHA256: artifact.SHA256,
|
||||
assets = append(assets, artifact.ExpectedAsset{
|
||||
Filename: builtArtifact.Filename,
|
||||
SHA256: builtArtifact.SHA256,
|
||||
Size: info.Size(),
|
||||
Path: artifact.Path,
|
||||
Path: builtArtifact.Path,
|
||||
})
|
||||
}
|
||||
manifest, err := gitea.BuildManifest(assets)
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
@@ -131,7 +132,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
build = &built
|
||||
}
|
||||
var assets []gitea.ExpectedAsset
|
||||
var assets []artifact.ExpectedAsset
|
||||
if build != nil {
|
||||
assets, err = declaredBuildAssets(*build)
|
||||
if err != nil {
|
||||
@@ -142,8 +143,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
return result, err
|
||||
}
|
||||
if len(assets) > 0 {
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
return result, fmt.Errorf("verify build assets: %w", err)
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
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 {
|
||||
@@ -196,7 +201,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
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 len(assets) != 0 {
|
||||
return errors.New("assets require a build identity")
|
||||
@@ -223,7 +228,7 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte
|
||||
if !build.SourceVerified {
|
||||
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 nil
|
||||
@@ -245,7 +250,7 @@ func stateForError(err error, pageCreated bool) State {
|
||||
return NotAttempted
|
||||
}
|
||||
|
||||
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
|
||||
func assetIdentities(assets []artifact.ExpectedAsset) []gitea.AssetIdentity {
|
||||
result := make([]gitea.AssetIdentity, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
const performanceArtifactSize = 16 << 20
|
||||
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,33 +5,16 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
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) {
|
||||
content := []byte("selected binary")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -53,7 +36,7 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -66,7 +49,7 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
|
||||
}
|
||||
|
||||
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
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
@@ -96,8 +79,8 @@ func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
|
||||
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
manifest, err := BuildManifest(binaries)
|
||||
binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
manifest, err := artifact.BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,25 +107,25 @@ func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
|
||||
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
full, err := BuildManifest(binaries)
|
||||
binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
full, err := artifact.BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subset, err := BuildManifest(binaries[:1])
|
||||
subset, err := artifact.BuildManifest(binaries[:1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
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
|
||||
}
|
||||
_, _ = w.Write(subset.Content)
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -152,7 +135,7 @@ func TestInspectAssetsRejectsSubsetManifest(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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -160,13 +143,13 @@ func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
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
|
||||
}
|
||||
_, _ = w.Write(different)
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,7 +169,7 @@ func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -201,7 +184,7 @@ func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||
_, _ = w.Write([]byte("null"))
|
||||
}))
|
||||
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 {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
@@ -243,7 +226,7 @@ func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||
return
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -282,7 +265,7 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||
}))
|
||||
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 {
|
||||
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)
|
||||
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) {
|
||||
|
||||
@@ -3,8 +3,6 @@ package gitea
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,10 +10,11 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
type DeliveryState string
|
||||
@@ -50,20 +49,6 @@ type UploadResult struct {
|
||||
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.
|
||||
@@ -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"))
|
||||
}
|
||||
expected := orderedExpected(inspection.Assets)
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, err
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "preflight release assets", 0, err)
|
||||
}
|
||||
result := UploadResult{State: DeliveryPartial}
|
||||
preflightConflict := false
|
||||
@@ -132,20 +117,26 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
|
||||
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 {
|
||||
if observation.State == AssetMissing {
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
if verifyLocal {
|
||||
missingAssets := make([]artifact.ExpectedAsset, 0, len(result.Pending))
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
if err := verifyLocalAsset(item); err != nil {
|
||||
return result, err
|
||||
missingAssets = append(missingAssets, item)
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
@@ -207,7 +198,7 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
|
||||
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"
|
||||
parsed, err := url.Parse(uploadURL)
|
||||
if err != nil {
|
||||
@@ -279,49 +270,16 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
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))
|
||||
func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
|
||||
result := make([]artifact.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 {
|
||||
if result[i].Filename == artifact.ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if result[j].Filename == ManifestFilename {
|
||||
if result[j].Filename == artifact.ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return result[i].Filename < result[j].Filename
|
||||
@@ -329,7 +287,7 @@ func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
||||
return result
|
||||
}
|
||||
|
||||
func identity(asset ExpectedAsset) AssetIdentity {
|
||||
func identity(asset artifact.ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
||||
@@ -47,7 +49,7 @@ func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -111,11 +113,11 @@ func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *t
|
||||
|
||||
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
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
|
||||
// that exact content so only the checksum manifest needs uploading.
|
||||
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
||||
@@ -132,10 +134,10 @@ func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
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))})
|
||||
}
|
||||
|
||||
func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
||||
func localAssets(t *testing.T, names []string) []artifact.ExpectedAsset {
|
||||
t.Helper()
|
||||
output := t.TempDir()
|
||||
assets := make([]ExpectedAsset, 0, len(names))
|
||||
assets := make([]artifact.ExpectedAsset, 0, len(names))
|
||||
for _, name := range names {
|
||||
content := []byte("binary-" + name)
|
||||
path := filepath.Join(output, name)
|
||||
@@ -431,7 +433,7 @@ func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
||||
return assets
|
||||
}
|
||||
|
||||
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte {
|
||||
func readExpectedFile(t *testing.T, asset artifact.ExpectedAsset) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(asset.Path)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user