diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 831a4ec..0401b72 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -1029,6 +1029,227 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) { } } +func TestDeliverRejectsMissingBlankAndUnsupportedForgeBeforeTokenOrBuild(t *testing.T) { + for _, forge := range []string{"", " \t", "unknown"} { + t.Run(forge, func(t *testing.T) { + repository := newCLIRepository(t) + state := &deliveryServerState{} + server := newDeliveryServer(t, state) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{ + Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example", + TokenFile: filepath.Join(repository, "missing-token"), + }, + }) + state.update(func(data *deliveryServerData) { data.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) + cliGitRun(t, repository, "tag", "v1.2.3") + + shimDir := t.TempDir() + markerPath := filepath.Join(shimDir, "go-invoked") + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", markerPath) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") { + t.Fatalf("deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String()) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("Go was invoked before forge validation: %v", err) + } + if got := state.snapshot().apiRequests; got != 0 { + t.Fatalf("hosted API requests = %d, want 0", got) + } + }) + } +} + +func TestCIDeliverRejectsUnsupportedForgeBeforeTokenOrDependencyPreparation(t *testing.T) { + repository := newDeliveryBuildRepository(t) + state := &deliveryServerState{} + server := newDeliveryServer(t, state) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{ + Forge: "unsupported", BaseURL: server.URL, Owner: "owner", Repository: "example", + TokenFile: filepath.Join(repository, "missing-token"), + }, + }) + expectedCommit := pushTaggedCLIRepository(t, repository) + state.update(func(data *deliveryServerData) { data.commit = expectedCommit }) + + shimDir := t.TempDir() + markerPath := filepath.Join(shimDir, "go-invoked") + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", markerPath) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr) + if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") { + t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String()) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("Go was invoked before forge validation: %v", err) + } + if got := state.snapshot().apiRequests; got != 0 { + t.Fatalf("hosted API requests = %d, want 0", got) + } +} + +func TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne(t *testing.T) { + repository := newCLIRepository(t) + var mu sync.Mutex + var requests, writes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.Method != http.MethodGet { + writes++ + http.Error(w, "unexpected write", http.StatusMethodNotAllowed) + return + } + requests++ + if r.URL.Path == "/api/v1/repos/owner/example" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) + return + } + http.Error(w, "verification unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + Delivery: &cli.DeliverySettings{ + Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN", + }, + }) + expectedCommit := pushTaggedCLIRepository(t, repository) + t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token") + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr) + if status != 1 || !strings.Contains(stderr.String(), "unavailable") { + t.Fatalf("page-only ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String()) + } + if requests != 2 || writes != 0 { + t.Fatalf("page-only preflight requests=%d writes=%d, want two reads and no writes", requests, writes) + } +} + +func TestDeliverUnavailablePreflightStopsBeforeManagedBuild(t *testing.T) { + repository := newCLIRepository(t) + var mu sync.Mutex + var requests, writes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.Method != http.MethodGet { + writes++ + http.Error(w, "unexpected write", http.StatusMethodNotAllowed) + return + } + requests++ + if r.URL.Path == "/api/v1/repos/owner/example" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) + return + } + http.Error(w, "verification unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{ + Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN", + }, + }) + cliGitRun(t, repository, "tag", "v1.2.3") + t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token") + shimDir := t.TempDir() + markerPath := filepath.Join(shimDir, "go-invoked") + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", markerPath) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 1 || !strings.Contains(stderr.String(), "unavailable") { + t.Fatalf("deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String()) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("managed build ran before successful preflight: %v", err) + } + if requests != 2 || writes != 0 { + t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes) + } +} + +func TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation(t *testing.T) { + repository := newDeliveryBuildRepository(t) + var mu sync.Mutex + var requests, writes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.Method != http.MethodGet { + writes++ + http.Error(w, "unexpected write", http.StatusMethodNotAllowed) + return + } + requests++ + if r.URL.Path == "/api/v1/repos/owner/example" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) + return + } + http.Error(w, "verification unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{ + Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN", + }, + }) + expectedCommit := pushTaggedCLIRepository(t, repository) + t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token") + shimDir := t.TempDir() + markerPath := filepath.Join(shimDir, "go-invoked") + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", markerPath) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr) + if status != 1 || !strings.Contains(stderr.String(), "unavailable") { + t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String()) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("dependency preparation ran before successful preflight: %v", err) + } + if requests != 2 || writes != 0 { + t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes) + } +} + func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{} diff --git a/delivery_test.go b/delivery_test.go index 54dd2a8..2f430dd 100644 --- a/delivery_test.go +++ b/delivery_test.go @@ -19,6 +19,175 @@ import ( releasekit "go.maximumdirect.net/releasekit" ) +func TestDeliveryForgeSelectionIsExplicit(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + + for _, forge := range []releasekit.Forge{"", " \t", "unknown"} { + t.Run(string(forge), func(t *testing.T) { + destination := releasekit.DeliveryDestination{ + Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example", Token: "fixture-token", + } + if err := releasekit.PreflightDelivery(context.Background(), destination); err == nil || !strings.Contains(err.Error(), "forge") { + t.Fatalf("PreflightDelivery() error = %v, want explicit forge selection error", err) + } + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + Version: "v1.2.3", Destination: destination, + }) + if err == nil || !strings.Contains(err.Error(), "forge") || result.State != releasekit.DeliveryNotAttempted || result.Version != "v1.2.3" { + t.Fatalf("Deliver() = %#v, %v; want selection error before delivery", result, err) + } + }) + } + if requests != 0 { + t.Fatalf("hosted requests = %d, want 0 for invalid forge selection", requests) + } +} + +func TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout(t *testing.T) { + var requests, writes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes++ + t.Errorf("preflight method = %q, want GET", r.Method) + http.Error(w, "unexpected write", http.StatusMethodNotAllowed) + return + } + if got, want := r.Header.Get("Authorization"), "token fixture-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + requests++ + switch r.URL.Path { + case "/api/v1/repos/owner/example": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}}) + case "/api/v1/repositories/31": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}}) + default: + t.Errorf("preflight path = %q", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + if err := releasekit.PreflightDelivery(context.Background(), facadeDeliveryDestination(server.URL)); err != nil { + t.Fatalf("PreflightDelivery() error = %v", err) + } + if requests != 2 || writes != 0 { + t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes) + } +} + +func TestPreflightDeliveryPreservesPublicFailureKindsAndCauses(t *testing.T) { + cases := []struct { + name string + token string + status int + wantKind releasekit.DeliveryPreflightFailureKind + wantReads int + canceled bool + }{ + {name: "missing credential", wantKind: releasekit.DeliveryPreflightMissingCredential}, + {name: "invalid credential", token: "fixture-token", status: http.StatusUnauthorized, wantKind: releasekit.DeliveryPreflightInvalidCredential, wantReads: 1}, + {name: "inaccessible destination", token: "fixture-token", status: http.StatusForbidden, wantKind: releasekit.DeliveryPreflightInaccessibleDestination, wantReads: 1}, + {name: "unavailable service", token: "fixture-token", status: http.StatusServiceUnavailable, wantKind: releasekit.DeliveryPreflightUnavailable, wantReads: 1}, + {name: "canceled request", token: "fixture-token", wantKind: releasekit.DeliveryPreflightUnavailable, canceled: true}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + reads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reads++ + if test.status != 0 { + http.Error(w, "controlled failure", test.status) + return + } + t.Errorf("unexpected request to %q", r.URL.Path) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + destination := facadeDeliveryDestination(server.URL) + destination.Token = test.token + ctx := context.Background() + if test.canceled { + canceled, cancel := context.WithCancel(ctx) + cancel() + ctx = canceled + } + err := releasekit.PreflightDelivery(ctx, destination) + var preflight *releasekit.DeliveryPreflightError + if !errors.As(err, &preflight) || preflight.Kind != test.wantKind || preflight.Err == nil { + t.Fatalf("PreflightDelivery() error = %#v, want kind %q with cause", err, test.wantKind) + } + if test.canceled && !errors.Is(err, context.Canceled) { + t.Fatalf("PreflightDelivery() error = %v, want cancellation identity", err) + } + if reads != test.wantReads { + t.Fatalf("preflight reads = %d, want %d", reads, test.wantReads) + } + }) + } +} + +func TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + OutputDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + contents, err := os.ReadFile(prebuilt.Artifacts[0].Path) + if err != nil { + t.Fatal(err) + } + contents[0] ^= 0xff + if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil { + t.Fatal(err) + } + + var reads, writes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes++ + http.Error(w, "unexpected write", http.StatusMethodNotAllowed) + return + } + reads++ + if r.URL.Path == "/api/v1/repos/owner/example" { + _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}}) + return + } + http.Error(w, "verification unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Prebuilt: &prebuilt, + }) + var preflight *releasekit.DeliveryPreflightError + if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightUnavailable || result.State != releasekit.DeliveryNotAttempted { + t.Fatalf("Deliver() = %#v, %v; want unavailable preflight before tamper verification", result, err) + } + if reads != 2 || writes != 0 { + t.Fatalf("preflight reads=%d writes=%d, want two reads and no writes", reads, writes) + } +} + func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) { repository := newTaggedDeliveryConsumer(t) temporaryRoot := t.TempDir() diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4d36904..5950ffa 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -474,7 +474,7 @@ profile is authoritative; credential verification precedes application downloads ## Stage 9: Complete regression coverage and dependency checks -Status: pending. +Status: complete. 1. Finish adapting facade, CLI, HTTP fixture, and benchmark callers. Retain root/nested/major-module identities, page-only delivery, managed/prebuilt diff --git a/internal/apicheck/main.go b/internal/apicheck/main.go index 0314aa3..0269e13 100644 --- a/internal/apicheck/main.go +++ b/internal/apicheck/main.go @@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error { } visit(modulePath + "/buildinfo") + for _, root := range []string{ + modulePath + "/internal/delivery", + modulePath + "/internal/forge", + modulePath + "/internal/artifact", + } { + seen := make(map[string]bool) + var visitDelivery func(string) + visitDelivery = func(path string) { + if seen[path] { + return + } + seen[path] = true + if path == modulePath+"/internal/gitea" { + violations = append(violations, fmt.Sprintf("shared package %s depends on Gitea adapter", root)) + return + } + pkg, ok := byPath[path] + if !ok { + return + } + for _, imported := range pkg.Imports { + visitDelivery(imported) + } + } + visitDelivery(root) + } + if len(violations) == 0 { return nil } diff --git a/internal/apicheck/main_test.go b/internal/apicheck/main_test.go index a21696a..769358a 100644 --- a/internal/apicheck/main_test.go +++ b/internal/apicheck/main_test.go @@ -162,6 +162,30 @@ func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) { } } +func TestSharedDeliveryPackagesCannotDependOnGitea(t *testing.T) { + allowed := []listedPackage{ + {ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/forge", modulePath + "/internal/artifact"}}, + {ImportPath: modulePath + "/internal/forge"}, + {ImportPath: modulePath + "/internal/artifact"}, + {ImportPath: modulePath + "/internal/gitea"}, + } + if err := checkImportDirections(allowed); err != nil { + t.Fatalf("shared delivery boundary rejected neutral dependencies: %v", err) + } + + violating := []listedPackage{ + {ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/policy"}}, + {ImportPath: modulePath + "/internal/policy", Imports: []string{modulePath + "/internal/gitea"}}, + {ImportPath: modulePath + "/internal/forge"}, + {ImportPath: modulePath + "/internal/artifact"}, + {ImportPath: modulePath + "/internal/gitea"}, + } + err := checkImportDirections(violating) + if err == nil || !strings.Contains(err.Error(), "shared package "+modulePath+"/internal/delivery depends on Gitea adapter") { + t.Fatalf("checkImportDirections() error = %v, want transitive shared-delivery violation", err) + } +} + func checkFixture(t *testing.T, facadeSource string) []string { t.Helper() fset := token.NewFileSet() diff --git a/internal/gitea/assets.go b/internal/gitea/assets.go index 7165961..445720e 100644 --- a/internal/gitea/assets.go +++ b/internal/gitea/assets.go @@ -2,25 +2,12 @@ package gitea import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "io" "net/http" "net/url" "strconv" "strings" - - "go.maximumdirect.net/releasekit/internal/artifact" -) - -type AssetState string - -const ( - AssetMatching AssetState = "matching" - AssetMissing AssetState = "missing" - AssetConflicting AssetState = "conflicting" - AssetUnverifiable AssetState = "unverifiable" ) type RemoteAsset struct { @@ -30,18 +17,6 @@ type RemoteAsset struct { DownloadURL string } -type AssetInspection struct { - Expected artifact.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"` @@ -49,66 +24,6 @@ type assetResponse struct { BrowserDownloadURL string `json:"browser_download_url"` } -// 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 []artifact.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 := 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) - listed, err := c.listAssets(ctx, c.httpClient(), destination.Token, releaseID, apiRoot) - if err != nil { - return Inspection{}, err - } - remoteByName := make(map[string][]RemoteAsset, len(listed)) - for _, item := range listed { - remoteByName[item.Filename] = append(remoteByName[item.Filename], item) - } - 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.Filename]; !expected { - inspection.Unrelated = append(inspection.Unrelated, item) - } - } - return inspection, nil -} - func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) { if releaseID <= 0 { return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive")) @@ -131,20 +46,6 @@ func (c Client) listAssets(ctx context.Context, client *http.Client, token strin return assets, nil } -func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) { - stream, err := c.openAsset(ctx, client, base, token, rawURL) - if err != nil { - return "", 0, err - } - defer stream.Close() - hasher := sha256.New() - size, err := io.Copy(hasher, stream) - if err != nil { - return "", size, failure(FailureUnverifiable, "read release asset", 0, err) - } - return hex.EncodeToString(hasher.Sum(nil)), size, nil -} - func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) { if strings.TrimSpace(rawURL) == "" { return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL")) diff --git a/internal/gitea/assets_test.go b/internal/gitea/assets_test.go index 1ca3b87..08d06bd 100644 --- a/internal/gitea/assets_test.go +++ b/internal/gitea/assets_test.go @@ -2,310 +2,115 @@ package gitea import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "sync/atomic" "testing" - "go.maximumdirect.net/releasekit/internal/artifact" + "go.maximumdirect.net/releasekit/internal/forge" ) -const assetListPath = "/api/v1/repos/owner/project/releases/10/assets" - -func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) { - content := []byte("selected binary") +func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAssetGET(t, r, "") + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization = %q, want %q", got, want) + } + _, _ = io.WriteString(w, "null") + })) + defer server.Close() + + _, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10}) + var failure *forge.Failure + if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed { + t.Fatalf("ListAssets() error = %v, want malformed attachment response", err) + } +} + +func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("authorization at %s = %q, want %q", r.URL.Path, got, want) + } 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.Errorf("unexpected path %s", r.URL.Path) - return - } - })) - defer server.Close() - - 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) - } - 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 := []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, "") - 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 := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))} - manifest, err := artifact.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.Errorf("unexpected path %s", r.URL.Path) - return - } - _, _ = 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 := []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 := 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: 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, []artifact.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 := artifact.BuildManifest([]artifact.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: 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, []artifact.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, []artifact.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, []artifact.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" { + case "/files/redirect": http.Redirect(w, r, "/files/content", http.StatusFound) - return + case "/files/content": + _, _ = io.WriteString(w, "streamed asset") + default: + t.Errorf("unexpected path %q", r.URL.Path) + http.NotFound(w, r) } - if r.URL.Path == "/files/content" { - _, _ = w.Write(content) - return - } - t.Errorf("unexpected path %s", r.URL.Path) - return })) defer server.Close() - inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected}) + + backend := NewBackend(testDestination(server.URL), server.Client()) + stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"}) if err != nil { t.Fatal(err) } - if inspection.Assets[0].State != AssetMatching { - t.Fatalf("inspection = %#v", inspection) + content, readErr := io.ReadAll(stream) + closeErr := stream.Close() + if readErr != nil || closeErr != nil || string(content) != "streamed asset" { + t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr) } } -func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) { - for _, mode := range []string{"direct", "redirect", "conflicting"} { +func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) { + for _, mode := range []string{"direct", "redirect"} { t.Run(mode, func(t *testing.T) { - expected := assetFor("app.bin", []byte("matching bytes")) - var externalGets atomic.Int64 + var externalRequests atomic.Int32 external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - externalGets.Add(1) - if r.Header.Get("Authorization") != "" { - t.Error("Gitea token reached external host") + externalRequests.Add(1) + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("external authorization = %q, want empty", got) } - content := []byte("matching bytes") - if mode == "conflicting" { - content = []byte("different bytes") - } - w.Write(content) + _, _ = io.WriteString(w, "external asset") })) defer external.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAssetGET(t, r, "") - if r.URL.Path == assetListPath { - download := external.URL + "/content" - if mode == "redirect" { - download = "/redirect" - } - writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}}) - return + if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { + t.Errorf("origin authorization = %q, want %q", got, want) } http.Redirect(w, r, external.URL+"/content", http.StatusFound) })) defer origin.Close() - inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []artifact.ExpectedAsset{expected}) + + downloadURL := external.URL + "/content" + if mode == "redirect" { + downloadURL = "/redirect" + } + backend := NewBackend(testDestination(origin.URL), origin.Client()) + stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: downloadURL}) if err != nil { t.Fatal(err) } - want := AssetMatching - if mode == "conflicting" { - want = AssetConflicting - } - if inspection.Assets[0].State != want || externalGets.Load() != 1 { - t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load()) + content, readErr := io.ReadAll(stream) + closeErr := stream.Close() + if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 { + t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load()) } }) } } -func assetFor(filename string, content []byte) artifact.ExpectedAsset { - sum := sha256.Sum256(content) - return artifact.ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)} -} +func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}}) + })) + defer server.Close() -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) + assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10}) + if err != nil { + t.Fatal(err) + } + if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil { + t.Fatalf("ListAssets() = %#v", assets) } } - -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} -} diff --git a/internal/gitea/backend_test.go b/internal/gitea/backend_test.go index b2c1234..4338eaa 100644 --- a/internal/gitea/backend_test.go +++ b/internal/gitea/backend_test.go @@ -400,10 +400,56 @@ func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) { } } +func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) { + readerErr := errors.New("asset reader failed") + readDone := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" { + _ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"}) + return + } + _, _ = io.Copy(io.Discard, r.Body) + _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0}) + })) + defer server.Close() + backend := NewBackend(testDestination(server.URL), server.Client()) + release, err := backend.FindRelease(context.Background(), "v1.2.3") + if err != nil { + t.Fatalf("FindRelease() error = %v", err) + } + + evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone}) + select { + case <-readDone: + default: + t.Fatal("UploadAsset() returned before the multipart worker finished reading") + } + if !errors.Is(err, readerErr) { + t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr) + } + if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain { + t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence) + } +} + +type failingUploadReader struct { + err error + done chan struct{} +} + +func (reader failingUploadReader) Read([]byte) (int, error) { + close(reader.done) + return 0, reader.err +} + func validCreateReleaseRequest() forge.CreateReleaseRequest { return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"} } +func testDestination(baseURL string) Destination { + return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"} +} + type trackedReader struct { io.Reader closed bool diff --git a/internal/gitea/release.go b/internal/gitea/release.go index 2d955df..1926863 100644 --- a/internal/gitea/release.go +++ b/internal/gitea/release.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "net/url" - "strings" ) type FailureKind string @@ -55,14 +54,7 @@ type Destination struct { Token string } -type Request struct { - Destination Destination - Tag string - Commit string - Note string -} - -type Release struct { +type release struct { ID int64 URL string TagName string @@ -72,12 +64,6 @@ type Release struct { Prerelease bool } -type Result struct { - Release Release - Created bool - WriteAttempted bool // a create request was issued, including reconciled creates. -} - type Client struct { HTTPClient *http.Client } @@ -108,109 +94,29 @@ type createReleaseRequest struct { Prerelease bool `json:"prerelease"` } -// EnsureRelease verifies the hosted tag and finds or creates its stable release -// page. It never edits a release that already exists. -func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) { - if err := validateRequest(request); err != nil { - return Result{}, err - } - base, err := parseBaseURL(request.Destination.BaseURL) - if err != nil { - return Result{}, failure(FailureInvalid, "validate destination", 0, err) - } - client := c.httpClient() - apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository) - tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag) - - var tag tagResponse - if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil { - return Result{}, err - } - if tag.Commit.SHA != request.Commit { - return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit)) - } - - releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag) - existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL) - if err == nil { - return acceptExisting(existing, request) - } - if status != http.StatusNotFound { - return Result{}, err - } - - title := noteTitle(request.Note) - body, err := json.Marshal(createReleaseRequest{ - TagName: request.Tag, TargetCommitish: request.Commit, - Name: title, Body: request.Note, - }) - if err != nil { - return Result{}, failure(FailureInvalid, "encode release", 0, err) - } - created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body) - if postErr == nil { - result, verifyErr := acceptCreated(created, request) - if verifyErr == nil { - result.WriteAttempted = true - return result, nil - } - postErr = verifyErr - } - if !shouldReconcileWrite(postStatus, postErr) { - return Result{}, postErr - } - - // A conflict or interrupted server response may have followed a successful - // create. One lookup resolves only a page with the exact selected content. - retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL) - if retryErr == nil { - result, matchErr := acceptExisting(retry, request) - if matchErr == nil { - result.WriteAttempted = true - return result, nil - } - return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr) - } - if retryStatus == http.StatusNotFound { - if postStatus == http.StatusConflict { - return Result{}, failure(FailureConflict, "create release", postStatus, postErr) - } - return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr) - } - return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr)) -} - -func shouldReconcileWrite(status int, err error) bool { - if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return true - } - var failure *Failure - return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed) -} - -func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) { +func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return Release{}, 0, failure(FailureInvalid, "find release", 0, err) + return release{}, 0, failure(FailureInvalid, "find release", 0, err) } request.Header.Set("Authorization", "token "+token) request.Header.Set("Accept", "application/json") response, err := client.Do(request) if err != nil { - return Release{}, 0, failure(FailureTransport, "find release", 0, err) + return release{}, 0, failure(FailureTransport, "find release", 0, err) } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { - return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil) + return release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil) } if response.StatusCode < 200 || response.StatusCode >= 300 { - return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode) + return release{}, response.StatusCode, responseFailure("find release", response.StatusCode) } var decoded releaseResponse if err := decodeJSON(response.Body, &decoded); err != nil { - return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err) + return release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err) } - return decoded.Release(), response.StatusCode, nil + return decoded.release(), response.StatusCode, nil } func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error { @@ -234,27 +140,27 @@ func (c Client) getJSON(ctx context.Context, client *http.Client, token, operati return nil } -func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) { +func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (release, int, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { - return Release{}, 0, failure(FailureInvalid, "create release", 0, err) + return release{}, 0, failure(FailureInvalid, "create release", 0, err) } request.Header.Set("Authorization", "token "+token) request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/json") response, err := client.Do(request) if err != nil { - return Release{}, 0, failure(FailureTransport, "create release", 0, err) + return release{}, 0, failure(FailureTransport, "create release", 0, err) } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { - return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode) + return release{}, response.StatusCode, responseFailure("create release", response.StatusCode) } var decoded releaseResponse if err := decodeJSON(response.Body, &decoded); err != nil { - return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err) + return release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err) } - return decoded.Release(), response.StatusCode, nil + return decoded.release(), response.StatusCode, nil } func (c Client) httpClient() *http.Client { @@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client { return &clientCopy } -func validateRequest(request Request) error { - if strings.TrimSpace(request.Tag) == "" { - return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required")) - } - if !validObjectID(request.Commit) { - return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID")) - } - if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") { - return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment")) - } - if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") { - return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment")) - } - if strings.TrimSpace(request.Destination.Token) == "" { - return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required")) - } - if noteTitle(request.Note) == "" { - return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading")) - } - return nil -} - func parseBaseURL(raw string) (*url.URL, error) { parsed, err := url.Parse(raw) if err != nil { @@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) { return parsed, nil } -func acceptExisting(release Release, request Request) (Result, error) { - if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease { - return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag")) - } - return Result{Release: release}, nil -} - -func acceptCreated(release Release, request Request) (Result, error) { - if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease { - return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release")) - } - return Result{Release: release, Created: true}, nil -} - -func (response releaseResponse) Release() Release { - return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease} -} - -func noteTitle(note string) string { - first := strings.SplitN(note, "\n", 2)[0] - if !strings.HasPrefix(first, "# ") { - return "" - } - return strings.TrimSpace(strings.TrimPrefix(first, "# ")) +func (response releaseResponse) release() release { + return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease} } func validObjectID(value string) bool { diff --git a/internal/gitea/release_test.go b/internal/gitea/release_test.go deleted file mode 100644 index 7bc65ac..0000000 --- a/internal/gitea/release_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package gitea - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" -) - -const testCommit = "0123456789012345678901234567890123456789" -const testNote = "# Example v1.2.3\n\nRelease details.\n" -const testToken = "fixture-secret-token" - -func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) { - server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false) - result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if err != nil { - t.Fatal(err) - } - if result.Created || result.Release.Title != "Historical title" { - t.Fatalf("result = %#v", result) - } -} - -func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) { - tag := "integrations/ckconfig/v1.2.3" - state := &fixtureState{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAuth(t, r) - switch r.URL.EscapedPath() { - case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - writeTag(t, w, testCommit) - case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - http.NotFound(w, r) - case "/api/v1/repos/owner/project/releases": - assertRequest(t, r, http.MethodPost, r.URL.Path) - state.posts.Add(1) - var payload createReleaseRequest - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Error(err) - return - } - if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote { - t.Errorf("create payload = %#v", payload) - return - } - if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil { - t.Error(err) - return - } - default: - t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath()) - return - } - })) - defer server.Close() - - request := testRequest(server.URL) - request.Tag = tag - result, err := testClient(server).EnsureRelease(context.Background(), request) - if err != nil || !result.Created || state.posts.Load() != 1 { - t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err) - } -} - -func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) { - server, _ := newReleaseServer(t, releaseFixture{}, true) - result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if err != nil { - t.Fatal(err) - } - if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote { - t.Fatalf("result = %#v", result) - } -} - -func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) { - server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false) - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureConflict { - t.Fatalf("error = %v", err) - } - if got := state.posts.Load(); got != 0 { - t.Fatalf("release creates = %d, want 0", got) - } -} - -func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureUnauthorized { - t.Fatalf("error = %v", err) - } - if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) { - t.Fatalf("error exposes unexpected content: %v", err) - } -} - -func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) { - var posts atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - posts.Add(1) - } - assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") - _, _ = w.Write([]byte("{")) - })) - defer server.Close() - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureMalformed || posts.Load() != 0 { - t.Fatalf("error = %v, posts = %d", err, posts.Load()) - } -} - -func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) { - var posts atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") - if r.Method == http.MethodPost { - posts.Add(1) - } - _, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7) - })) - defer server.Close() - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureConflict || posts.Load() != 0 { - t.Fatalf("error = %v, posts = %d", err, posts.Load()) - } -} - -func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) { - var posts atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") - if r.Method == http.MethodPost { - posts.Add(1) - } - http.NotFound(w, r) - })) - defer server.Close() - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureNotFound || posts.Load() != 0 { - t.Fatalf("error = %v, posts = %d", err, posts.Load()) - } -} - -func TestEnsureReleaseHonorsContextCancellation(t *testing.T) { - var called atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) })) - defer server.Close() - ctx, cancel := context.WithCancel(context.Background()) - cancel() - _, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL)) - if !errors.Is(err, context.Canceled) || called.Load() { - t.Fatalf("error = %v, server called = %t", err, called.Load()) - } -} - -func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) { - var created atomic.Bool - var lookups atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAuth(t, r) - switch r.URL.Path { - case "/api/v1/repos/owner/project/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - writeTag(t, w, testCommit) - case "/api/v1/repos/owner/project/releases/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - lookups.Add(1) - if !created.Load() { - http.NotFound(w, r) - return - } - writeRelease(t, w, releaseFixture{title: "Example v1.2.3"}) - case "/api/v1/repos/owner/project/releases": - assertRequest(t, r, http.MethodPost, r.URL.Path) - var payload createReleaseRequest - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Error(err) - return - } - if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { - t.Errorf("create payload = %#v", payload) - return - } - created.Store(true) - hijacker, ok := w.(http.Hijacker) - if !ok { - t.Error("HTTP writer does not support hijacking") - return - } - connection, _, err := hijacker.Hijack() - if err != nil { - t.Error(err) - return - } - _ = connection.Close() - default: - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - return - } - })) - defer server.Close() - result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if err != nil { - t.Fatal(err) - } - if result.Created || lookups.Load() != 2 { - t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load()) - } -} - -func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) { - var lookups atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAuth(t, r) - switch r.URL.Path { - case "/api/v1/repos/owner/project/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - writeTag(t, w, testCommit) - case "/api/v1/repos/owner/project/releases/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - lookups.Add(1) - if lookups.Load() == 1 { - http.NotFound(w, r) - return - } - writeRelease(t, w, releaseFixture{title: "Created by another request"}) - case "/api/v1/repos/owner/project/releases": - assertRequest(t, r, http.MethodPost, r.URL.Path) - w.WriteHeader(http.StatusConflict) - default: - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - return - } - })) - defer server.Close() - result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if err != nil { - t.Fatal(err) - } - if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 { - t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load()) - } -} - -func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) { - var lookups atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAuth(t, r) - switch r.URL.Path { - case "/api/v1/repos/owner/project/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - writeTag(t, w, testCommit) - case "/api/v1/repos/owner/project/releases/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - lookups.Add(1) - if lookups.Load() == 1 { - http.NotFound(w, r) - return - } - http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) - case "/api/v1/repos/owner/project/releases": - assertRequest(t, r, http.MethodPost, r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - default: - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - return - } - })) - defer server.Close() - _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 { - t.Fatalf("error = %v, release lookups = %d", err, lookups.Load()) - } -} - -type releaseFixture struct { - title string - body string - draft bool - pre bool -} - -type fixtureState struct{ posts atomic.Int64 } - -func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) { - t.Helper() - state := &fixtureState{} - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertAuth(t, r) - switch r.URL.Path { - case "/api/v1/repos/owner/project/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - writeTag(t, w, testCommit) - case "/api/v1/repos/owner/project/releases/tags/v1.2.3": - assertRequest(t, r, http.MethodGet, r.URL.Path) - if existing.title == "" && existing.body == "" { - http.NotFound(w, r) - return - } - writeRelease(t, w, existing) - case "/api/v1/repos/owner/project/releases": - assertRequest(t, r, http.MethodPost, r.URL.Path) - state.posts.Add(1) - var payload createReleaseRequest - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Error(err) - return - } - if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { - t.Errorf("create payload = %#v", payload) - return - } - if !createMissing { - w.WriteHeader(http.StatusConflict) - return - } - writeRelease(t, w, releaseFixture{title: payload.Name}) - default: - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - return - } - }) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - return server, state -} - -func writeTag(t *testing.T, w http.ResponseWriter, commit string) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) -} - -func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre}) -} - -func (fixture releaseFixture) bodyOrDefault() string { - if fixture.body == "" { - return testNote - } - return fixture.body -} - -func assertRequest(t *testing.T, request *http.Request, method, path string) { - t.Helper() - if request.Method != method || request.URL.Path != path { - t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path) - return - } - assertAuth(t, request) -} - -func assertAuth(t *testing.T, request *http.Request) { - t.Helper() - if got := request.Header.Get("Authorization"); got != "token "+testToken { - t.Errorf("Authorization = %q", got) - return - } -} - -func testClient(server *httptest.Server) Client { - return Client{HTTPClient: server.Client()} -} - -func testRequest(baseURL string) Request { - return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote} -} - -func failureKind(t *testing.T, err error) FailureKind { - t.Helper() - var failure *Failure - if !errors.As(err, &failure) { - t.Fatalf("error type = %T (%v), want *Failure", err, err) - } - return failure.Kind -} - -func contains(value, substring string) bool { - return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring) -} - -func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) { - for _, mode := range []string{"matching", "conflicting", "unavailable"} { - t.Run(mode, func(t *testing.T) { - var posts, lookups atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"): - writeTag(t, w, testCommit) - case strings.Contains(r.URL.Path, "/releases/tags/"): - lookups.Add(1) - if posts.Load() == 0 { - http.NotFound(w, r) - return - } - if mode == "unavailable" { - http.Error(w, "unavailable", 503) - return - } - body := testNote - if mode == "conflicting" { - body = "other content" - } - writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body}) - case r.Method == http.MethodPost: - posts.Add(1) - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote}) - default: - t.Errorf("unexpected request %s", r.URL.Path) - } - })) - defer server.Close() - result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) - if posts.Load() != 1 || lookups.Load() != 2 { - t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load()) - } - switch mode { - case "matching": - if err != nil || !result.WriteAttempted { - t.Fatalf("result=%#v err=%v", result, err) - } - case "conflicting": - if failureKind(t, err) != FailureConflict { - t.Fatal(err) - } - case "unavailable": - if failureKind(t, err) != FailureUnknown { - t.Fatal(err) - } - } - }) - } -} diff --git a/internal/gitea/upload.go b/internal/gitea/upload.go index 275ab46..7d3b03c 100644 --- a/internal/gitea/upload.go +++ b/internal/gitea/upload.go @@ -1,208 +1,15 @@ package gitea import ( - "bytes" "context" "errors" - "fmt" "io" "mime/multipart" "net/http" "net/url" - "os" - "sort" "strconv" - "strings" - - "go.maximumdirect.net/releasekit/internal/artifact" ) -type DeliveryState string - -const ( - DeliveryNotAttempted DeliveryState = "not_attempted" - DeliveryComplete DeliveryState = "complete" - DeliveryPartial DeliveryState = "partial" - DeliveryConflict DeliveryState = "conflict" - DeliveryUnknown DeliveryState = "unknown" -) - -type AssetIdentity struct { - Filename string - SHA256 string -} - -// UploadResult reports the current asset observations from one upload attempt. -// Each identity appears in at most one category; early failures can leave some -// declared assets unreported. -type UploadResult struct { - State DeliveryState - // Matching contains existing attachments with the declared content. - Matching []AssetIdentity - // Uploaded contains assets whose upload was confirmed during this invocation. - Uploaded []AssetIdentity - // Pending contains assets known to be missing. - Pending []AssetIdentity - // Conflicting contains assets with detected content conflicts. - Conflicting []AssetIdentity - // Uncertain contains assets whose remote state could not be resolved. - Uncertain []AssetIdentity -} - -// 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. -func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) { - return c.uploadMissing(ctx, destination, releaseID, inspection, true) -} - -// UploadVerifiedMissing uploads missing assets whose local contents have already -// passed VerifyLocalAssets. It reuses that verification while retaining remote -// inspection, conflict handling, and upload reconciliation. -func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) { - return c.uploadMissing(ctx, destination, releaseID, inspection, false) -} - -func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) { - if releaseID <= 0 { - return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive")) - } - if strings.TrimSpace(destination.Token) == "" { - return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required")) - } - base, err := parseBaseURL(destination.BaseURL) - if err != nil { - return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err) - } - if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") { - 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 := artifact.ValidateExpectedAssets(expected); err != nil { - return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "preflight release assets", 0, err) - } - result := UploadResult{State: DeliveryPartial} - preflightConflict := false - preflightUnknown := false - for _, observation := range inspection.Assets { - switch observation.State { - case AssetMatching: - result.Matching = append(result.Matching, identity(observation.Expected)) - case AssetMissing: - result.Pending = append(result.Pending, identity(observation.Expected)) - case AssetConflicting: - preflightConflict = true - result.Conflicting = append(result.Conflicting, identity(observation.Expected)) - case AssetUnverifiable: - preflightUnknown = true - result.Uncertain = append(result.Uncertain, identity(observation.Expected)) - default: - return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename)) - } - } - if preflightConflict { - result.State = DeliveryConflict - return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict")) - } - if preflightUnknown { - result.State = DeliveryNotAttempted - return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified")) - } - if len(expected) == 0 { - result.State = DeliveryComplete - return result, nil - } - if len(result.Pending) == 0 { - result.State = DeliveryComplete - return result, nil - } - - 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 - } - 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) - } - } - - baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository) - client := c.httpClient() - for _, item := range expected { - if _, needsUpload := missing[item.Filename]; !needsUpload { - continue - } - content, openErr := assetContent(item) - if openErr != nil { - return result, failure(FailureInvalid, "open local release asset", 0, openErr) - } - status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item.Filename, content) - _ = content.Close() - if uploadErr == nil { - result.Uploaded = append(result.Uploaded, identity(item)) - result.Pending = removeIdentity(result.Pending, item.Filename) - continue - } - if !shouldReconcileWrite(status, uploadErr) { - return result, uploadErr - } - 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) - result.Uncertain = append(result.Uncertain, identity(item)) - return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr)) - } - observation := inspection.Assets[0] - switch observation.State { - case AssetMatching: - result.Matching = append(result.Matching, identity(item)) - result.Pending = removeIdentity(result.Pending, item.Filename) - case AssetConflicting: - result.State = DeliveryConflict - result.Pending = removeIdentity(result.Pending, item.Filename) - result.Conflicting = append(result.Conflicting, identity(item)) - return result, failure(FailureConflict, "resolve asset upload", status, uploadErr) - case AssetMissing: - if status == http.StatusConflict { - result.State = DeliveryConflict - result.Pending = removeIdentity(result.Pending, item.Filename) - result.Conflicting = append(result.Conflicting, identity(item)) - return result, failure(FailureConflict, "resolve asset upload", status, uploadErr) - } - result.State = DeliveryUnknown - result.Pending = removeIdentity(result.Pending, item.Filename) - result.Uncertain = append(result.Uncertain, identity(item)) - return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr) - default: - result.State = DeliveryUnknown - result.Pending = removeIdentity(result.Pending, item.Filename) - result.Uncertain = append(result.Uncertain, identity(item)) - return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err)) - } - } - if len(result.Pending) == 0 { - result.State = DeliveryComplete - } else { - result.State = DeliveryPartial - } - return result, nil -} - func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) { if content == nil { return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required")) @@ -263,40 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api } return response.StatusCode, nil } - -func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) { - if len(asset.Content) > 0 { - return io.NopCloser(bytes.NewReader(asset.Content)), nil - } - return os.Open(asset.Path) -} - -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 == artifact.ManifestFilename { - return false - } - if result[j].Filename == artifact.ManifestFilename { - return true - } - return result[i].Filename < result[j].Filename - }) - return result -} - -func identity(asset artifact.ExpectedAsset) AssetIdentity { - return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256} -} - -func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity { - for index, item := range identities { - if item.Filename == filename { - return append(identities[:index], identities[index+1:]...) - } - } - return identities -} diff --git a/internal/gitea/upload_test.go b/internal/gitea/upload_test.go deleted file mode 100644 index 4c25b83..0000000 --- a/internal/gitea/upload_test.go +++ /dev/null @@ -1,484 +0,0 @@ -package gitea - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "reflect" - "strings" - "sync" - "testing" - - "go.maximumdirect.net/releasekit/internal/artifact" -) - -func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte)} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"alpha.bin", "beta.bin"}) - inspection, err := client.InspectAssets(t.Context(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - contents := readExpectedFile(t, assets[1]) - contents[0] ^= 0xff - if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(t.Context(), destination, 10, inspection) - if err == nil || result.State != DeliveryPartial { - t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err) - } - if posts := fixture.postNames(); len(posts) != 0 { - t.Fatalf("uploaded %v before rejecting local content", posts) - } -} - -func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"}) - manifest, err := artifact.BuildManifest(assets) - if err != nil { - t.Fatal(err) - } - assets = append(assets, manifest) - - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - first, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err == nil || first.State != DeliveryPartial { - t.Fatalf("first upload result = %#v, error = %v", first, err) - } - if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("uploaded = %v, want %v", got, want) - } - if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, first) - - secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection) - if err != nil || second.State != DeliveryComplete { - t.Fatalf("second upload result = %#v, error = %v", second, err) - } - if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { - t.Fatalf("second uploaded = %v, want %v", got, want) - } - if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { - t.Fatalf("POST order = %v, want %v", got, want) - } -} - -func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"app.bin"}) - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err != nil || result.State != DeliveryComplete { - t.Fatalf("result = %#v, error = %v", result, err) - } - if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("matching = %v, want %v", got, want) - } - if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 { - t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) - } - assertAssetCategoriesDisjoint(t, result) -} - -func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) { - assets := localAssets(t, []string{"app.bin"}) - manifest, err := artifact.BuildManifest(assets) - if err != nil { - t.Fatal(err) - } - 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]) - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - expected := append(assets, manifest) - initial, err := client.InspectAssets(context.Background(), destination, 10, expected) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err != nil || result.State != DeliveryComplete { - t.Fatalf("result = %#v, error = %v", result, err) - } - 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{artifact.ManifestFilename}) { - t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) - } - assertAssetCategoriesDisjoint(t, result) -} - -func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"app.bin", "later.bin"}) - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err == nil || result.State != DeliveryUnknown { - t.Fatalf("result = %#v, error = %v", result, err) - } - if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("uncertain = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, result) - if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) { - t.Fatalf("POST names = %v", got) - } -} - -func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) { - fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"app.bin", "other.bin"}) - inspection, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, inspection) - if err == nil || result.State != DeliveryConflict { - t.Fatalf("result = %#v, error = %v", result, err) - } - if len(fixture.postNames()) != 0 { - t.Fatalf("uploads happened before conflict review: %v", fixture.postNames()) - } - if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("conflicting = %v, want %v", got, want) - } - if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, result) -} - -func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err == nil || result.State != DeliveryUnknown { - t.Fatalf("result = %#v, error = %v", result, err) - } - if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("uncertain = %v, want %v", got, want) - } - if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, result) - if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("POST names = %v, want %v", got, want) - } -} - -func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) { - fixture := &uploadFixture{ - files: make(map[string][]byte), - conflictName: "alpha.bin", - conflictDifferentContent: true, - } - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err == nil || result.State != DeliveryConflict { - t.Fatalf("result = %#v, error = %v", result, err) - } - if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("conflicting = %v, want %v", got, want) - } - if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, result) -} - -func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) { - fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"} - server := httptest.NewServer(fixture) - defer server.Close() - client := testClient(server) - destination := testDestination(server.URL) - assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) - initial, err := client.InspectAssets(context.Background(), destination, 10, assets) - if err != nil { - t.Fatal(err) - } - result, err := client.UploadMissing(context.Background(), destination, 10, initial) - if err == nil || result.State != DeliveryConflict { - t.Fatalf("result = %#v, error = %v", result, err) - } - if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("conflicting = %v, want %v", got, want) - } - if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { - t.Fatalf("pending = %v, want %v", got, want) - } - assertAssetCategoriesDisjoint(t, result) -} - -type uploadFixture struct { - mu sync.Mutex - files map[string][]byte - posts []string - failName string - conflictName string - conflictDifferentContent bool - conflictMissingName string - dropAckName string - dropAckMissingName string - failListAfter int - listCount int -} - -func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "token "+testToken { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method == http.MethodGet && r.URL.Path == assetListPath { - fixture.mu.Lock() - fixture.listCount++ - if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter { - fixture.mu.Unlock() - http.Error(w, "read failed", http.StatusServiceUnavailable) - return - } - assets := make([]assetResponse, 0, len(fixture.files)) - for name, content := range fixture.files { - assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)}) - } - fixture.mu.Unlock() - _ = json.NewEncoder(w).Encode(assets) - return - } - if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") { - name := strings.TrimPrefix(r.URL.Path, "/files/") - fixture.mu.Lock() - content, ok := fixture.files[name] - fixture.mu.Unlock() - if !ok { - http.NotFound(w, r) - return - } - _, _ = w.Write(content) - return - } - if r.Method == http.MethodPost && r.URL.Path == assetListPath { - fixture.receiveUpload(w, r) - return - } - http.Error(w, "unexpected request", http.StatusMethodNotAllowed) -} - -func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") { - http.Error(w, "multipart required", http.StatusBadRequest) - return - } - if err := r.ParseMultipartForm(1 << 20); err != nil { - http.Error(w, "invalid multipart body", http.StatusBadRequest) - return - } - file, header, err := r.FormFile("attachment") - if err != nil { - http.Error(w, "missing attachment field", http.StatusBadRequest) - return - } - defer file.Close() - content, err := io.ReadAll(file) - if err != nil { - http.Error(w, "read multipart file", http.StatusBadRequest) - return - } - if header.Filename != r.URL.Query().Get("name") { - http.Error(w, "filename query mismatch", http.StatusBadRequest) - return - } - name := header.Filename - fixture.mu.Lock() - fixture.posts = append(fixture.posts, name) - attempts := 0 - for _, posted := range fixture.posts { - if posted == name { - attempts++ - } - } - if name == fixture.failName && attempts == 1 { - fixture.mu.Unlock() - http.Error(w, "write failed", http.StatusBadRequest) - return - } - if name == fixture.dropAckMissingName { - fixture.mu.Unlock() - hijacker, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijacking unavailable", http.StatusInternalServerError) - return - } - connection, _, err := hijacker.Hijack() - if err == nil { - _ = connection.Close() - } - return - } - if name == fixture.conflictMissingName { - fixture.mu.Unlock() - w.WriteHeader(http.StatusConflict) - return - } - if name == fixture.conflictName && fixture.conflictDifferentContent { - fixture.files[name] = []byte("conflicting bytes") - fixture.mu.Unlock() - w.WriteHeader(http.StatusConflict) - return - } - fixture.files[name] = append([]byte(nil), content...) - fixture.mu.Unlock() - if name == fixture.conflictName { - w.WriteHeader(http.StatusConflict) - return - } - if name == fixture.dropAckName { - hijacker, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijacking unavailable", http.StatusInternalServerError) - return - } - connection, _, err := hijacker.Hijack() - if err == nil { - _ = connection.Close() - } - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))}) -} - -func localAssets(t *testing.T, names []string) []artifact.ExpectedAsset { - t.Helper() - output := t.TempDir() - assets := make([]artifact.ExpectedAsset, 0, len(names)) - for _, name := range names { - content := []byte("binary-" + name) - path := filepath.Join(output, name) - if err := os.WriteFile(path, content, 0o600); err != nil { - t.Fatal(err) - } - sum := assetFor(name, content) - sum.Content = nil - sum.Path = path - assets = append(assets, sum) - } - return assets -} - -func readExpectedFile(t *testing.T, asset artifact.ExpectedAsset) []byte { - t.Helper() - content, err := os.ReadFile(asset.Path) - if err != nil { - t.Fatal(err) - } - return content -} - -func (fixture *uploadFixture) postNames() []string { - fixture.mu.Lock() - defer fixture.mu.Unlock() - return append([]string(nil), fixture.posts...) -} - -func names(identities []AssetIdentity) []string { - result := make([]string, 0, len(identities)) - for _, identity := range identities { - result = append(result, identity.Filename) - } - return result -} - -func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) { - t.Helper() - groups := []struct { - name string - identities []AssetIdentity - }{ - {name: "matching", identities: result.Matching}, - {name: "uploaded", identities: result.Uploaded}, - {name: "pending", identities: result.Pending}, - {name: "conflicting", identities: result.Conflicting}, - {name: "uncertain", identities: result.Uncertain}, - } - seen := make(map[string]string) - for _, group := range groups { - for _, item := range group.identities { - if previous, ok := seen[item.Filename]; ok { - t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name) - } - seen[item.Filename] = group.name - } - } -} - -func urlPathEscape(value string) string { - return (&url.URL{Path: value}).EscapedPath() -}