diff --git a/docs/integrations/gitea.md b/docs/integrations/gitea.md index fee37d5..b8b8385 100644 --- a/docs/integrations/gitea.md +++ b/docs/integrations/gitea.md @@ -1,9 +1,9 @@ # Gitea release pages -ReleaseKit's internal Gitea client verifies a hosted source tag and finds or -creates its stable release page. The public delivery facade and CLI workflow -are planned separately; this integration guide describes the implemented -internal operation. +ReleaseKit's internal Gitea client verifies a hosted source tag, finds or +creates its stable release page, inspects existing assets, and uploads missing +assets. The public delivery facade and CLI workflow are planned separately; +this guide describes the implemented internal operations. The caller supplies the Gitea base URL, owner, repository name, and token explicitly. The client does not read ambient credentials. The token is sent in @@ -43,8 +43,20 @@ not missing. Unrelated attachments are returned separately and are preserved. Attachment downloads and same-origin redirects carry authorization only when their scheme, host, and port match the configured Gitea service. Cross-origin -URLs and redirects are left unrequested and reported as unverifiable. Inspection -uses GET requests only; uploads are a later delivery operation. +URLs and redirects are left unrequested and reported as unverifiable. + +The upload operation accepts only an inspection result without conflicts or +unverifiable assets. It uploads missing binaries in filename order and then the +missing `SHA256SUMS`, using multipart field `attachment` and the filename query +parameter `name`. Matching attachments are retained; unrelated and existing +attachments are never overwritten or deleted. Results keep matching, uploaded, +pending, conflicting, and uncertain identities separate. After an ambiguous +upload or an upload name conflict, the client performs one list and content +check. It does not repeat the POST when the result is missing or cannot be +verified, so a caller can retry with a new inspection. + +Uploads use the configured service origin and the same explicit token header. +Source-only page delivery supplies no binary assets and creates no manifest. The integration tests use a local HTTP fixture and assert request paths, payloads, authorization, streaming comparisons, manifest handling, and failure diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 028540e..72eb90d 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -16,8 +16,8 @@ Implemented internal packages: overlays. - `internal/build` compiles explicit Go targets with shared version injection, verifies native version output, and returns generated file digests. -- `internal/gitea` verifies hosted source tags, finds or creates matching stable - release pages, and inspects existing attachments through the Gitea REST API. +- `internal/gitea` verifies hosted source tags, manages matching stable release + pages, and inspects and uploads declared attachments through the Gitea REST API. - `internal/candidate` captures and validates one clean local candidate, checks its committed release note and local tag state, runs validation once, and rechecks mutable repository state. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index acf7b8c..da0d197 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit staged implementation plan -Status: In progress. Stages 1–13 are complete; later stages remain pending. +Status: In progress. Stages 1–14 are complete; later stages remain pending. Date: 2026-10-05 @@ -775,7 +775,7 @@ by inspection tests. ## Stage 14: Upload missing assets and report partial delivery -Status: Pending. Depends on Stage 13. +Status: Complete. Depends on Stage 13. 1. Add multipart uploads for missing binaries in filename order, followed by a missing manifest. Record completed work and pending asset identities. diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index 2d62226..935afa6 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -2,9 +2,9 @@ This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, Narratio, and Scriptorium on 2026-09-29. The implementation plan records the -current implemented boundary; the internal Gitea page and asset-inspection -client is implemented, while asset delivery, facade/CLI integration, and -broader integrations remain planned. The public facade architecture is +current implemented boundary; the internal Gitea page and asset-delivery +client is implemented, while facade/CLI integration and broader integrations +remain planned. The public facade architecture is accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, candidate validation, and source publication. The [implementation plan](implementation.md) owns ordered stages, execution @@ -56,9 +56,9 @@ cleanup. The [architecture policy](../policy/architecture.md) owns the adopted responsibility and dependency rules, including the runtime version boundary. The root `releasekit` package provides the public facade for candidate -validation, source publication, and selected-target builds. The internal -Gitea page and asset-inspection client is implemented; delivery orchestration -remains planned. The separate public `buildinfo` package provides lightweight +validation, source publication, and selected-target builds. Internal Gitea +page and asset-delivery operations are implemented; facade and CLI orchestration +remain planned. The separate public `buildinfo` package provides lightweight runtime version access. Each facade owns its public named types and documented contracts. Public diff --git a/internal/gitea/release.go b/internal/gitea/release.go index d1ac3ce..a8e180b 100644 --- a/internal/gitea/release.go +++ b/internal/gitea/release.go @@ -152,7 +152,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err if postErr == nil { return acceptCreated(created, request) } - if !shouldReconcileCreate(postStatus, postErr) { + if !shouldReconcileWrite(postStatus, postErr) { return Result{}, postErr } @@ -175,7 +175,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr)) } -func shouldReconcileCreate(status int, err error) bool { +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 } diff --git a/internal/gitea/upload.go b/internal/gitea/upload.go new file mode 100644 index 0000000..db779ff --- /dev/null +++ b/internal/gitea/upload.go @@ -0,0 +1,303 @@ +package gitea + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type DeliveryState string + +const ( + DeliveryComplete DeliveryState = "complete" + DeliveryPartial DeliveryState = "partial" + DeliveryConflict DeliveryState = "conflict" + DeliveryUnknown DeliveryState = "unknown" +) + +type AssetIdentity struct { + Filename string + SHA256 string +} + +type UploadResult struct { + State DeliveryState + Matching []AssetIdentity + Uploaded []AssetIdentity + Pending []AssetIdentity + Conflicting []AssetIdentity + 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) { + 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 := validateExpectedAssets(expected); err != nil { + return UploadResult{State: DeliveryPartial}, 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 = DeliveryUnknown + return result, failure(FailureUnknown, "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]ExpectedAsset, len(result.Pending)) + for _, observation := range inspection.Assets { + if observation.State == AssetMissing { + missing[observation.Expected.Filename] = observation.Expected + } + } + for _, item := range expected { + if _, needsUpload := missing[item.Filename]; !needsUpload { + continue + } + if err := verifyLocalAsset(item); err != nil { + return result, 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 + } + status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item) + 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, []ExpectedAsset{item}) + if inspectErr != nil || len(inspection.Assets) != 1 { + result.State = DeliveryUnknown + 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.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.Conflicting = append(result.Conflicting, identity(item)) + return result, failure(FailureConflict, "resolve asset upload", status, uploadErr) + } + result.State = DeliveryUnknown + 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, asset ExpectedAsset) (int, error) { + uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" + parsed, err := url.Parse(uploadURL) + if err != nil { + return 0, failure(FailureInvalid, "prepare asset upload", 0, err) + } + query := parsed.Query() + query.Set("name", asset.Filename) + parsed.RawQuery = query.Encode() + reader, writer := io.Pipe() + multipartWriter := multipart.NewWriter(writer) + writeDone := make(chan error, 1) + go func() { + part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename) + if partErr == nil { + var source io.Reader + if len(asset.Content) > 0 { + source = bytes.NewReader(asset.Content) + } else { + file, openErr := os.Open(asset.Path) + if openErr != nil { + partErr = openErr + } else { + defer file.Close() + source = file + } + } + if partErr == nil { + _, partErr = io.Copy(part, source) + } + } + closeErr := multipartWriter.Close() + if partErr == nil { + partErr = closeErr + } + _ = writer.CloseWithError(partErr) + writeDone <- partErr + }() + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), reader) + if err != nil { + _ = reader.CloseWithError(err) + <-writeDone + return 0, failure(FailureInvalid, "upload release asset", 0, err) + } + request.Header.Set("Authorization", "token "+token) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + response, requestErr := client.Do(request) + if requestErr != nil { + _ = reader.CloseWithError(requestErr) + <-writeDone + return 0, failure(FailureTransport, "upload release asset", 0, requestErr) + } + defer response.Body.Close() + writeErr := <-writeDone + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response.StatusCode, responseFailure("upload release asset", response.StatusCode) + } + var uploaded assetResponse + if err := decodeJSON(response.Body, &uploaded); err != nil { + return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err) + } + if uploaded.ID <= 0 || uploaded.Name != asset.Filename { + return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset")) + } + if writeErr != nil { + return response.StatusCode, failure(FailureMalformed, "stream uploaded asset", response.StatusCode, writeErr) + } + return response.StatusCode, nil +} + +func verifyLocalAsset(asset ExpectedAsset) error { + if len(asset.Content) > 0 { + if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 { + return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename)) + } + return nil + } + if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename { + return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename)) + } + file, err := os.Open(asset.Path) + if err != nil { + return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err)) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err)) + } + if !info.Mode().IsRegular() || info.Size() != asset.Size { + return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename)) + } + hasher := sha256.New() + size, err := io.Copy(hasher, file) + if err != nil { + return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err)) + } + if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 { + return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename)) + } + return nil +} + +func orderedExpected(observations []AssetInspection) []ExpectedAsset { + result := make([]ExpectedAsset, 0, len(observations)) + for _, observation := range observations { + result = append(result, observation.Expected) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Filename == ManifestFilename { + return false + } + if result[j].Filename == ManifestFilename { + return true + } + return result[i].Filename < result[j].Filename + }) + return result +} + +func identity(asset 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 new file mode 100644 index 0000000..d2fb01d --- /dev/null +++ b/internal/gitea/upload_test.go @@ -0,0 +1,323 @@ +package gitea + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" +) + +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 := 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) + } + + 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()) + } +} + +func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) { + assets := localAssets(t, []string{"app.bin"}) + manifest, err := BuildManifest(assets) + if err != nil { + t.Fatal(err) + } + fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: 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", ManifestFilename}; !reflect.DeepEqual(got, want) { + t.Fatalf("matching = %v, want %v", got, want) + } + if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) { + t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) + } +} + +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"}) + 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{"app.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) + } + 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) + } +} + +type uploadFixture struct { + mu sync.Mutex + files map[string][]byte + posts []string + failName string + conflictName string + dropAckName 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 + } + 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) []ExpectedAsset { + t.Helper() + output := t.TempDir() + assets := make([]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 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 urlPathEscape(value string) string { + return (&url.URL{Path: value}).EscapedPath() +}