Skip to content

Hold an envelope's claimed length to the bytes held before it is priced (#347) - #349

Merged
mattlorimor merged 6 commits into
masterfrom
fix/347-held-envelope-lengths
Nov 6, 2026
Merged

mattlorimor merged 6 commits into
masterfrom
fix/347-held-envelope-lengths

Conversation

@mattlorimor

Copy link
Copy Markdown
Owner

Fixes #347.

Matt's decision on #347 was "Check held lengths". An envelope's claimed length is now checked against the bytes actually held, before anything is priced or allocated. A short payload with an inflated header is refused as malformed (InvalidDataException) under any budget, as C16's rule asks. Before, a held 128,827-byte TopK payload claiming 2^30 bytes allocated about 1.07 GB with no budget, and under the default budget it was refused for its budget.

The change

All four points follow the coordinator's plan on #347.

  1. Top level. PersistenceFormat.Read checks an ordinary envelope's length against what a seekable stream has left after the header, with the checksum, before EnvelopeCost is priced or charged. A byte array is read through a MemoryStream, which can seek.
  2. Nested. For a held payload, PriceNested checks a nested envelope's length against its run before pricing the envelope. The nested read reads from a MemoryStream over the run, so (1) holds it too, and the price and the read stop in the same place.
  3. One helper, RequireHeld, makes both checks. The large form's existing check (ReadLarge) now calls it too, with its message unchanged.
  4. A stream that can't seek is still held by the budget, since its length isn't known. The comments in Read and PriceNested say so. The comment that called the old behaviour intended now says it of that stream. FORMAT.md's limits and read-budget sections say so too.

The check refuses when fewer bytes than the checksum's four follow the header, before it compares what's left with the claim. That keeps the unsigned subtraction from wrapping.

Tests

New file TestHeldEnvelopeLengths.cs, in #340's and #345's edited-claim pattern. One field of a payload the library wrote is edited, and the checksums are resealed, so the length is the only lie.

  • TestEveryHeldPayloadClaimingMoreThanItHoldsIsRefusedBeforeItIsPriced:
    • covers every StructureId, in the ordinary envelope, as a byte array and from a seekable stream;
    • claims are one past what the bytes hold, and Array.MaxLength;
    • each is refused for the bytes it lacks under no budget, the default and its own price, having allocated no more than a claim refused at the header, plus 1 KiB;
    • controls: the length the bytes hold reads, including from a stream with 64 more bytes after the envelope.
  • TestEveryLargeFormPayloadClaimingMoreThanItsStreamHoldsIsRefusedBeforeItIsPriced: the same in the large form, which already had this check (green on arrival).
  • TestAPayloadFromAStreamThatCannotSeekIsHeldToItsBudgetInstead: from a stream that can't seek, Array.MaxLength is refused for the default budget.
  • TestANestedEnvelopeClaimingMoreThanItsRunHoldsIsRefusedBeforeItIsPriced: its roster is every structure whose sample holds a nested envelope, checked against the structures whose Price calls PriceNested in IL (TopK and ScalableBloomFilter).
  • TestEveryPayloadCutShortOfItsChecksumIsRefusedBeforeItIsPriced: each payload cut to 0 and to 3 bytes after the header, which reaches the checksum half of the check. A boundary control, an empty payload with its 4 checksum bytes, must not be refused as short.

Red, on 6e2e5ec plus the test file:

  • Ordinary envelope: all 33 structures fail. A claim of L + 1 was read into and refused as "The stream ended after 3 of the 4 bytes the checksum needs". TopK allocated 130,736 bytes doing it. Array.MaxLength was refused for its budget, asking 2,147,603,592 bytes for BloomFilter.
  • Nested: TopK's L + 1 claim was priced 370,360 bytes past a claim refused at the header, and its read allocated 348,592. Array.MaxLength was priced 2,147,483,752 bytes past it before Slice refused it.
  • Cut short of the checksum: all 33 fail in the ordinary envelope.

Changed: TestPayloadWriterGrowth's TestALengthOfExactlyTheLargestArrayIsRefusedForItsCostNotItsSize pinned the old budget refusal for a held payload. It's there to show that CheckHeader lets exactly the largest array through. It now reads that payload from a stream that can't seek, where the length still reaches the default budget, and held, where it gets past its size and is refused as too few bytes.

Mutation of RequireHeld, against TestHeldEnvelopeLengths alone: 6 mutants, all killed, by 33 to 101 tests each. The boundary control is what kills held < 4 made <=; before it, that mutant survived this class. The table is in 52b5698's message.

Merging with #345

#345's TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData expects Slice's message for a nested claim of one byte past its run. With this change, PriceNested refuses earlier, with the held-check message. The claim is still refused as data under every budget, and only the expected message changes. The two merge cleanly as text. The author found this by reading the code, and has the one-line change ready for when master has #345.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx

