Artifact 34b25d126e41d0baf1be4ca1f1c9fe35c60ed7dcd5d379da95ffcb97643f7d8d:
- File
internal/sync/serve_http_test.go
— part of check-in
[f1236bd031]
at
2026-07-23 13:54:08
on branch trunk
— xfer: select framing by Content-Type; bound clone rounds by client decode limit (#133)
* fix: select xfer framing by Content-Type (#106); bound clone rounds by the client's decode limit (#109)
seeing which parsed, which made a decompression failure indistinguishable from "wrong framing" (the root of #104). Select framing from the §4 Content-Type instead: application/x-fossil is the §4.1 compressed container, every other type (including application/x-fossil-uncompressed, used by clone-v3 replies) is plain card text — the same rule canonical fossil applies. Verified against fossil 2.28: pull replies arrive as the prefixed container, clone replies as plain text, and no reply is ever an unprefixed raw zlib stream. The former unprefixed-zlib framing, checked first on the unverified belief that fossil emits it, was removed along with the length-prefix/zlib-header aliasing guard that only existed to disambiguate the two compressed framings trial-dispatch conflated. A compressed body that fails to inflate is now reported as the fault it is, never re-fed to the card parser. xfer.Decode takes the Content-Type; byte-oriented callers with no header pass the compressed type explicitly.
crossing one whole, so a round carried budget + one whole artifact. When sub-budget filler preceded a large artifact, that sum exceeded what the client could decode (xfer.MaxDecompressedBytes) and the clone failed with a spurious "declared container length exceeds" error whose threshold depended on incidental filler size. The server now flushes the round and sends an over-bound artifact alone when adding it would cross the client's decode bound, so clonability is a property of the artifact alone. The 2*budget compile-time guard now certifies what it appears to.
* fix: reject unrecognized xfer Content-Type with 415 (#106)
A POST whose Content-Type was absent or unrecognized fell through to the plaintext card decoder, which is exactly the #104 failure shape: possibly- compressed binary fed to the card parser. Request bodies come from untrusted peers, so a body carrying no framing signal is rejected rather than guessed at. Real fossil always sets the header; no conforming peer is affected.
xfer.KnownContentType shares the media-type parsing with the framing check, so the server and Decode agree on what a type means.
Also runs the #109 filler subtests in parallel — serially they pushed internal/sync past the 120s per-package timeout CI uses. (user: [email protected], size: 8894) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "net" "net/http" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/xfer" ) func freePort(t *testing.T) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := ln.Addr().String() ln.Close() return addr } func TestServeHTTPRoundTrip(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("http test blob") uuid := hash.SHA1(data) storeReceivedFile(r, uuid, "", data, nil) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) transport := &HTTPTransport{URL: fmt.Sprintf("http://%s", addr)} req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &xfer.GimmeCard{UUID: uuid}, }} resp, err := transport.Exchange(ctx, req) if err != nil { t.Fatalf("exchange: %v", err) } files := findCards[*xfer.FileCard](resp) found := false for _, f := range files { if f.UUID == uuid && string(f.Content) == string(data) { found = true } } if !found { t.Fatal("expected file card in HTTP response") } } func TestServeHTTPPushPull(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) transport := &HTTPTransport{URL: fmt.Sprintf("http://%s", addr)} // Push a blob data := []byte("pushed via http") uuid := hash.SHA1(data) pushReq := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: uuid, Content: data}, }} pushResp, err := transport.Exchange(ctx, pushReq) if err != nil { t.Fatalf("push exchange: %v", err) } errs := findCards[*xfer.ErrorCard](pushResp) if len(errs) > 0 { t.Fatalf("push error: %s", errs[0].Message) } // Pull it back pullReq := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &xfer.GimmeCard{UUID: uuid}, }} pullResp, err := transport.Exchange(ctx, pullReq) if err != nil { t.Fatalf("pull exchange: %v", err) } files := findCards[*xfer.FileCard](pullResp) found := false for _, f := range files { if f.UUID == uuid && string(f.Content) == string(data) { found = true } } if !found { t.Fatal("pushed blob not available via pull") } } func TestServeHTTPGetProbe(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) // GET should return HTML, not an error. resp, err := http.Get(fmt.Sprintf("http://%s/", addr)) if err != nil { t.Fatalf("GET: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("GET status: %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if ct != "text/html" { t.Fatalf("GET content-type: %s", ct) } } func TestServeHTTPEmptyPost(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) // Empty POST should return an empty xfer response, not 400. resp, err := http.Post(fmt.Sprintf("http://%s/", addr), "application/x-fossil", nil) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("empty POST status: %d", resp.StatusCode) } } func TestServeHTTPBadPayload(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) // Send binary garbage that isn't valid zlib or card data. // The Decode fallback to uncompressed will try to parse cards, // which may succeed on some text. Use NUL bytes to force failure. garbage := make([]byte, 50) for i := range garbage { garbage[i] = 0xFF } resp, err := http.Post( fmt.Sprintf("http://%s/", addr), "application/x-fossil", strings.NewReader(string(garbage)), ) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() // Decode fallback to uncompressed may produce empty cards or error. // Either 200 (empty response) or 400 (decode error) is acceptable // since the data has no valid cards. if resp.StatusCode != 200 && resp.StatusCode != 400 { t.Fatalf("bad payload status: %d, want 200 or 400", resp.StatusCode) } } // TestServeHTTPUnknownContentType pins that a body whose Content-Type is absent // or unrecognised is rejected outright rather than decoded as plain card text. // A peer is untrusted input: without a §4 media type nothing says how the body // is framed, and guessing "uncompressed" reopens issue #104, where still- // compressed bytes were fed to the card parser. func TestServeHTTPUnknownContentType(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) // A compressed container sent without the media type that identifies it. body, err := (&xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }}).Encode() if err != nil { t.Fatalf("encode: %v", err) } for _, ct := range []string{"", "text/plain", "application/octet-stream"} { req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/", addr), strings.NewReader(string(body))) if err != nil { t.Fatalf("new request: %v", err) } if ct != "" { req.Header.Set("Content-Type", ct) } else { // Go sets no Content-Type unless asked; make the absence explicit. req.Header.Del("Content-Type") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST %q: %v", ct, err) } resp.Body.Close() if resp.StatusCode != http.StatusUnsupportedMediaType { t.Fatalf("content type %q: status %d, want %d", ct, resp.StatusCode, http.StatusUnsupportedMediaType) } } } // TestServeHTTPUncompressedContentType pins that the other §4 media type is // still accepted — the rejection above is of unrecognised types, not of // uncompressed framing. func TestServeHTTPUncompressedContentType(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) resp, err := http.Post(fmt.Sprintf("http://%s/", addr), xfer.ContentTypeUncompressed+"; charset=utf-8", strings.NewReader("pragma client-version 1\n")) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("uncompressed POST status: %d, want 200", resp.StatusCode) } } func TestServeHTTPHandlerError(t *testing.T) { r := setupSyncTestRepo(t) addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Handler that always returns an error → server sends 500. failHandler := func(_ context.Context, _ *repo.Repo, _ *xfer.Message) (*xfer.Message, error) { return nil, fmt.Errorf("intentional handler failure") } go ServeHTTP(ctx, addr, r, failHandler) time.Sleep(100 * time.Millisecond) // Use raw HTTP to check the 500 status directly. body, _ := (&xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }}).Encode() resp, err := http.Post( fmt.Sprintf("http://%s/", addr), "application/x-fossil", strings.NewReader(string(body)), ) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != 500 { t.Fatalf("handler error status: %d, want 500", resp.StatusCode) } } func TestServeHTTPClone(t *testing.T) { r := setupSyncTestRepo(t) stored := map[string]bool{} for i := 0; i < 3; i++ { data := []byte(fmt.Sprintf("clone http %d", i)) uuid := hash.SHA1(data) storeReceivedFile(r, uuid, "", data, nil) stored[uuid] = true } addr := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go ServeHTTP(ctx, addr, r, HandleSync) time.Sleep(100 * time.Millisecond) transport := &HTTPTransport{URL: fmt.Sprintf("http://%s", addr)} req := &xfer.Message{Cards: []xfer.Card{ &xfer.CloneCard{Version: 1}, }} resp, err := transport.Exchange(ctx, req) if err != nil { t.Fatalf("clone exchange: %v", err) } files := findCards[*xfer.CFileCard](resp) for _, f := range files { delete(stored, f.UUID) } if len(stored) > 0 { t.Fatalf("clone missing blobs: %v", stored) } }