Skip to content

fix(hip): size the SOLUTION parameter as two whole-octet fields (#608) - #629

Merged
JarryShaw merged 1 commit into
mainfrom
fix/608-hip-solution-even-width
Sep 22, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/608-hip-solution-even-width

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Fixes #608.

The defect, re-verified on current main

The issue was measured against 493020f83; re-measured here against 6c3d1b0d9
(and the branch is now rebased onto ead73b204), CPython 3.14.7, importing from
this worktree rather than the editable install:

MEASURED pcapkit.__file__ = .../.claude/worktrees/agent-a7bb66740e7a3542b/pcapkit/__init__.py
CPython 3.14.7

random=0x1 solution=0xfff   bits=12  len=7   want=8   -> REJECTED by its own reader (HIPv2: [ParamNo 321] invalid format)
random=0xff solution=0xff   bits=8   len=6   want=6   -> accepted
random=0x1 solution=0x1     bits=1   len=5   want=6   -> REJECTED by its own reader (HIPv2: [ParamNo 321] invalid format)
random=0x1ff solution=0x1ff bits=9   len=7   want=8   -> REJECTED by its own reader (HIPv2: [ParamNo 321] invalid format)
random=0xffffff ...         bits=24  len=10  want=10  -> accepted
random=0x0 solution=0x0     bits=0   len=4   want=4   -> accepted
random=0x1ffff solution=0x3 bits=17  len=9   want=10  -> REJECTED by its own reader (HIPv2: [ParamNo 321] invalid format)

The headline case reproduces exactly as filed. The reproduction also turned up a
second symptom the issue did not name: the undersized len does not merely
mis-state the parameter, it truncates the value. Both fields take their width
from len, so solution=0xfff was packed into the one octet len=7 allowed and
came back as 0xff, silently:

before: built 11 octets: 01 41 00 07 01 21 6f 70 01 ff 00      <- solution 0xfff packed as 0xff
after:  built 12 octets: 01 41 00 08 01 21 6f 70 00 01 0f ff   <- solution 0xfff intact

The RFC, actually fetched

Fetched https://www.rfc-editor.org/rfc/rfc7401.txt and
https://www.rfc-editor.org/rfc/rfc5201.txt. The section in the issue brief is
off by one
: RFC 5201 §5.2.4 is PUZZLE; SOLUTION is §5.2.5 in both RFCs.

RFC 7401 §5.2.5 (HIPv2), verbatim:

     Type                321
     Length              4 + RHASH_len / 4
     ...
     Random #I           random number of size RHASH_len bits
     Puzzle solution #J  random number of size RHASH_len bits

with the diagram's two payload fields labelled Random #I, n bytes and
Puzzle solution #J, RHASH_len / 8 bytes, and RHASH_len defined in §2.2 as
"the natural output length of RHASH in bits". RFC 5201 §5.2.5 (HIPv1) fixes
both at 8 bytes and Length at 20.

There is no numbered figure for either parameter. RFC 7401 has exactly two
numbered figures — Figure 1, the base-exchange diagram in §4.1, and Figure 2, the
simplified HIP state diagram in §4.4.4 — and RFC 5201 has none at all. The §5.2.x
packet diagrams are unnumbered inline ASCII art, so there is nothing to cite by
figure number.

Formula, before and after

expression at 12 bits
before 4 + math.ceil(max(random.bit_length(), solution.bit_length()) / 4) len=7
after 4 + 2 * math.ceil(max(random.bit_length(), solution.bit_length()) / 8) len=8

RHASH_len / 4 is twice RHASH_len / 8 — but only because RHASH_len is a
hash output length in bits and therefore a whole number of octets. Substituting an
arbitrary int.bit_length() breaks the identity, and ceil(b/4) != 2*ceil(b/8)
for every b that is not a multiple of 8. The sibling _make_param_puzzle already
uses the ceil(bits / 8) form; this brings SOLUTION in line with it, and the
result is even by construction so the reader's guard can never trip.

Why loosening the reader is the wrong fix

The obvious alternative is to drop or weaken _read_param_solution's
(schema.len - 4) % 2 guard. Three reasons that is wrong:

  1. An odd contents width is not representable. SolutionParameter sizes
    random and solution at (pkt['len'] - 4) // 2 each
    (pcapkit/protocols/schema/internet/hip.py:454, :456). With len - 4 odd
    those two halves sum to len - 5, leaving one octet of declared contents
    unaccounted for — the parameter is mis-framed, not merely unusual.
  2. The RFC defines no such record. Both fields are RHASH_len / 8 octets, so
    they are equal-width by definition and the RFC says nothing about which of the
    two would take an extra octet. A laxer reader would have to invent that
    framing, and two implementations would invent it differently.
  3. It would not fix the data loss. The truncation above is caused by the
    undersized len, not by the parity check. Accepting len=7 would still
    have written 0xff for 0xfff. Only a correct len fixes both symptoms.

The builder is the side that is wrong, so the builder is what changed. The guard
and the schema's // 2 sizing are untouched.

Failing, then passing

Both runs import from a tree whose pcapkit.__file__ is printed and asserted, so
the editable install's MetaPathFinder cannot substitute the primary checkout.
The "before" run is an immutable git archive export of the base commit with only
the new test file overlaid; sha256sum of pcapkit/protocols/internet/hip.py,
pcapkit/protocols/schema/internet/hip.py and the test file was taken before
and after
each run and was identical each time
(fb542eb639acfeab… / b56203d2a044e69a… / ce9d80c439321914…).

Before (/tmp/hip608/before, git archive 6c3d1b0d9, unfixed hip.py):

=========================== short test summary info ============================
SUBFAILED(random=1, solution=1, bits=1) tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields
SUBFAILED(random=1, solution=511, bits=9) tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields
SUBFAILED(random=1, solution=4095, bits=12) tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields
SUBFAILED(random=131071, solution=3, bits=17) tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields
SUBFAILED(random=33554431, solution=0, bits=25) tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields
FAILED tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_at_57_bits_stays_legal_for_hipv1
========== 6 failed, 1 passed, 1 warning, 3 subtests passed in 1.63s ===========
[run.py] pytest exit code = 1

with AssertionError: 7 != 8 on the headline case and AssertionError: 19 != 20
on the HIPv1 case. Exit code read from a file, not a pipeline:
cat /tmp/hip608/exitcode.txt1.

After (this branch):

tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_length_is_two_whole_octet_fields PASSED [ 50%]
tests/protocols/internet/test_hip_unit.py::HIPUnitTests::test_hip_solution_parameter_at_57_bits_stays_legal_for_hipv1 PASSED [100%]

===================== 2 passed, 8 subtests passed in 1.35s =====================
[run.py] pytest exit code = 0

cat /tmp/hip608/exitcode.txt0.

Note which subtests failed: exactly the five non-byte-aligned widths, with
the three byte-aligned controls passing on the unfixed tree. That asymmetry is
the test discriminating, visible in the output itself.

Why these inputs discriminate

Four plausible expressions for the field-pair width, evaluated at each case's bit
length. The correct one is 2 * ceil(b/8); the others are the defect
(ceil(b/4)), a floor variant (2 * floor(b/8)), and one field's worth instead
of two (ceil(b/8)):

bits correct defect /4 2*floor(/8) ceil(/8) discriminates?
1 2 1 0 1 yes, all three
8 2 2 2 1 control
9 4 3 2 2 yes, all three
12 4 3 2 2 yes, all three
16 4 4 4 2 control
17 6 5 4 3 yes, all three
24 6 6 6 3 control
25 8 7 6 4 yes, all three

Five of the eight widths are deliberately not multiples of eight, which is the
whole point: at a multiple of eight the defect coincides with the correct width,
which is why every fixture that ever reached this builder passed through it
unharmed. The 8/16/24-bit cases are kept as controls, asserting the repair changes
nothing on the path that already worked.

The HIPv1 case uses 57 bits, not 64, for the same reason: 4 + ceil(64/4) and
4 + 2*ceil(64/8) are both 20, so a full-width 64-bit value would pass either
way. At 57 bits the defect gives 19, which fails both the HIPv1 len != 20 check
and the parity check, while the correct formula gives the 20 RFC 5201 §5.2.5
requires. A 57-bit value is an ordinary 8-octet field with seven leading zero bits.

EXPECTED_FAILURES in tests/protocols/test_option_roundtrip_unit.py was
imported, not grepped (it is built by ** unpacking): 45 entries, four
HIP-related, and neither SOLUTION nor PUZZLE among them. So there is no entry
to remove, and the round-trip suite stays green in both directions.

Coverage — which axis carries the evidence

Coverage cannot see this change, and the numbers say so:

BASELINE (git archive 6c3d1b0d9, pristine tests):
pcapkit/protocols/internet/hip.py            895      0    292      0   100%
pcapkit/protocols/schema/internet/hip.py     372      1     24      1    99%   216

AFTER (this branch):
pcapkit/protocols/internet/hip.py            895      0    292      0   100%
pcapkit/protocols/schema/internet/hip.py     372      1     24      1    99%   216

hip.py was already at 100% statement and 100% branch coverage. The changed
line was already executed by the existing _make_param_solution tests — they just
never checked the value it produced. Saturated before, saturated after: coverage
cannot go backwards, and it cannot go forwards either.

The subtest count is the meaningful axis. tests/protocols/internet/test_hip_unit.py
alone: 22 tests / 68 subtests → 24 tests / 76 subtests.

Regression runs

All scoped to the modules this change touches — the full suite was deliberately
not run (it has reached 41 GB RSS in a worktree on this host). Exit codes read
from a file.

suite result
test_hip_unit.py + test_option_roundtrip_unit.py + test_docstring_contract.py + tests/project 133 passed, 915 subtests passed, exit 0
+ test_tier_guard.py (pre-rebase) 158 passed, 934 subtests passed, exit 0
util/changelog_md.py --check in step, exit 0

tests/project/test_changelog_md.py is the changelog gate and is in that run.
CHANGELOG.md was regenerated with python util/changelog_md.py, never hand-edited.
The rebase onto ead73b204 merged cleanly and kept #628's bullet ahead of mine.

Found, and deliberately not fixed

The issue records two further hip.py claims as uninvestigated. Both are real —
I verified them while measuring this one — and both are left alone
, because each
is a distinct defect with its own blast radius:

  1. Padding is computed from the contents length, not the record length.
    RFC 7401 §5.2.1, verbatim: "All of the encoded TLV parameters have a length
    (that includes the Type and Length fields), which is a multiple of 8 bytes."
    Every parameter schema in pcapkit/protocols/schema/internet/hip.py pads with
    (8 - (pkt['len'] % 8)) % 8, which aligns len rather than len + 4. So a
    SOLUTION with len=8 emits a 12-octet record where the RFC requires 16.
    pcapkit round-trips it fine because its writer and reader share the wrong
    formula; a real HIP peer would not. This affects every HIP parameter, not
    just SOLUTION, and correcting it changes the bytes of every HIP fixture — far
    too wide to ride along with a one-line length fix.

  2. Re-serialising a parsed parameter loses leading zero octets. Given
    param=, both _make_param_puzzle and _make_param_solution recompute the
    width from param.random.bit_length(), and Data_SolutionParameter carries no
    field width to recover the original from. An 8-octet Random #I of 0x01 reads
    back as 1 and re-emits in one octet. My fix does not make this worse (it
    rounds up, never down), but it does not fix it either: doing so means
    threading the on-wire width through the data model, which is a design change.

  3. A third one, not in the issue at all, found while measuring this. RFC 7401
    §5.2.5 names the SOLUTION parameter's second octet Reserved — "zero when sent,
    ignored when received" — but SolutionParameter names it lifetime and both
    sides round-trip it as a 2**(v-32) second duration, which is PUZZLE's
    semantics (§5.2.4), not SOLUTION's. Two consequences, both measured:

    lifetime=  1 -> reserved octet on wire = 0x20  (RFC says it MUST be 0x00)
    lifetime=  2 -> reserved octet on wire = 0x21  (RFC says it MUST be 0x00)
    lifetime= 32 -> reserved octet on wire = 0x25  (RFC says it MUST be 0x00)
    default lifetime=0 -> ValueError: expected a positive input
    wire Reserved=0x00 parses to lifetime=datetime.timedelta(0)
    

    So the builder cannot emit the zero the RFC mandates, and asking it to — which is
    what its own default lifetime=0 does — escapes a bare ValueError out of
    math.log2(0) rather than an exception from pcapkit.utilities.exceptions. The
    same log2(lifetime) sits in _make_param_puzzle, where Lifetime is a real
    field, so the bare-ValueError half is shared and not SOLUTION-specific. Left
    alone: it is a semantics and error-handling change across two builders, and
    nothing about it depends on or is fixed by the length arithmetic here.

  4. A fourth, raised by the cross-review and reproduced here. Neither
    _make_param_solution nor _make_param_puzzle consults version when sizing,
    so under HIPv1 — where RFC 5201 §5.2.4/§5.2.5 fix the fields at 8 octets each and
    Length at 12 and 20 respectively — any value narrower than the full field builds
    a parameter its own reader rejects. That is HIP SOLUTION builder sizes with ceil(bits/4), emitting a parameter its own reader rejects #608's exact symptom in a second
    guise. Measured on both trees, version=1:

    SOLUTION (want len=20)          base        this PR
      bits=1                        len=5  X    len=6  X
      bits=8                        len=6  X    len=6  X
      bits=32                       len=12 X    len=12 X
      bits=56                       len=18 X    len=18 X
      bits=57                       len=19 X    len=20 OK
      bits=64                       len=20 OK   len=20 OK
    
    PUZZLE (want len=12)            base        this PR
      bits=1 / 8 / 56               len=5/5/11 X  (unchanged -- builder untouched)
      bits=64                       len=12 OK     len=12 OK
    

    Pre-existing, not a regression, and not made worse: this PR rounds up, so it
    fixes the 57-bit row (19 → 20) and changes nothing else. Fixing it properly means
    the builders sizing to a fixed 8 octets when version == 1 rather than to
    bit_length(), which is a behaviour change in two builders and belongs with the
    PUZZLE builder this PR deliberately does not touch. The 57-bit test here pins the
    one HIPv1 row that this change does move; it does not claim general HIPv1
    correctness.

Also noted, not a defect: the round-trip generator's SOLUTION case
(examples/generators/options.py:977) passes only {'lifetime': 1}, so random
and solution default to 0 and the case exercises this formula at zero bits
— maximally non-discriminating. It is left as-is because examples/ is outside
this change's scope and altering it would regenerate committed fixtures.

`_make_param_solution` declared `len = 4 + ceil(max(bits) / 4)`. RFC 7401
section 5.2.5 spells the SOLUTION parameter's Length `4 + RHASH_len / 4`, but
over a `Random #I` and a `Puzzle solution #J` of `RHASH_len / 8` octets *each* --
`/ 4` is twice `/ 8`, an identity that holds only because `RHASH_len` is a whole
number of octets. Applied to an arbitrary `int.bit_length()` it breaks, and the
builder emitted an odd contents width that `_read_param_solution`'s
`(len - 4) % 2` guard rejects, because `SolutionParameter` splits that width into
two equal `(len - 4) // 2` halves. `random=0x1, solution=0xfff` declared `len=7`
and raised `ProtocolError: HIPv2: [ParamNo 321] invalid format` on the library's
own output; the same undersized length also truncated `solution=0xfff` to `0xff`
on the wire, silently.

- `len = 4 + 2 * math.ceil(max(...) / 8)`, matching the sibling PUZZLE builder
  and even by construction, so the reader's guard can never trip. Loosening the
  reader instead was rejected: an odd contents width has no meaning in a format
  whose two fields are equal-width by definition.
- Record on both sites why the RFC's `/ 4` shorthand must not be reused on a
  width that is not a whole number of octets.
- Eight-width regression coverage in `tests/protocols/internet/test_hip_unit.py`,
  five widths deliberately not multiples of eight, plus a 57-bit case pinning the
  20-octet length RFC 5201 section 5.2.5 requires of HIPv1.

Fixes #608

Verified on CPython 3.14.7: the new tests fail 6/1 (5 SUBFAILED + the HIPv1 case)
against an unmodified snapshot of the base commit and pass 2/2 with 8 subtests
after. `test_hip_unit.py` goes 22 tests / 68 subtests -> 24 / 76; `hip.py` was
already at 100% statement and branch coverage before and after, so the subtest
count is the axis that carries the evidence. `test_option_roundtrip_unit.py`,
`test_docstring_contract.py`, `test_tier_guard.py` and `tests/project` all green.
@JarryShaw

Copy link
Copy Markdown
Owner Author

GOOD TO GO

Independent cross-review, per the standing rule that an agent-raised change is
reviewed by a different model. This change was authored by Claude Opus 5; the
review below was produced by Claude Sonnet, briefed to falsify rather than to
bless — a verdict per claim, from evidence it obtained itself, with disagreements
treated as the valuable output. No model substitution was needed; the intended model
ran.

It built its own checkouts (git archive origin/fix/608-hip-solution-even-width and
git archive 6c3d1b0d9) and wrote its own import wrapper rather than reusing mine, so
the measurement harness is not shared with the authoring side. It confirmed the branch
is fully rebased (merge-base with origin/main = ead73b204). Worth recording: it
found the editable finder keeps MAPPING as a module global, not a class or
instance attribute, so a naive getattr(finder, 'MAPPING') misses it — it had to reach
through sys.modules[finder.__module__]. Mine matched on the module-level attribute
and worked, but that is a real trap for the next person.

Verdicts

# Claim Verdict
1 4 + 2*ceil(b/8) is the RFC-correct length; SOLUTION is §5.2.5 in both RFCs CONFIRMED (one slip, below)
2 The builder, not the reader, is the right side to fix CONFIRMED
3 The undersized len silently truncated the value; exact octets CONFIRMED, byte-for-byte
4 Every cell of the discrimination table CONFIRMED, recomputed independently
5 The tests fail before and pass after, in the claimed shape CONFIRMED
6 Round-trip suite green; no SOLUTION/PUZZLE in EXPECTED_FAILURES CONFIRMED
7 No edge case broken by the change CONFIRMED + one new pre-existing finding
8 The two deliberately-unfixed defects are real, not invented caveats CONFIRMED, both reproduced
9 House rules, commit, Fixes #608, changelog gate CONFIRMED

It fetched both RFCs itself and matched §5.2.5 verbatim in each, confirmed
schema/internet/hip.py:454/:456 are the two (pkt['len'] - 4) // 2 fields, and
reproduced the truncation octets exactly: base 01 41 00 07 01 21 6f 70 01 ff 00,
PR 01 41 00 08 01 21 6f 70 00 01 0f ff. Its base-tree hashes matched the three
quoted in the description (fb542eb639acfeab… / b56203d2a044e69a… /
ce9d80c439321914…), the failing run reproduced as 6 failed, 1 passed, 3 subtests passed exit 1 with SUBFAILs at exactly bits 1/9/12/17/25, the passing run as
2 passed, 8 subtests passed exit 0, and the full module as 24 passed, 76 subtests.
EXPECTED_FAILURES imported: 45 entries, 4 HIP-related, no SOLUTION or PUZZLE.
Round-trip suite 6 passed, 358 subtests passed exit 0.

It also attacked the "round both fields independently" alternative I had not
considered, and rejected it on its own grounds: the RFC ties both fields to the same
RHASH_len, not to each value's magnitude, so independent rounding would emit a record
matching no real hash width.

It disputed two things. Both were checked here and both stand.

1. A factual error in my description — accepted and fixed. I described RFC 7401's
Figure 2 as "the header layout". It is not: §4.4.4 line 1925 reads "The following
diagram (Figure 2) shows the major state transitions", and Figure 1 is the
base-exchange diagram in §4.1. Verified directly against the fetched RFC text. The
substantive claim — that neither RFC numbers a figure for PUZZLE or SOLUTION — is
unaffected, but the parenthetical was wrong and the description has been corrected.

2. A fourth latent defect I had not found. Neither builder consults version when
sizing, so under HIPv1 any value narrower than the RFC's fixed 8-octet field builds a
parameter its own reader rejects — #608's symptom in a second guise. Reproduced here
independently on both trees:

SOLUTION, version=1 (RFC 5201 §5.2.5 requires len=20)     base       this PR
  bits=1                                                  len=5  X   len=6  X
  bits=56                                                 len=18 X   len=18 X
  bits=57                                                 len=19 X   len=20 OK
  bits=64                                                 len=20 OK  len=20 OK

PUZZLE, version=1 (RFC 5201 §5.2.4 requires len=12)       base       this PR
  bits=1 / 8 / 56                                         len=5/5/11 X  (unchanged)
  bits=64                                                 len=12 OK     len=12 OK

Pre-existing, present identically in the untouched PUZZLE builder, and not made worse
here
— this PR rounds up, so it fixes the 57-bit row and moves nothing else. It is now
recorded in the description's "found, and deliberately not fixed" section rather than
folded away, and the cross-review's suggestion that it be spun out as its own issue —
the way #608 itself was spun out of #601 — is the right call.

Neither disputed point changes the verdict: the load-bearing claims are 3 and 5, and
both were reproduced byte-for-byte and shape-for-shape on an independently built tree.

@JarryShaw
JarryShaw merged commit a62aed1 into main Sep 22, 2026
25 checks passed
@JarryShaw
JarryShaw deleted the fix/608-hip-solution-even-width branch September 22, 2026 15:33
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…t values (#653, #654, #655)

`_make_param_puzzle` and `_make_param_solution` derived three wire-format
quantities from the payload value rather than taking them from the data model.
All three derivations were wrong, in the same two functions, and they are fixed
together because the width resolution is one expression that cannot be written
twice.

* The field width came from `int.bit_length()` and nothing else, so every
  leading zero octet was dropped on re-serialisation. A SOLUTION read with
  `Length = 20` rebuilt as `Length = 6`, a PUZZLE read with `Length = 12` as
  `Length = 5` -- silently, since the integers survive and nothing raises. Both
  data models now carry `rhash_len`, the field's on-wire width in bits, and both
  builders prefer it. This only became reachable end to end once #608 was fixed
  (#629); before that the undersized rebuild tripped the reader's parity guard
  first and failed loudly. (#653)
* SOLUTION's second contents octet is `Reserved`, "zero when sent, ignored when
  received" (RFC 7401 5.2.5, RFC 5201 5.2.5 identically) -- not a PUZZLE
  `Lifetime`, which only RFC 7401 5.2.4 defines. pcapkit wrote `0x20`, `0x21`,
  `0x25` or `0x2b` there and could not write the mandated zero at all: a
  conformant SOLUTION with `Reserved = 0x00` parsed to `timedelta(0)` and then
  escaped a bare `ValueError` from `math.log2(0.0)`. `Data_SolutionParameter`
  and `SolutionParameter` now carry `reserved` verbatim, so the conformant zero
  round-trips and a received non-zero octet is reproduced rather than
  re-derived. (#654)
* Neither builder read its own `version` keyword, so `version=1` and
  `version=2` computed identical lengths at every bit width. RFC 5201 5.2.4 and
  5.2.5 state both fields as literally 8 bytes and `Length` as literally 12 and
  20, and the readers enforce exactly that -- so under HIPv1 each builder
  accepted only a `bit_length()` of 57..64 and built, for everything else, a
  parameter this library's own reader rejects. Width now comes from the version
  under HIPv1. (#655)

The remaining `math.log2` sites, both in PUZZLE where a `Lifetime` really
lives, now raise `ProtocolError` rather than letting `ValueError` escape.
`ProtocolError(BaseError, ValueError)` is what the readers already raise for a
malformed PUZZLE or SOLUTION, and deriving from the builtin it replaces keeps
any caller written around today's bare `ValueError` working; `EnumError` is
`(BaseError, TypeError)` and would silently stop being caught. The same guard
covers the upper end, because `UInt8Field` wraps rather than raising --
measured, `300` packs as `0x2c` -- so an out-of-range lifetime would otherwise
be written as some other valid-looking duration. A plain `float` lifetime used
to escape an `AttributeError` from the `isinstance(lifetime, int)` branch; the
test keyed on `timedelta` instead found that.

`_make_param_solution` no longer accepts `lifetime=`. `reserved=` and
`rhash_len=` are both `None`-sentinelled, so an explicit value overrides what a
parsed parameter carries: `Data_SolutionParameter` is immutable, so without that
a caller holding a parsed parameter had no way to write the conformant zero over
a peer's non-conformant `Reserved`. The plain data fields still let `param` win,
as they did before. `rhash_len=` is also the only way a from-scratch HIPv2 build
can state a width, which genuinely varies with the Responder's HIT Suite
(RFC 7401 2.3, 5.2.10).

Five new tests, each verified to fail on 0c7f2b7 for the defect's own reason
and pass here: `12 != 5` and friends for #655, `expected a positive input` and
`'reserved' not found` for #654, `no attribute 'rhash_len'` for #653. The
widths exercised are 1, 9, 15, 17, 57 and 65 bits, where the candidate formulas
disagree, with the byte-aligned widths kept as controls -- at a multiple of 8
the correct `2 * ceil(b / 8)` coincides with #608's `ceil(b / 4)`, which is why
every byte-aligned fixture passed through that defect unharmed.

Two cases are deliberately accepted rather than rejected, and now say so in
`_make_puzzle_field_width`'s docstring: `rhash_len == 0`, which is what the
derived path yields for the default `random=0` and what a `Length = 4`
parameter parses back to; and a `version` other than 1, which is treated as
HIPv2 exactly as both readers' `version == 1` guards do.

Coverage holds at 100% statement and branch on all three changed modules, with
statements 1385 -> 1414 and branches 316 -> 338; 30 tests / 434 subtests ->
35 / 455. Scoped unit tier green: 291 passed, 1191 subtests. No
EXPECTED_FAILURES entry moved -- still 45, with the same four HIP entries.

No changelog entry: that is consolidated in #657.
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…t values (#653, #654, #655)

`_make_param_puzzle` and `_make_param_solution` derived three wire-format
quantities from the payload value rather than taking them from the data model.
All three derivations were wrong, in the same two functions, and they are fixed
together because the width resolution is one expression that cannot be written
twice.

* The field width came from `int.bit_length()` and nothing else, so every
  leading zero octet was dropped on re-serialisation. A SOLUTION read with
  `Length = 20` rebuilt as `Length = 6`, a PUZZLE read with `Length = 12` as
  `Length = 5` -- silently, since the integers survive and nothing raises. Both
  data models now carry `rhash_len`, the field's on-wire width in bits, and both
  builders prefer it. This only became reachable end to end once #608 was fixed
  (#629); before that the undersized rebuild tripped the reader's parity guard
  first and failed loudly. (#653)
* SOLUTION's second contents octet is `Reserved`, "zero when sent, ignored when
  received" (RFC 7401 5.2.5, RFC 5201 5.2.5 identically) -- not a PUZZLE
  `Lifetime`, which only RFC 7401 5.2.4 defines. pcapkit wrote `0x20`, `0x21`,
  `0x25` or `0x2b` there and could not write the mandated zero at all: a
  conformant SOLUTION with `Reserved = 0x00` parsed to `timedelta(0)` and then
  escaped a bare `ValueError` from `math.log2(0.0)`. `Data_SolutionParameter`
  and `SolutionParameter` now carry `reserved` verbatim, so the conformant zero
  round-trips and a received non-zero octet is reproduced rather than
  re-derived. (#654)
* Neither builder read its own `version` keyword, so `version=1` and
  `version=2` computed identical lengths at every bit width. RFC 5201 5.2.4 and
  5.2.5 state both fields as literally 8 bytes and `Length` as literally 12 and
  20, and the readers enforce exactly that -- so under HIPv1 each builder
  accepted only a `bit_length()` of 57..64 and built, for everything else, a
  parameter this library's own reader rejects. Width now comes from the version
  under HIPv1. (#655)

The remaining `math.log2` sites, both in PUZZLE where a `Lifetime` really
lives, now raise `ProtocolError` rather than letting `ValueError` escape.
`ProtocolError(BaseError, ValueError)` is what the readers already raise for a
malformed PUZZLE or SOLUTION, and deriving from the builtin it replaces keeps
any caller written around today's bare `ValueError` working; `EnumError` is
`(BaseError, TypeError)` and would silently stop being caught. The same guard
covers the upper end, because `UInt8Field` wraps rather than raising --
measured, `300` packs as `0x2c` -- so an out-of-range lifetime would otherwise
be written as some other valid-looking duration. A plain `float` lifetime used
to escape an `AttributeError` from the `isinstance(lifetime, int)` branch; the
test keyed on `timedelta` instead found that.

`_make_param_solution` no longer accepts `lifetime=`. `reserved=` and
`rhash_len=` are both `None`-sentinelled, so an explicit value overrides what a
parsed parameter carries: `Data_SolutionParameter` is immutable, so without that
a caller holding a parsed parameter had no way to write the conformant zero over
a peer's non-conformant `Reserved`. The plain data fields still let `param` win,
as they did before. `rhash_len=` is also the only way a from-scratch HIPv2 build
can state a width, which genuinely varies with the Responder's HIT Suite
(RFC 7401 2.3, 5.2.10).

Five new tests, each verified to fail on 0c7f2b7 for the defect's own reason
and pass here: `12 != 5` and friends for #655, `expected a positive input` and
`'reserved' not found` for #654, `no attribute 'rhash_len'` for #653. The
widths exercised are 1, 9, 15, 17, 57 and 65 bits, where the candidate formulas
disagree, with the byte-aligned widths kept as controls -- at a multiple of 8
the correct `2 * ceil(b / 8)` coincides with #608's `ceil(b / 4)`, which is why
every byte-aligned fixture passed through that defect unharmed.

The byte-exact assertions compare the parameter without its trailing padding,
and then against the re-packed source schema rather than against a literal, so
they pin the `Length` field and the payload octets without encoding a padding
rule that #651/#664 is concurrently changing. Verified against a `git
merge-tree` of this branch and #664: both library files auto-merge with no
conflict, and all five new tests pass against the merged library.

Two cases are deliberately accepted rather than rejected, and now say so in
`_make_puzzle_field_width`'s docstring: `rhash_len == 0`, which is what the
derived path yields for the default `random=0` and what a `Length = 4`
parameter parses back to; and a `version` other than 1, which is treated as
HIPv2 exactly as both readers' `version == 1` guards do.

Coverage holds at 100% statement and branch on all three changed modules, with
statements 1385 -> 1414 and branches 316 -> 338; 30 tests / 434 subtests ->
35 / 455. Scoped unit tier green: 291 passed, 1191 subtests. No
EXPECTED_FAILURES entry moved -- still 45, with the same four HIP entries.

No changelog entry on this branch: that is consolidated in #657.
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…t values (#653, #654, #655) (#665)

`_make_param_puzzle` and `_make_param_solution` derived three wire-format
quantities from the payload value rather than taking them from the data model.
All three derivations were wrong, in the same two functions, and they are fixed
together because the width resolution is one expression that cannot be written
twice.

* The field width came from `int.bit_length()` and nothing else, so every
  leading zero octet was dropped on re-serialisation. A SOLUTION read with
  `Length = 20` rebuilt as `Length = 6`, a PUZZLE read with `Length = 12` as
  `Length = 5` -- silently, since the integers survive and nothing raises. Both
  data models now carry `rhash_len`, the field's on-wire width in bits, and both
  builders prefer it. This only became reachable end to end once #608 was fixed
  (#629); before that the undersized rebuild tripped the reader's parity guard
  first and failed loudly. (#653)
* SOLUTION's second contents octet is `Reserved`, "zero when sent, ignored when
  received" (RFC 7401 5.2.5, RFC 5201 5.2.5 identically) -- not a PUZZLE
  `Lifetime`, which only RFC 7401 5.2.4 defines. pcapkit wrote `0x20`, `0x21`,
  `0x25` or `0x2b` there and could not write the mandated zero at all: a
  conformant SOLUTION with `Reserved = 0x00` parsed to `timedelta(0)` and then
  escaped a bare `ValueError` from `math.log2(0.0)`. `Data_SolutionParameter`
  and `SolutionParameter` now carry `reserved` verbatim, so the conformant zero
  round-trips and a received non-zero octet is reproduced rather than
  re-derived. (#654)
* Neither builder read its own `version` keyword, so `version=1` and
  `version=2` computed identical lengths at every bit width. RFC 5201 5.2.4 and
  5.2.5 state both fields as literally 8 bytes and `Length` as literally 12 and
  20, and the readers enforce exactly that -- so under HIPv1 each builder
  accepted only a `bit_length()` of 57..64 and built, for everything else, a
  parameter this library's own reader rejects. Width now comes from the version
  under HIPv1. (#655)

The remaining `math.log2` sites, both in PUZZLE where a `Lifetime` really
lives, now raise `ProtocolError` rather than letting `ValueError` escape.
`ProtocolError(BaseError, ValueError)` is what the readers already raise for a
malformed PUZZLE or SOLUTION, and deriving from the builtin it replaces keeps
any caller written around today's bare `ValueError` working; `EnumError` is
`(BaseError, TypeError)` and would silently stop being caught. The same guard
covers the upper end, because `UInt8Field` wraps rather than raising --
measured, `300` packs as `0x2c` -- so an out-of-range lifetime would otherwise
be written as some other valid-looking duration. A plain `float` lifetime used
to escape an `AttributeError` from the `isinstance(lifetime, int)` branch; the
test keyed on `timedelta` instead found that.

`_make_param_solution` no longer accepts `lifetime=`. `reserved=` and
`rhash_len=` are both `None`-sentinelled, so an explicit value overrides what a
parsed parameter carries: `Data_SolutionParameter` is immutable, so without that
a caller holding a parsed parameter had no way to write the conformant zero over
a peer's non-conformant `Reserved`. The plain data fields still let `param` win,
as they did before. `rhash_len=` is also the only way a from-scratch HIPv2 build
can state a width, which genuinely varies with the Responder's HIT Suite
(RFC 7401 2.3, 5.2.10).

Five new tests, each verified to fail on 0c7f2b7 for the defect's own reason
and pass here: `12 != 5` and friends for #655, `expected a positive input` and
`'reserved' not found` for #654, `no attribute 'rhash_len'` for #653. The
widths exercised are 1, 9, 15, 17, 57 and 65 bits, where the candidate formulas
disagree, with the byte-aligned widths kept as controls -- at a multiple of 8
the correct `2 * ceil(b / 8)` coincides with #608's `ceil(b / 4)`, which is why
every byte-aligned fixture passed through that defect unharmed.

The byte-exact assertions compare the parameter without its trailing padding,
and then against the re-packed source schema rather than against a literal, so
they pin the `Length` field and the payload octets without encoding a padding
rule that #651/#664 is concurrently changing. Verified against a `git
merge-tree` of this branch and #664: both library files auto-merge with no
conflict, and all five new tests pass against the merged library.

Two cases are deliberately accepted rather than rejected, and now say so in
`_make_puzzle_field_width`'s docstring: `rhash_len == 0`, which is what the
derived path yields for the default `random=0` and what a `Length = 4`
parameter parses back to; and a `version` other than 1, which is treated as
HIPv2 exactly as both readers' `version == 1` guards do.

Coverage holds at 100% statement and branch on all three changed modules, with
statements 1385 -> 1414 and branches 316 -> 338; 30 tests / 434 subtests ->
35 / 455. Scoped unit tier green: 291 passed, 1191 subtests. No
EXPECTED_FAILURES entry moved -- still 45, with the same four HIP entries.

No changelog entry on this branch: that is consolidated in #657.
@JarryShaw JarryShaw added the breaking Breaks public-facing behaviour or API (apply alongside the type label) label Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaks public-facing behaviour or API (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HIP SOLUTION builder sizes with ceil(bits/4), emitting a parameter its own reader rejects

1 participant