mattlorimor and others added 4 commits November 6, 2026 04:01
… priced (red, #347)

New file, TestHeldEnvelopeLengths. The pattern is the edited claim of #340
and #344: one field of a payload this library wrote, edited, with the
checksums resealed so that the length is the only lie. The claims are one
past what the bytes hold with the checksum, which is where the check is
decided, and the most each form's length field is let claim (the ordinary
envelope's Array.MaxLength, the large form's long.MaxValue). Each read is
held to a refusal for the bytes it lacks, not for its budget, under a
budget of its own price, the default one and none, tightest first, having
allocated no more than the read of a claim one past what the field may
claim, which the header's check refuses just before the new one, also with
a formatted message, and 1 KiB besides. A claim past what a no-GC region
counts is read no further once it shows a problem. The lengths the bytes
hold are the controls.

- TestEveryHeldPayloadClaimingMoreThanItHoldsIsRefusedBeforeItIsPriced:
  every StructureId's TestReadBudget sample, ordinary envelope, read as a
  byte array and from a seekable stream positioned past 64 bytes. Nothing
  is priced before the claim is refused, so its own price is 0. The
  control: the claim the bytes hold reads, from either, and from a stream
  with 64 bytes more after the envelope.
- TestEveryLargeFormPayloadClaimingMoreThanItsStreamHoldsIsRefusedBeforeItIsPriced:
  the same in the large form, which already checks a seekable stream.
- TestAPayloadFromAStreamThatCannotSeekIsHeldToItsBudgetInstead: from a
  stream that cannot seek, Array.MaxLength is refused for the default
  budget, a byte more than held where the stream ends, and the whole reads.
- TestANestedEnvelopeClaimingMoreThanItsRunHoldsIsRefusedBeforeItIsPriced:
  the roster is every StructureId whose sample holds a run that is one
  whole ordinary envelope, compared with the structures whose Price's IL
  calls PriceNested (TopK and ScalableBloomFilter, both ways). The first
  nested envelope's length is edited. The price must stop with the check's
  refusal having priced exactly what it does for a nested claim past one
  array, which the nested header's check refuses.

Red, measured on this commit (6e2e5ec plus this file):

- Ordinary envelope: fails for all 33 structures, at 8 checks each. Both
  readers, claim L+1: refused for a budget of 0, and under the default
  budget and none read into and refused as "The stream ended after 3 of
  the 4 bytes the checksum needs". TopK (the issue's 128,827-byte payload)
  allocated 130,736 bytes as a byte array and 130,120 from the stream.
  Claim Array.MaxLength: refused for a budget of 0, asking 2,147,603,592
  for BloomFilter. No control failed.
- Nested: fails at 10 checks. TopK at payload offset 12: L+1 (108,794) is
  priced 370,360 bytes past a claim past the field, and walks to its end;
  Array.MaxLength is priced 2,147,483,752 past it before the slice refuses
  it; the L+1 read, under its own price (608,320), the default and none, is
  refused as the checksum's stream ending, having allocated 348,592.
  ScalableBloomFilter at 44: likewise, 262,800 and 2,147,483,752 priced,
  94,024 allocated.
- Green on arrival, as expected: the large form for all 33 (its check
  exists; it is shown red by mutation), and the non-seekable stream pin,
  which states what FORMAT.md will say stays guarded by the budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
Matt's Decision on #347, "Check held lengths", as the coordinator planned
it:

1. A held payload's claimed length is checked against the bytes held,
   before anything is priced or allocated. PersistenceFormat.Read checks
   an ordinary envelope's length against what a stream that can seek has
   left after the header, with the checksum, before EnvelopeCost is
   priced or charged. A byte array is read through a MemoryStream, which
   can seek.
