Artifact 0149cd49120c3e15080c6c25fc97595ceb2023083b2c16a243774a0edab470a7:
- File
internal/xfer/message_oversize_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: 7472) [annotate] [blame] [check-ins using]
package xfer import ( "bytes" "compress/zlib" "encoding/binary" "strings" "testing" ) // compressedContainer builds the §4.1 compressed container -- 4-byte // big-endian pre-compression length followed by a zlib stream -- around n // bytes of card text, without ever holding n bytes in memory. func compressedContainer(t *testing.T, n int) []byte { t.Helper() if n <= 0 { panic("compressedContainer: n must be positive") } var buf bytes.Buffer var prefix [4]byte binary.BigEndian.PutUint32(prefix[:], uint32(n)) buf.Write(prefix[:]) zw := zlib.NewWriter(&buf) // "igot <hash>\n" repeated: real cards, so a decoder that reaches them // fails for a reason other than the payload being unparseable. card := []byte("igot " + strings.Repeat("a", 40) + "\n") written := 0 for written < n { chunk := card if remaining := n - written; remaining < len(chunk) { chunk = chunk[:remaining] } if _, err := zw.Write(chunk); err != nil { t.Fatalf("zlib write: %v", err) } written += len(chunk) } if err := zw.Close(); err != nil { t.Fatalf("zlib close: %v", err) } if buf.Len() >= n { t.Fatalf("container did not compress: %d bytes for %d input", buf.Len(), n) } return buf.Bytes() } // TestDecodeOversizeCompressedMessageIsReported checks that a well-formed // compressed body larger than MaxDecompressedBytes is reported as an oversize // message. Method: build a valid container whose decompressed length exceeds // the bound and decode it. The regression this pins (issue #104) is not that // the decode fails -- it is *how* it failed: the previous fallback chain // treated the size rejection as "not this framing" and handed the still // compressed bytes to the card parser, which read compressed noise as cards // until one of them split into no fields, and reported a card-syntax error // roughly sixteen thousand cards into a body that held none. func TestDecodeOversizeCompressedMessageIsReported(t *testing.T) { if testing.Short() { t.Skip("inflates past MaxDecompressedBytes") } data := compressedContainer(t, MaxDecompressedBytes+1024) _, err := Decode(data, ContentTypeCompressed) if err == nil { t.Fatal("Decode accepted a message over MaxDecompressedBytes") } if !strings.Contains(err.Error(), "exceeds") { t.Errorf("error does not name the size bound: %v", err) } if strings.Contains(err.Error(), "empty line after split") { t.Errorf("compressed bytes were parsed as cards: %v", err) } if strings.Contains(err.Error(), "decode card") { t.Errorf("transport failure reported as a card fault: %v", err) } } // TestDecodeCorruptZlibIsNotParsedAsCards checks that a body with a valid zlib // header and a corrupt body is reported as a decompression failure. Method: // truncate a valid container mid-stream, which leaves the header intact. func TestDecodeCorruptZlibIsNotParsedAsCards(t *testing.T) { full := compressedContainer(t, 1<<20) truncated := full[:len(full)/2] _, err := Decode(truncated, ContentTypeCompressed) if err == nil { t.Fatal("Decode accepted a truncated zlib stream") } if strings.Contains(err.Error(), "decode card") { t.Errorf("truncated stream reported as a card fault: %v", err) } } // TestDecodeRoundTripsLargeMessageUnderBound checks that a message just under // the bound still decodes, so the guard cannot be satisfied by rejecting // everything large. Method: encode enough igot cards to pass 32 MiB and // decode the result. func TestDecodeRoundTripsLargeMessageUnderBound(t *testing.T) { if testing.Short() { t.Skip("encodes a 32 MiB message") } const target = 32 << 20 msg := &Message{} for size := 0; size < target; size += 46 { msg.Cards = append(msg.Cards, &IGotCard{UUID: strings.Repeat("b", 40)}) } wire, err := msg.Encode() if err != nil { t.Fatalf("Encode: %v", err) } got, err := Decode(wire, ContentTypeCompressed) if err != nil { t.Fatalf("Decode: %v", err) } if len(got.Cards) != len(msg.Cards) { t.Errorf("decoded %d cards, want %d", len(got.Cards), len(msg.Cards)) } } // TestDecodeUncompressedBodyStillWorks checks that plain card text is still // accepted, since the fix narrows when that fallback is reached. Method: // decode an uncompressed body of the kind an x-fossil-uncompressed clone // reply carries. func TestDecodeUncompressedBodyStillWorks(t *testing.T) { body := []byte("igot " + strings.Repeat("c", 40) + "\nclone_seqno 7\n") msg, err := Decode(body, ContentTypeUncompressed) if err != nil { t.Fatalf("Decode: %v", err) } if len(msg.Cards) != 2 { t.Fatalf("decoded %d cards, want 2", len(msg.Cards)) } if _, ok := msg.Cards[1].(*CloneSeqNoCard); !ok { t.Errorf("second card is %T, want *CloneSeqNoCard", msg.Cards[1]) } } // TestMaxDecompressedBytesIsReachable guards the bound against being set below // what a zlib stream can actually deliver here. Method: decompress a container // sized exactly at the bound. func TestMaxDecompressedBytesIsReachable(t *testing.T) { if testing.Short() { t.Skip("inflates MaxDecompressedBytes") } data := compressedContainer(t, MaxDecompressedBytes) raw, err := decompressContainer(data) if err != nil { t.Fatalf("decompressContainer at the bound: %v", err) } if len(raw) != MaxDecompressedBytes { t.Errorf("decompressed %d bytes, want %d", len(raw), MaxDecompressedBytes) } } // TestDecodeRejectsOversizeDeclaredLength pins issue #110's surviving // protection: a §4.1 container whose declared length exceeds // MaxDecompressedBytes is rejected on the declared length alone, before the // zlib stream after it is inflated. Method: build a genuine container -- 4-byte // declared length plus a real zlib payload -- but overwrite its declared // length with 0x081D0000 (well over the bound) and assert it is rejected // naming the declared length, not after expansion. Content-Type dispatch made // this unconditional: a compressed body always reads its length prefix, so the // former format-1/format-2 aliasing that motivated #110 can no longer arise. func TestDecodeRejectsOversizeDeclaredLength(t *testing.T) { real := compressedContainer(t, 64) // small, genuine §4.1 container // Overwrite the genuine declared-length prefix with an oversize one; the // zlib payload after it is untouched and still real. oversize := append([]byte{0x08, 0x1D, 0x00, 0x00}, real[4:]...) _, err := decompressContainer(oversize) if err == nil { t.Fatal("decompressContainer accepted a container with an oversize declared length") } if !strings.Contains(err.Error(), "declared") { t.Errorf("error does not name the declared-length rejection: %v", err) } if _, err := Decode(oversize, ContentTypeCompressed); err == nil { t.Fatal("Decode accepted a container with an oversize declared length") } } // TestDecodeUncompressedBodyWithLargeLeadingBytesStillWorks checks that plain // card text whose first four bytes happen to read as a large integer decodes // as cards. Under Content-Type dispatch the leading bytes are never read as a // length: an uncompressed Content-Type sends the body straight to the card // parser, so a body can never be misclassified as an oversize container. func TestDecodeUncompressedBodyWithLargeLeadingBytesStillWorks(t *testing.T) { body := []byte("igot " + strings.Repeat("c", 40) + "\nclone_seqno 7\n") msg, err := Decode(body, ContentTypeUncompressed) if err != nil { t.Fatalf("Decode: %v", err) } if len(msg.Cards) != 2 { t.Fatalf("decoded %d cards, want 2", len(msg.Cards)) } }