Inspect hosted assets against build manifests

This commit is contained in:
2026-10-05 19:56:32 +00:00
parent 717d6d0d04
commit 692a503c26
9 changed files with 695 additions and 13 deletions

39
delivery_assets.go Normal file
View File

@@ -0,0 +1,39 @@
package releasekit
import (
"fmt"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
func declaredBuildAssets(result BuildResult) ([]gitea.ExpectedAsset, error) {
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
for _, artifact := range result.Artifacts {
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)
}
info, err := os.Stat(artifact.Path)
if err != nil {
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
}
assets = append(assets, gitea.ExpectedAsset{
Filename: artifact.Filename,
SHA256: artifact.SHA256,
Size: info.Size(),
Path: artifact.Path,
})
}
manifest, err := gitea.BuildManifest(assets)
if err != nil {
return nil, fmt.Errorf("build checksum manifest: %w", err)
}
if manifest.Filename != "" {
assets = append(assets, manifest)
}
return assets, nil
}

54
delivery_assets_test.go Normal file
View File

@@ -0,0 +1,54 @@
package releasekit
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
output := t.TempDir()
first := []byte("first artifact")
second := []byte("second artifact")
firstPath := filepath.Join(output, "zeta.bin")
secondPath := filepath.Join(output, "alpha.bin")
if err := os.WriteFile(firstPath, first, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(secondPath, second, 0o600); err != nil {
t.Fatal(err)
}
result, err := declaredBuildAssets(BuildResult{Artifacts: []BuildArtifact{
{Path: firstPath, Filename: "zeta.bin", SHA256: assetDigest(first)},
{Path: secondPath, Filename: "alpha.bin", SHA256: assetDigest(second)},
}})
if err != nil {
t.Fatal(err)
}
if len(result) != 3 || result[0].Filename != "zeta.bin" || result[1].Filename != "alpha.bin" || result[2].Filename != gitea.ManifestFilename {
t.Fatalf("assets = %#v", result)
}
want := assetDigest(second) + " alpha.bin\n" + assetDigest(first) + " zeta.bin\n"
if string(result[2].Content) != want {
t.Fatalf("manifest = %q, want %q", result[2].Content, want)
}
if result[0].Size != int64(len(first)) || result[1].Size != int64(len(second)) {
t.Fatalf("sizes = %d, %d", result[0].Size, result[1].Size)
}
}
func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) {
assets, err := declaredBuildAssets(BuildResult{})
if err != nil || len(assets) != 0 {
t.Fatalf("assets = %#v, error = %v", assets, err)
}
}
func assetDigest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

View File

@@ -28,6 +28,24 @@ lookup cannot determine the result after a possibly successful write, the
client returns an unknown outcome. Other HTTP failures retain their structured client returns an unknown outcome. Other HTTP failures retain their structured
failure kind and status code. failure kind and status code.
For a binary-producing release, the client can build a deterministic
`SHA256SUMS` file from the declared artifact names and SHA-256 digests. Entries
are sorted by filename and use the standard two-space separator. A source-only
release produces no manifest.
Asset inspection lists the release attachments, streams each declared existing
file, and compares its SHA-256 digest. It reports each asset as matching,
missing, conflicting, or unverifiable. Same-name attachments with different
bytes are conflicts even when their reported size matches. An existing
`SHA256SUMS` must exactly match the complete expected manifest; a subset or
different manifest is a conflict. An unreadable attachment is unverifiable,
not missing. Unrelated attachments are returned separately and are preserved.
Attachment downloads and same-origin redirects carry authorization only when
their scheme, host, and port match the configured Gitea service. Cross-origin
URLs and redirects are left unrequested and reported as unverifiable. Inspection
uses GET requests only; uploads are a later delivery operation.
The integration tests use a local HTTP fixture and assert request paths, The integration tests use a local HTTP fixture and assert request paths,
payloads, authorization, and failure handling. They do not contact a live Gitea payloads, authorization, streaming comparisons, manifest handling, and failure
service or use real credentials. handling. They do not contact a live Gitea service or use real credentials.

View File

@@ -16,8 +16,8 @@ Implemented internal packages:
overlays. overlays.
- `internal/build` compiles explicit Go targets with shared version injection, - `internal/build` compiles explicit Go targets with shared version injection,
verifies native version output, and returns generated file digests. verifies native version output, and returns generated file digests.
- `internal/gitea` verifies hosted source tags and finds or creates matching - `internal/gitea` verifies hosted source tags, finds or creates matching stable
stable release pages through the Gitea REST API. release pages, and inspects existing attachments through the Gitea REST API.
- `internal/candidate` captures and validates one clean local candidate, checks - `internal/candidate` captures and validates one clean local candidate, checks
its committed release note and local tag state, runs validation once, and its committed release note and local tag state, runs validation once, and
rechecks mutable repository state. rechecks mutable repository state.

View File

