diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2ad1376..cc4d104 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/artifact/assets.go b/internal/artifact/assets.go new file mode 100644 index 0000000..9e95b24 --- /dev/null +++ b/internal/artifact/assets.go @@ -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[:]) +} diff --git a/internal/artifact/assets_test.go b/internal/artifact/assets_test.go new file mode 100644 index 0000000..c6d2d48 --- /dev/null +++ b/internal/artifact/assets_test.go @@ -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[:]) +} diff --git a/internal/delivery/assets.go b/internal/delivery/assets.go index c7020b6..9cfe4de 100644 --- a/internal/delivery/assets.go +++ b/internal/delivery/assets.go @@ -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) } diff --git a/internal/delivery/assets_test.go b/internal/delivery/assets_test.go index 9de7881..0036e62 100644 --- a/internal/delivery/assets_test.go +++ b/internal/delivery/assets_test.go @@ -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" diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 1edcd29..1c789f1 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -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}) diff --git a/internal/delivery/performance_test.go b/internal/delivery/performance_test.go index 6cb918f..605b50f 100644 --- a/internal/delivery/performance_test.go +++ b/internal/delivery/performance_test.go @@ -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) } } diff --git a/internal/gitea/assets.go b/internal/gitea/assets.go index 9eac60a..30f7a85 100644 --- a/internal/gitea/assets.go +++ b/internal/gitea/assets.go @@ -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 diff --git a/internal/gitea/assets_test.go b/internal/gitea/assets_test.go index 60f26e6..1ca3b87 100644 --- a/internal/gitea/assets_test.go +++ b/internal/gitea/assets_test.go @@ -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) { diff --git a/internal/gitea/upload.go b/internal/gitea/upload.go index b9a714e..1f3cdd7 100644 --- a/internal/gitea/upload.go +++ b/internal/gitea/upload.go @@ -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} } diff --git a/internal/gitea/upload_test.go b/internal/gitea/upload_test.go index 89316ed..4c25b83 100644 --- a/internal/gitea/upload_test.go +++ b/internal/gitea/upload_test.go @@ -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 {