fix(corekit): size the width repair with a real ceiling, not a floored one (#599) - #600
Conversation
…d one (#599) `NumberField.pre_process` derives a width from the value when a field is packed while its `length` is still the `-1` placeholder. It derived it with `math.ceil(value.bit_length() // 8)`, and `math.ceil` of an integer is that integer -- the `//` had already floored the quotient, so the outer call did nothing and the expression was plain floor division. The width came out one octet short, which both `int.to_bytes` and `struct.pack` refuse. - Now `math.ceil(value.bit_length() / 8)`, matching the `math.ceil(n / 8)` idiom already used in `ipv6_route.py`, `hip.py` and `pcapng.py`. Float division is exact here: `bit_length()` would have to exceed 2**53 to lose a bit. - The reach is wider than the report's "just past an octet boundary". Floor division is wrong for *every* bit length that is not a multiple of eight, so `1` -- bit length 1, floored to zero octets -- failed too, and every value from 1 to 127 with it. Measured: `256` sized at 1, `65536` at 2, `16777216` at 3, `1` at 0. - #591's fix neither caused nor masked this, but it did change what the failure looks like. With `_need_process` now recomputed from the width in force, a mis-sized 1, 2 or 4 octets surfaces from `struct.pack` as `'B' format requires 0 <= number <= 255`, where pre-#591 it surfaced from `int.to_bytes` as `OverflowError`. The `OverflowError` named in #599 is therefore only what a mis-sized 3 octets still raises. - Reachability, which #599 leaves open: the repair runs only on a field the caller never resolved. `-1` is assigned in exactly one place, where a callable `length` is swapped for a placeholder, and `__call__` installs the real width; a field omitting `length` is refused with `IntError` instead. `Schema.pack` resolves every field before packing it, so this is reached through the field-level API, on `NumberField` or `EnumField` -- the subclasses that fix `__template__` keep `_need_process` false and never enter the branch. Deliberately not fixed, both independent of the arithmetic and neither made worse: an unresolved signed field is sized without room for its sign bit, so it still cannot pack `128`; and an unresolved field's `_bit_mask` is `-1`, which makes the masking and the sign remap at the top of `pre_process` no-ops. The identical `math.ceil(x.bit_length() // 8)` expression also survives at the ILNP nonce builders in `hopopt.py:1889` and `ipv6_opts.py:1892`, outside this change's files. New tests in tests/corekit/test_fields_numbers_width_repair.py proven to fail without the fix: 31 failures across 13 of 15 tests before, all 15 passing after. Each boundary gets its own method rather than one sweep, since the defect is a pattern and a single case would pass against a width-specific fix, and each is asserted as a pair so a width shifted the other way fails too. The 2 that pass either way are the coverage/contract cases -- the `IntError` refusal and the explicit `bit_length` branch. They take `numbers.py` from 93% to 100% statement and branch coverage. tests/corekit/ 149 passed, 239 subtests; tests/protocols/test_option_roundtrip_unit.py 6 passed, 358 subtests, with no `EXPECTED_FAILURES` entry flipped. No new mypy finding. Fixes #599
|
✅ GOOD TO MERGE — head |
Cross-review appendix — PR #600Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head
|
… own (#606) The stacklevel probes record with `simplefilter('always')`, which un-ignores the categories Python hides by default, then asserted their window held exactly one record and took the last of it. A `ResourceWarning` for a file handle an earlier test left for the collector therefore failed them, and did so unpredictably: the same failure was watched migrating between #596 and #600 nine minutes apart with neither branch touched and neither head moved, so what trips is decided by garbage-collection timing and collection order rather than by any test's code. It had reddened #577, #596 and #600 by then, none of which the test exercises. * `tests/utilities/test_stacklevel.py`: count the window's pcapkit warnings via a new `emissions()` helper and check the one found is the expected category, instead of requiring the probe's to be the only record there and picking it by position. Counting over `BaseWarning` rather than the exact category keeps what the old assertion did buy -- a second complaint from the package is still a failure -- and gives up only its sensitivity to warnings pcapkit never raised. Both probe helpers were affected, `warning_site()` and `emit()`. Adds a regression test that frames the probe with foreign warnings on both sides. * `tests/utilities/test_logging.py`: enter each `Extractor` as a context manager so `__exit__` closes the input file, removing the leaked `in.pcap` handle these three constructions were shedding into sibling modules' tests. `pytest tests/utilities/` 103 passed, and with `-W always::ResourceWarning` the unclosed-`in.pcap` warnings go from 2 to 0. The new test fails `3 != 1` without the fix. The leak's root cause is left alone deliberately: `Extractor._cleanup()` closes the handle only when the caller supplied the stream, never when pcapkit opened it, and that is production code wanting its own review. Fixes #606
Fixes #599
The defect
NumberField.pre_processderives a width from the value when a field is packed while itslengthis still the-1placeholder. It derived it withmath.ceilof anintis thatint— the//has already floored the quotient, so the outer call did nothing and the expression was plain floor division. The width came out one octet short, which bothint.to_bytesandstruct.packrefuse.Now
math.ceil(value.bit_length() / 8), matching themath.ceil(n / 8)idiom already used inipv6_route.py:253,hip.py:3156andpcapng.py:3530. Float division is exact for every input reachable here:bit_length()would have to exceed 2**53 to lose a bit, which is a value of some 10**15 bits.Re-located line. #598 rewrote this neighbourhood an hour before this change; the line the issue cites as
numbers.py:198isnumbers.py:232in the current tree.The reach is wider than the report says
#599 frames this as hitting values "just past an octet boundary", which reads as though small values were safe. They are not — floor division is wrong for every bit length that is not an exact multiple of eight. Measured on
13a75dfcd, CPython 3.14.7:1OverflowError: int too big to convert127OverflowError: int too big to convert255256struct.error: 'B' format requires 0 <= number <= 2556553565536struct.error: 'H' format requires 0 <= number <= 655351677721516777216OverflowError: int too big to convertSo every value from 1 to 127 failed too, and seven of every eight magnitudes.
Verifying #598's claim, and one correction
#598's author reported this defect and left it, stating that their fix neither fixes nor masks it. That holds — the sizes are byte-for-byte identical across the two versions and every boundary still fails. Reachability is unchanged too:
build_template(-1, …)takes the fall-through branch and sets_need_processtrue in both versions, so theif self._need_process and self._length < 0:guard is entered identically.But #598 did change what the failure looks like, which the issue's "Measured" block (taken on the pre-#598
fa6d18e31) does not reflect. With_need_processnow recomputed from the width in force, a mis-sized 1, 2 or 4 octets is returned as anintand refused bystruct.pack; pre-#598 the flag latched true and the same value was refused byint.to_bytesasOverflowError. Measured by restoring the pre-#598 latchingbuild_template:The
OverflowErrorin the issue title is therefore now only what a mis-sized 3 octets raises. This is why the tests here assert widths and octets rather than exception types.Reachability, which the issue leaves open
The repair runs only on a field the caller never resolved:
-1is assigned in exactly one place —field.py:542, where a non-integerlengthis swapped for a placeholder and stashed as_length_callback. A field omittinglengthnever becomes an unresolved one; it is refused at construction with the library's ownIntError.__call__installs the real width, so a resolved field never satisfies_length < 0. A callable returning a negative, or a staticlength=-1, does not survive__call__either — it raisesValueError: negative shift countcomputing the bit mask first._need_processmust also be true, which needs__template__unset. So it isNumberFieldandEnumField; the eightInt/UIntsubclasses keep_need_processfalse and never enter the branch.Schema.packresolves every field before packing it (schema.py:655, and:731for aConditionalField's inner field), so this is not reachable through a protocol. It is reached through the field-level API —NumberField(length=lambda pkt: 8).pack(value, {})with no interveningfield(packet), which is exactly what fix(corekit): recompute _need_process from the width in force, not once from the placeholder (#591) #598's owntest_an_unresolved_field_repairs_its_length_and_honours_the_new_templatedoes.Which is why the defect survived #591's suite. That test's five repair-path values —
0xFF,0xFFFF,0xFFFFFFFF,0xFFFFFFFFFFFFFFFF,0x800001— have bit lengths of 8, 16, 32, 64 and 24. Every one is an exact multiple of eight, precisely where floor division and the ceiling agree.Tests
New module
tests/corekit/test_fields_numbers_width_repair.py, 15 tests. Each boundary gets its own method rather than one parametrised sweep, because the defect is a pattern and a single case would pass against a fix that special-cased the reported width; standalone methods also keep each failure individually visible, since this pytest has nopytest-subtests. Each boundary is asserted as a pair — the value below it, which always packed, and the value above it, which did not — so a width shifted by one in the other direction fails too.Also: a sweep over all eight boundaries, the
ceil(bit_length / 8)invariant, the smallest mis-sized value being1, a pack/unpack round trip, the root-cause arithmetic (math.ceil(9 // 8) == 1vsmath.ceil(9 / 8) == 2), and controls for the bit lengths that divide by eight and for the reachability of the repair at all.Proven against the unfixed line — the fix reverted, exit code read from a file rather than the printed summary:
and with the fix in place:
13 of the 15 fail without the fix. The 2 that pass either way are the coverage/contract cases — the
IntErrorrefusal and the explicit-bit_lengthbranch — and they are what takes the file to full coverage.Coverage of
pcapkit/corekit/fields/numbers.py, scoped with--includeper the host-safety rule (never over the whole tree):tests/corekit/149 passed, 239 subtests.tests/protocols/test_option_roundtrip_unit.py6 passed, 358 subtests — noEXPECTED_FAILURESentry flipped, and that file is not touched. No new mypy finding innumbers.py(the one it reports, at:509, is pre-existing inEnumField.post_processand outside this diff).Found and deliberately not fixed
Three things, all independent of the arithmetic and none made worse here:
pack(128)on a signed field sizes 1 octet from bit length 8 and is refused with'b' format requires -128 <= number <= 127— before and after this change. The ceiling does improve signed sizing (-1and127go fromOverflowErrorto packing correctly), it just does not complete it._bit_maskis-1, so the masking and the sign remap at the top ofpre_processare both no-ops on this path. That is the underlying reason (1) is incoherent rather than merely off by one.pcapkit/protocols/internet/hopopt.py:1889andpcapkit/protocols/internet/ipv6_opts.py:1892, eachlen=math.ceil(nonce.bit_length() // 8)in the ILNP nonce option builder — compare the correct4 + math.ceil(random.bit_length() / 8)athip.py:3156. There is no ILNP entry inEXPECTED_FAILURES, so the roundtrip test is presumably landing on a nonce whose bit length divides by eight, the same blind spot as above. These want their own issue.Also noted:
value = 0sizes at zero octets and packs tob'', identically before and after. Left alone as a behaviour change beyond this issue.