@@ -1,6 +1,6 @@
# ReleaseKit staged implementation plan # ReleaseKit staged implementation plan
Status: In progress. Stages 1–12 are complete; later stages remain pending. Status: In progress. Stages 1–13 are complete; later stages remain pending.
Date: 2026-10-05 Date: 2026-10-05
@@ -758,7 +758,7 @@ request paths, payloads, and authentication without using real credentials.
## Stage 13: Inspect asset and manifest content ## Stage 13: Inspect asset and manifest content
Status: Pending. Depends on Stage 12. Status: Complete. Depends on Stage 12.
1. Generate the expected binary manifest from the declared build result. Add 1. Generate the expected binary manifest from the declared build result. Add
attachment discovery and streaming hash comparison of existing assets. attachment discovery and streaming hash comparison of existing assets.

View File

@@ -2,11 +2,11 @@
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
current implemented boundary; the internal Gitea page client is implemented, current implemented boundary; the internal Gitea page and asset-inspection
while asset delivery, facade/CLI integration, and broader integrations remain client is implemented, while asset delivery, facade/CLI integration, and
planned. The public facade architecture is accepted in broader integrations remain planned. The public facade architecture is
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for
candidate validation, and source publication. runtime version, candidate validation, and source publication.
The [implementation plan](implementation.md) owns ordered stages, execution The [implementation plan](implementation.md) owns ordered stages, execution
instructions, acceptance checks, and progress toward this target state. instructions, acceptance checks, and progress toward this target state.
@@ -57,8 +57,9 @@ The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary. responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate The root `releasekit` package provides the public facade for candidate
validation, source publication, and selected-target builds. The internal validation, source publication, and selected-target builds. The internal
Gitea page client is implemented; delivery orchestration remains planned. The Gitea page and asset-inspection client is implemented; delivery orchestration
separate public `buildinfo` package provides lightweight runtime version access. remains planned. The separate public `buildinfo` package provides lightweight
runtime version access.
Each facade owns its public named types and documented contracts. Public Each facade owns its public named types and documented contracts. Public
operations, inputs, outcomes, errors, and callbacks express deliberately operations, inputs, outcomes, errors, and callbacks express deliberately

266
internal/gitea/assets.go Normal file
View File

@@ -0,0 +1,266 @@
package gitea
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
)
const ManifestFilename = "SHA256SUMS"
type AssetState string
const (
AssetMatching AssetState = "matching"
AssetMissing AssetState = "missing"
AssetConflicting AssetState = "conflicting"
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
Size int64
DownloadURL string
}
type AssetInspection struct {
Expected ExpectedAsset
State AssetState
Existing []RemoteAsset
Err error
}
type Inspection struct {
Assets []AssetInspection
Unrelated []RemoteAsset
}
type assetResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
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) {
if releaseID <= 0 {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
}
if strings.TrimSpace(destination.Token) == "" {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
}
base, err := parseBaseURL(destination.BaseURL)
if err != nil {
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
}
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
}
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
var listed []assetResponse
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
return Inspection{}, err
}
if listed == nil {
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
}
remoteByName := make(map[string][]RemoteAsset, len(listed))
for _, item := range listed {
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
}
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
}
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
declared := make(map[string]struct{}, len(expected))
for _, item := range expected {
declared[item.Filename] = struct{}{}
matches := remoteByName[item.Filename]
observation := AssetInspection{Expected: item, Existing: matches}
switch len(matches) {
case 0:
observation.State = AssetMissing
case 1:
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
if err != nil {
observation.State = AssetUnverifiable
observation.Err = err
} else if remoteHash == item.SHA256 {
observation.State = AssetMatching
} else {
observation.State = AssetConflicting
}
default:
observation.State = AssetConflicting
}
inspection.Assets = append(inspection.Assets, observation)
}
for _, item := range listed {
if _, expected := declared[item.Name]; !expected {
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
}
}
return inspection, nil
}
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
if strings.TrimSpace(rawURL) == "" {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
}
assetURL, err := base.Parse(rawURL)
if err != nil || !sameOrigin(base, assetURL) || assetURL.User != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset URL is outside the configured service origin"))
}
assetURL.Fragment = ""
visited := make(map[string]struct{})
for {
key := assetURL.String()
if _, exists := visited[key]; exists {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
}
visited[key] = struct{}{}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
if err != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
}
request.Header.Set("Authorization", "token "+token)
request.Header.Set("Accept", "application/octet-stream")
response, err := client.Do(request)
if err != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
}
if isRedirect(response.StatusCode) {
location, locationErr := response.Location()
_ = response.Body.Close()
if locationErr != nil || !sameOrigin(base, location) || location.User != nil {
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("asset redirect is outside the configured service origin"))
}
location.Fragment = ""
assetURL = location
continue
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", 0, responseFailure("download release asset", response.StatusCode)
}
hasher := sha256.New()
size, err := io.Copy(hasher, response.Body)
if err != nil {
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
}
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
}
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Hostname(), target.Hostname()) && effectivePort(base) == effectivePort(target)
}
func effectivePort(parsed *url.URL) string {
if port := parsed.Port(); port != "" {
return port
}
if strings.EqualFold(parsed.Scheme, "https") {
return "443"
}
return "80"
}
func isRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return true
default:
return false
}
}

