Repository navigation
Hold an envelope's claimed length to the bytes held before it is priced (#347) - #349
Merged
Merged
Conversation
… 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-byteTopKpayload 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.
PersistenceFormat.Readchecks an ordinary envelope's length against what a seekable stream has left after the header, with the checksum, beforeEnvelopeCostis priced or charged. A byte array is read through aMemoryStream, which can seek.PriceNestedchecks a nested envelope's length against its run before pricing the envelope. The nested read reads from aMemoryStreamover the run, so (1) holds it too, and the price and the read stop in the same place.RequireHeld, makes both checks. The large form's existing check (ReadLarge) now calls it too, with its message unchanged.ReadandPriceNestedsay 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:StructureId, in the ordinary envelope, as a byte array and from a seekable stream;Array.MaxLength;TestEveryLargeFormPayloadClaimingMoreThanItsStreamHoldsIsRefusedBeforeItIsPriced: the same in the large form, which already had this check (green on arrival).TestAPayloadFromAStreamThatCannotSeekIsHeldToItsBudgetInstead: from a stream that can't seek,Array.MaxLengthis refused for the default budget.TestANestedEnvelopeClaimingMoreThanItsRunHoldsIsRefusedBeforeItIsPriced: its roster is every structure whose sample holds a nested envelope, checked against the structures whosePricecallsPriceNestedin IL (TopKandScalableBloomFilter).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
6e2e5ecplus the test file:TopKallocated 130,736 bytes doing it.Array.MaxLengthwas refused for its budget, asking 2,147,603,592 bytes forBloomFilter.TopK's L + 1 claim was priced 370,360 bytes past a claim refused at the header, and its read allocated 348,592.Array.MaxLengthwas priced 2,147,483,752 bytes past it beforeSlicerefused it.Changed:
TestPayloadWriterGrowth'sTestALengthOfExactlyTheLargestArrayIsRefusedForItsCostNotItsSizepinned the old budget refusal for a held payload. It's there to show thatCheckHeaderlets 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, againstTestHeldEnvelopeLengthsalone: 6 mutants, all killed, by 33 to 101 tests each. The boundary control is what killsheld < 4made<=; before it, that mutant survived this class. The table is in52b5698's message.Merging with #345
#345's
TestANestedEnvelopeClaimingMoreThanItsRunIsRefusedAsDataexpectsSlice's message for a nested claim of one byte past its run. With this change,PriceNestedrefuses 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
-warnaserror: exit 0.52b5698: 2,625 passed, 0 failed, run by the author and again by the coordinator in a clean clone.🤖 Generated with Claude Code
https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx