Artifact d7401472ed44e133d923d3a0a6fb92e0c66684880406c94e8807be17fa7ccb3d:
- File
internal/xfer/message_oversize_test.go
— part of check-in
[60ef41b5b1]
at
2026-10-03 15:59:54
on branch trunk
— fix(blob): always inflate blob.content on load (#235) (#238)
* fix(blob): always inflate blob.content on load (#235)
blob.Load decided whether a row was stored raw by comparing len(content) with blob.size. That guess is unsound for delta rows: blob.size is the expanded artifact's length, while content is the compressed delta, and the two can be equal by coincidence. When they are, Load handed back the still-compressed bytes, and the delta applier failed with "expected integer at pos 0".
Go 1.27 replaced compress/flate's encoder, so the compressed size of any given delta shifted. The fixture in TestExpand_SingleDelta and TestIsAvailable_GroundedChain now compresses to exactly the target size (62 bytes), which exposed the bug. Any real repository could hit it under either Go version.
Fossil itself never makes this guess: content_of_blob (src/content.c:218-230) inflates blob.content unconditionally, and every writer here stores the compressed form. Load now does the same. TestLoadInflatesWhenStoredLengthEqualsSize forces the coincidence directly so it fails on the old code under any Go version.
Separately, TestDecodeRejectsOversizeDeclaredLength built its container from a 64-byte payload. Go 1.27's flate skips match-finding for inputs under 128 bytes, so that payload came out as a stored block larger than its input and tripped the helper's did-it-compress guard. The test now uses 1 KiB. The property it asserts is unchanged.
* docs(changelog): note the blob.Load inflate fix (#235) (user: [email protected], size: 7775) [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) { // Small, genuine §4.1 container. 1 KiB rather than less because Go 1.27's // compress/flate skips match-finding for inputs under 128 bytes // (deflateFast, compress/flate/deflate.go), so a tinier payload comes out // as a stored block larger than its input and fails compressedContainer's // did-it-compress guard (issue #235). real := compressedContainer(t, 1024) // 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)) } }