View File

@@ -0,0 +1,303 @@
package gitea
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
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) {
assertAssetGET(t, r, "")
switch r.URL.Path {
case assetListPath:
writeAssetList(t, w, []assetResponse{
{ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"},
{ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"},
})
case "/files/app":
_, _ = w.Write(content)
case "/files/notes":
_, _ = w.Write([]byte("unrelated"))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)})
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 1 || inspection.Assets[0].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "notes.txt" {
t.Fatalf("unrelated = %#v", inspection.Unrelated)
}
}
func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
expected := []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, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{
{ID: 2, Name: expected[0].Filename, Size: 5, BrowserDownloadURL: "/files/app"},
{ID: 3, Name: expected[1].Filename, Size: 4, BrowserDownloadURL: "/files/other"},
})
return
}
if r.URL.Path == "/files/app" {
appDownloads.Add(1)
_, _ = w.Write([]byte("bravo"))
return
}
otherDownloads.Add(1)
_, _ = w.Write([]byte("beta"))
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, expected)
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting || inspection.Assets[1].State != AssetMatching || appDownloads.Load() != 1 || otherDownloads.Load() != 1 {
t.Fatalf("inspection = %#v, downloads = %d and %d", inspection, appDownloads.Load(), otherDownloads.Load())
}
}
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
manifest, err := BuildManifest(binaries)
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: manifest.Filename, Size: manifest.Size, BrowserDownloadURL: "/files/manifest"}})
return
}
if r.URL.Path != "/files/manifest" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write(manifest.Content)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, append(binaries, manifest))
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 3 || inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMissing || inspection.Assets[2].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
full, err := BuildManifest(binaries)
if err != nil {
t.Fatal(err)
}
subset, err := 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"}})
return
}
_, _ = w.Write(subset.Content)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{full})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
expected, err := BuildManifest([]ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))})
if err != nil {
t.Fatal(err)
}
different := []byte("not the declared checksum list\n")
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"}})
return
}
_, _ = w.Write(different)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
expected := assetFor("app.bin", []byte("alpha"))
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: 2, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/app"}})
return
}
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetUnverifiable || inspection.Assets[0].Err == nil {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, assetListPath)
_, _ = w.Write([]byte("null"))
}))
defer server.Close()
_, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app.bin", []byte("alpha"))})
if failureKind(t, err) != FailureMalformed {
t.Fatalf("error = %v", err)
}
}
func TestInspectAssetsSourceOnlyDoesNotDeclareManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, assetListPath)
writeAssetList(t, w, []assetResponse{{ID: 7, Name: "historical.txt", Size: 3}})
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, nil)
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 0 || len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "historical.txt" {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
content := []byte("alpha")
expected := assetFor("app.bin", content)
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: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
return
}
if r.URL.Path == "/files/redirect" {
http.Redirect(w, r, "/files/content", http.StatusFound)
return
}
if r.URL.Path == "/files/content" {
_, _ = w.Write(content)
return
}
t.Fatalf("unexpected path %s", r.URL.Path)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsNeverSendsTokenToCrossOriginRedirect(t *testing.T) {
var externalCalled atomic.Bool
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalCalled.Store(true) }))
defer external.Close()
expected := assetFor("app.bin", []byte("alpha"))
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: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
return
}
http.Redirect(w, r, external.URL+"/private", http.StatusFound)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetUnverifiable || externalCalled.Load() {
t.Fatalf("inspection = %#v, cross-origin called = %t", inspection, externalCalled.Load())
}
}
func assetFor(filename string, content []byte) ExpectedAsset {
sum := sha256.Sum256(content)
return 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) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(assets); err != nil {
t.Error(err)
}
}
func assertAssetGET(t *testing.T, r *http.Request, expectedPath string) {
t.Helper()
if r.Method != http.MethodGet {
t.Errorf("method = %q, want GET", r.Method)
}
if expectedPath != "" && r.URL.Path != expectedPath {
t.Errorf("path = %q, want %q", r.URL.Path, expectedPath)
}
if got := r.Header.Get("Authorization"); got != "token "+testToken {
t.Errorf("Authorization = %q", got)
}
}
func testDestination(base string) Destination {
return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken}
}

View File

@@ -23,6 +23,7 @@ const (
FailureUnauthorized FailureKind = "unauthorized" FailureUnauthorized FailureKind = "unauthorized"
FailureConflict FailureKind = "conflict" FailureConflict FailureKind = "conflict"
FailureMalformed FailureKind = "malformed_response" FailureMalformed FailureKind = "malformed_response"
FailureUnverifiable FailureKind = "unverifiable"
FailureTransport FailureKind = "transport" FailureTransport FailureKind = "transport"
FailureRejected FailureKind = "rejected" FailureRejected FailureKind = "rejected"
FailureUnknown FailureKind = "unknown" FailureUnknown FailureKind = "unknown"