Upload missing Gitea release assets

This commit is contained in:
2026-10-05 20:05:25 +00:00
parent 692a503c26
commit eb2cab78bb
7 changed files with 656 additions and 18 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

303
internal/gitea/upload.go Normal file
View File

@@ -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
}

View File

@@ -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()
}