2. A nested envelope's length is checked against its run: PriceNested,
   for a held payload, checks it before it prices EnvelopeCost (or the
   large form's cost). The nested read is held by (1), since it reads
   from a MemoryStream over the run, so price and read stop at one place.
3. Both are refused as InvalidDataException, saying the data is
   truncated, under any budget. The check is one helper, RequireHeld,
   which the large form's existing check (ReadLarge) now calls too, with
   its message unchanged.
4. A stream that cannot seek is still held by the budget, since its
   length is not known. The comments in Read and PriceNested say so, the
   one that called the old behaviour intended ("a header claiming two
   gigabytes is refused on that claim") now says it for that stream, and
   FORMAT.md's limits and read-budget sections say so.

One test pinned the old behaviour, and is changed to the new:
TestPayloadWriterGrowth's TestALengthOfExactlyTheLargestArrayIsRefusedForItsCostNotItsSize
read a held BloomFilter claiming Array.MaxLength and asked for a refusal
for its budget. What it is for is that CheckHeader lets exactly the
largest array through. It now reads that payload from a stream that
cannot seek, where the length still reaches the default budget, and held,
where it gets past its size and is refused as too few bytes. On a146da9
before this change it failed the full suite (2,591 of 2,592 passed).

The streamed path, a nested envelope inside the large form, is
unchanged: it prices nothing by the length, and its slice already holds
the length to the run.

Green: TestHeldEnvelopeLengths, all 68 rows, after the red commit before
this one failed 34 of them. With the bound's 1 KiB taken away, the
refusals of the edited claims measured 136 to 528 bytes past the read of
a claim past the field (their baseline), so the bound has room of at
least 496.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
…347)

The check the fix added refuses a length when the bytes after the header
are fewer than the checksum's four, before it compares what is left with
the claim. That half is what keeps the subtraction from wrapping: as an
unsigned number, three bytes less four is more than any claim, so a check
of the comparison alone lets every claim through. No test reached it,
since every edited claim kept the checksum's bytes.

TestEveryPayloadCutShortOfItsChecksumIsRefusedBeforeItIsPriced, every
StructureId in both forms, as a byte array and from a seekable stream
positioned past 64 bytes: each payload cut to its header and 0 bytes
after it, and to 3, claiming nothing and its writer's length (the large
header's own checksum resealed). Each is held, as in the tests before it,
to a refusal for the bytes it lacks under a budget of 0, the default and
none, having allocated no more than a claim past the field and 1 KiB.

Red against 6e2e5ec's PersistenceFormat.cs, put in this tree for the run
and taken out again: all 33 structures fail, at 24 checks each, every one
in the ordinary envelope: refused for a budget of 0, and under the default
and none refused as "The stream ended after 0 (or 3) of the 4 bytes the
checksum needs", TopK's claim of its 128,827 bytes cut to 3 after the
header allocating 130,696. The large form passes there, since its check
had this half already. Green on this commit's library. The mutation that
drops the half is in the table of the next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
TestEveryPayloadCutShortOfItsChecksumIsRefusedBeforeItIsPriced cuts a
payload to nothing after its header and to a byte short of its checksum,
and both must be refused for the bytes they lack. Nothing held the other
side of that boundary: in the mutation run on the fix, the guard
`held < ChecksumLength` made `held <= ChecksumLength` passed every test
in TestHeldEnvelopeLengths, and was caught only elsewhere in the suite,
by 34 tests that read whole payloads.

The control is the cut at the checksum's four bytes: an empty payload
and its checksum, resealed in either form, which holds what it claims.
It may be refused by its structure's reader for being empty, but not as
short of its checksum.

Mutation of RequireHeld through scripts/mutate.sh, against
TestHeldEnvelopeLengths alone, with the control (the earlier run's
verdict against the same tests without it beside each):

  mutant                                    with control  without
  H1  short guard dropped                   killed (33)   killed (33)
  H2  held < 4 made held <= 4               killed (33)   SURVIVED
  H2b held < 4 made held < 3                killed (33)   (not run)
  H3  held - 4 < length made <=             killed (101)  killed
  H5  one byte looser                       killed (67)   killed
  H6  one byte stricter                     killed (101)  killed

Measured (Release): TestHeldEnvelopeLengths passes, 101 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
mattlorimor and others added 2 commits November 6, 2026 07:56
Brings in #345 and #348 (2a4dfbc). CHANGELOG merged without conflict.
#345's TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData fails on
this merge, as expected: it pins Slice's refusal, and the price now
refuses first, in RequireHeld. The commit after this holds it to that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsData, from #345,
expected the price to refuse a nested envelope claiming more than its
run with Slice's message ("while reading N more at offset 14"). On the
merge of master it failed for TopK and ScalableBloomFilter, refused
instead by RequireHeld, which since #347 holds a nested envelope's
length to what its run holds before anything is priced.

RequireHeld counts the checksum as well, so the boundary the test held
moved. A claim of the run less its header was #345's control, held and
then refused by the nested read for its checksum; it is now refused by
the price, as is any claim past the writer's. So the claims are now the
coarsest pair at the new boundary: the writer's own, which the run holds
with its checksum and which has to read, and a byte more, which the
price and the read both have to refuse with RequireHeld's message, and
no other. The summary and remarks say so; they described the refusal as
coming from Slice.

Mutation through scripts/mutate.sh, against this test alone:

  N1  nested price: RequireHeld dropped            killed (1)
  N3  nested price: one byte looser                killed (1)
  N4  nested price: one byte stricter              killed (1)
  R1  read: RequireHeld dropped                    killed (1)
  H4  RequireHeld: checksum not counted            killed (1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A held envelope's claimed length is held only by the budget, not by the bytes held

1 participant