fix(hip): PUZZLE and SOLUTION builders must stop inventing wire-format values (#653, #654, #655) - #665
Conversation
4ce1625 to
27ac665
Compare
GOOD TO GOCross-review by an independent agent on a different model (Sonnet) from the one that wrote the change (Opus), briefed to falsify rather than to bless, running read-only. It fetched both RFCs and built its own pristine Verdicts
What it disputed, and what I changedOne finding was acted on. It independently spotted that Two findings were deliberately not acted on, and are now documented in the code rather than left implicit:
Both now have a "two things this deliberately does not reject, both of which look like oversights and are not" paragraph in On the judgement call I asked it to stress-test — whether re-serialising should carry a received non-zero Two methodology notes worth recording:
One extra observation it contributed that is not in any of the three issues: at Not verified by the cross-reviewNothing was left unverifiable. CI on this PR is still queued behind other open pull requests in this repo and has not reported yet; the evidence above is local, and the lint flag sets were run locally from the |
…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.
27ac665 to
ebc929c
Compare
…ships in #665 The bullet #665 would otherwise have carried, kept here so that #665 touches only `pcapkit/protocols/internet/hip.py`, `pcapkit/protocols/schema/internet/hip.py`, `pcapkit/protocols/data/internet/hip.py` and `tests/protocols/internet/test_hip_unit.py`. Covers all three as one bullet, because they are one root cause: the HIP `PUZZLE` and `SOLUTION` builders derived the field width, the `Reserved` octet and the version-dependent length from the payload value instead of from the data model. Splitting the entry would tell the story three times and explain it none. Two public data models change, so the bullet says so in bold and carries a migration sentence: `SolutionParameter.lifetime` becomes `reserved` and an `int` rather than a `timedelta`, both parameter models gain a required `rhash_len`, and `_make_param_solution` no longer takes `lifetime=`. 46 lines added to the entry file; `CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Committed from a detached HEAD on 69a6e13 and pushed to the branch ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree at a stale f846523 and could not be taken here.
The bullet #664 would otherwise have carried, kept here so that #664 touches only `pcapkit/protocols/internet/hip.py`, `pcapkit/protocols/schema/internet/hip.py`, `tests/protocols/internet/test_hip_unit.py`, `tests/protocols/test_option_roundtrip_unit.py`, `examples/generators/options.py` and `docs/source/pcapkit/protocols/internet/hip.rst`. One bullet, because it is one root cause in 95 places: every HIP padding site aligned the parameter's *contents* to eight octets rather than the record, ignoring the four-octet type-and-length header, so every parameter pcapkit wrote was `4 (mod 8)` for every possible `Length`. The bullet says in bold that both the emitted octets and the data model's reported `length` change, and carries a migration sentence: a `SEQ` parameter's `length` is 8 where it was 12, so code comparing stored output byte for byte or asserting on `Data_*Parameter.length` sees different values. It also records what was deliberately *not* changed, since both look like part of the same defect and are not: `HIP.make`'s `len = total_length // 8 + 4`, which RFC 7401 section 5.1.3 shows is correct and merely needed 8-aligned parameters; and `HIP_COPIES`, which stays at two for `R1_COUNTER`'s four-octet `counter` against section 5.2.3's eight -- a separate, still-unfiled defect this one had been masking. The `EncryptedParameter.data` length callback is named as fixed in the same change because the two four-octet errors cancelled at four of the eight residues of `Length`, so correcting the padding alone would have regressed it. 41 lines added to the entry file; `CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Committed from a detached HEAD on 2ce3687 and pushed to the branch ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree at a stale f846523 and could not be taken here. Note 2ce3687, not the 69a6e13 I was given: the branch had already moved on with #665's entry.
The bullet #667 would otherwise have carried, kept here so that #667 touches only `pcapkit/corekit/multidict.py` and `tests/corekit/test_multidict.py`. One bullet, because it is one convention gap in one class: `_Missing` behind `MultiDict.pop` and `OrderedMultiDict.pop` lacked the `@final` and the falsy `__bool__` that `NoValueType` in `pcapkit.corekit.fields.field` sets as the package's convention for a marker of this kind. The bullet says plainly that no behaviour changes, and says why rather than asserting it: both `pop()` implementations decide by identity, never by truthiness, and `pop()` structurally cannot return the marker -- it returns `default` only on the branch where `default is not _missing`. It also names the one way the old truthiness was observable, which is what justifies touching it at all: `inspect.signature(MultiDict.pop).parameters['default'].default` hands the marker to any caller who asks, and `if default:` on it reported "a default was supplied" where none had been. It closes by recording the disposition of the other two sites from the #640 sweep, so the entry is the whole story: site 1 needed nothing, and `_NOT_FOUND` in `pcapkit.utilities.compat` stays a bare `object()` deliberately, being a verbatim line of CPython's `functools.cached_property` inside a `sys.version_info < (3, 8)` branch no supported interpreter reaches. The reasoning behind that one is on #661, not here. `:obj:` roles had to come out: `util/changelog_md.py` rejects them with `ResidualMarkupError`, since its six conversion rules do not cover interpreted text and `CHANGELOG.md` would carry the role through as literal text. Double backticks instead, which is what the rest of the entry file uses. 20 lines added to the entry file; `CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Committed from a detached HEAD on e55ba36 and pushed to the branch ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree at a stale f846523 and could not be taken here. Note e55ba36, not the 69a6e13 I was given: the branch had already moved on with #665's and #651's entries. Refs #661
#669 One bullet, because it is one round trip with a defect on each side of it, in the same two files: `_make_http_data` never read `frame.flags` on the construct side, and `FrameType.post_process` seeded its accumulator with a bare `0` on the parse side. The bullet leads with what changes rather than with the mechanism, since both halves alter output: the reconstructed DATA frame's flags octet, and the dumped `__value__` of a flagless frame. It says why #650 was worth fixing at all, which its issue had left as an open question -- the dump rendered `__value__` as a JSON number for a flagless frame and a JSON string for every other frame in the same capture, so the fix removes a type inconsistency rather than introducing one. It also records two things a reader would otherwise be surprised by. The seed is guarded rather than unconditional, because `FrameType.Flags` has no members and a memberless `enum.Flag` subclass refuses `Flags(0)` -- the one-token fix the issue proposed would have crashed six of the twelve frame schemas. And a DATA round trip is still lossy after this, for the unrelated mis-parenthesised length callbacks filed as #668, so the entry does not let the reader infer a clean round trip that does not exist yet. The `TypeError` message had to sit on one line: `util/changelog_md.py` rejects a `` literal spanning a line break with `ResidualMarkupError`, since its six conversion rules do not cover it. 37 lines added to the entry file; `CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Committed from a detached HEAD on 367b6e6 and pushed to the branch ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree and could not be taken here. Note 367b6e6, not the 69a6e13 I was given: the branch had already moved on with #665's, #651's and #661's entries. Refs #652 Refs #650
…s in #670 and #671 Two bullets, not one, because the two defects are unrelated: one changes what the dumpers emit, the other only the text of four exception messages. They share a file only by accident of being found in the same pass. The #648 bullet leads with the output change and says so in bold, because that is what a reader upgrading needs to see: a flag value with no declared bits dumped as `Type::None [0]` in all six textual format names, out of both `Extractor` and `TraceFlow`. It then justifies the *replacement* rather than just stating it, since "render it as its decimal value" looks arbitrary until you know the enumeration libraries already spell an undeclared residue that way -- and that a decimal cannot collide with a member name where `None` can, `NONE` being a real declared name elsewhere. Three things a reader would otherwise get wrong are recorded: three sites carried the interpolation and not one, the guard is on `name is None` rather than on zero because the defect never was about zero, and it is not an `aenum` quirk since stdlib `enum.IntFlag` behaves identically. It also corrects the issue on a point of fact. #648 said `Flags` was the only registry nameless at zero; a sweep of all seven finds five, the four Mobility Header flag registries included. And it states that the committed example dumps do not move, which was measured by regenerating all three with and without the change rather than assumed -- a reader of a bullet this emphatic will otherwise wonder whether `examples/captures/` drifted. The #649 bullet says "cosmetic" in its second sentence so nobody reads it as a behavioural change, then gives the one reason it was worth doing at all: it is the text a user sees when an option is rejected. It names all four sites, and the 28-against-4 count in the same file, because that count is what makes the correct form a fact about the module rather than a preference. Neither bullet claims a guard it does not have: #648's third site, the `addon` branch, is not reachable from any registry in the library today, and the bullet does not imply otherwise. `CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited by hand. `--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Each `` literal is kept on one line, since the generator rejects one spanning a line break with `ResidualMarkupError`. Committed from a detached HEAD on d14577d and pushed to the branch ref, because `docs/changelog-1.5.0` is checked out in another agent's worktree and could not be taken here. Note d14577d, not the 69a6e13 I was given: the branch had already moved on with #665's, #651's, #661's and #652/#650's entries. Refs #648 Refs #649
Fixes #653
Fixes #654
Fixes #655
One PR, and why
All three defects live in
_make_param_puzzleand_make_param_solution, and they are one root cause in three guises: both builders derived wire-format values from the payload value instead of taking them from the data model. #653 derived the field width from the value, #655 derived it without consultingversion, and #654 derived SOLUTION'sReservedoctet from a lifetime that SOLUTION does not have — with the derivation machinery crashing on a legal input.One half is genuinely separable and shipped here anyway, which is worth stating plainly rather than leaving in the issue: #654's exception-hygiene repair (bare
ValueErrortoProtocolError) serves PUZZLE too, independent of SOLUTION's rename, and #654 itself calls the two repairs separable with one riskier than the other. It could have gone first, smaller and non-breaking. It rides along because it is four lines inside the same two functions the rest of this change rewrites, and splitting it would buy a rebase rather than a smaller review.The rest is mechanically inseparable. #653 and #655 rewrite the same
len=expression in both builders, and a width resolution cannot be written twice. #654's SOLUTION half deletes thelifetimelocal that sits between them. And all three add or rename fields on the same two dataclasses inpcapkit/protocols/data/internet/hip.py. Split into separate PRs, every hunk would conflict and the second would be a rebase of the first, for one coherent public-API change shipped twice.RFC text, fetched not paraphrased
https://www.rfc-editor.org/rfc/rfc7401.txt, sha25609366b9f83dc80593172304ffcf05f57afd186aacb468ac6170a8fe26944c4be, andhttps://www.rfc-editor.org/rfc/rfc5201.txt, sha2568b42d181a8e239713eb8d608c11e8e75829561d994adbd3caa96c6db6e69cef6— both matching the hashes the issues record. SOLUTION is §5.2.5 and PUZZLE is §5.2.4 in both RFCs, andRHASH_lenis defined in RFC 7401 §2.3.RFC 7401 §5.2.5, verbatim:
against RFC 7401 §5.2.4, where a lifetime genuinely lives:
RFC 5201 states the same two field names, and fixes both widths as constants — §5.2.4:
Random #I, 8 bytes,Length 12, "Random #I is represented as a 64-bit integer"; §5.2.5:Random #I, 8 bytes,Puzzle solution #J, 8 bytes,Length 20. RFC 7401 §2.3 definesRHASH_lenas "the natural output length of RHASH in bits", which is why the width has to come from somewhere other than the version under HIPv2.#653 — the silent difference, in bytes
Hand-written wire octets (not produced by the builder under test — the only way to present a value narrower than its field), parsed then rebuilt. Measured on
0c7f2b7c9:and PUZZLE:
After, both are byte-identical:
20 -> 20and12 -> 12,identical across the round trip? True. Nothing raised before — the integers survive and the cycle closes, so a conformant peer readsRHASH_len = 8bits where the sender said 64.#654 — reachable from conformant input
The octet at SOLUTION offset 5, the
Reservedposition, before:and the conformant path — a SOLUTION whose
Reservedis the mandated0x00:After:
reservedis0x00for every from-scratch build, the conformant input round-trips byte-identically, and a received non-zeroReserved(0x2b) is reproduced verbatim rather than re-derived.The PUZZLE
Lifetimesites, where a lifetime is real, now raise in-library:Which exception, and why
ProtocolError. It is already what both readers raise for a malformed PUZZLE or SOLUTION, and it is declaredProtocolError(BaseError, ValueError)— the precedentFieldValueError(BaseError, ValueError)sets. Deriving from the builtin it replaces is what makes this non-breaking: a caller already written around today's bareValueErrorkeeps working.EnumErroris(BaseError, TypeError), so choosing it by name would silently stop those callers catching anything. Both properties are asserted in the test.The same guard covers the upper end, because
UInt8Fieldwraps rather than raising — measured,300packs as0x2cand-1as0xff— so an out-of-range lifetime would otherwise be written as some other valid-looking duration.#655 — the version=1 / version=2 table
Before, on
0c7f2b7c9— the v1 and v2 columns are identical at every width:After — v1 is the RFC's constant, v2 unchanged:
ProtocolErrorProtocolErrorProtocolErrorProtocolErrorAt 65 and 128 bits the value cannot fit HIPv1's 64-bit field at all, so it is refused at build time with a message saying why, instead of building a
Lengththe reader then rejects.Why these widths discriminate
Multiples of 8 cannot tell the candidate formulas apart — at 8, 16, 32, 56, 64 and 128 bits the correct
2 * ceil(b / 8)agrees with #608'sceil(b / 4)and with the floor variant, which is exactly why every byte-aligned fixture passed through #608 unharmed. So 1, 9, 15, 17, 57 and 65 carry the weight (at 1 bit: correct 6, quarter 5, floor 4 — all three differ), and the byte-aligned rows are kept as controls. 57 and 65 bracket HIPv1's field: 57 is the narrowest value whose derived width reached the required 20, 65 the narrowest that overshoots.Evidence discipline
__editable__*stripped fromsys.meta_path(it sits at index 4, afterPathFinder), worktree atsys.path[0],PYTHONSAFEPATH=1, andpcapkit.__file__asserted before any other import. Measured:/local/home/jarryx/GitHub/PyPCAPKit/.claude/worktrees/agent-a5bae1f1fdefc68a0/pcapkit/__init__.py, CPython 3.14.7. The "before" figures come from an immutablegit archive 0c7f2b7c9snapshot.0c7f2b7c9(pytest exit code 1, read from a file) and pass here (exit code 0), each for the defect's own reason:AssertionError: 12 != 5,6 != 12,7 != 12,8 != 12,11 != 12andProtocolError not raisedfor Neither the HIP PUZZLE nor SOLUTION builder consults version when sizing, so HIPv1 rejects what it builds #655;ValueError: expected a positive inputand'reserved' not found in {... 'lifetime': 'timedelta' ...}for SOLUTION's Reserved octet is written as a PUZZLE Lifetime, and lifetime=0 escapes a bare ValueError from math.log2 #654;AttributeError: 'SolutionParameter' object has no attribute 'rhash_len'for Re-serialising a parsed HIP PUZZLE or SOLUTION loses leading zero octets: a len=20 parameter rebuilds as len=6 #653. In the Neither the HIP PUZZLE nor SOLUTION builder consults version when sizing, so HIPv1 rejects what it builds #655 test PUZZLE is asserted before SOLUTION deliberately, because PUZZLE's keyword arguments are unchanged by this PR and so its assertion is reached on the old tree; SOLUTION's cannot be, since there the old builder crashes on its ownlifetimedefault first.One precision point, raised by the cross-review:
test_hip_puzzle_and_solution_size_from_version_under_hipv1reportsPASSEDon pytest's own per-test line on the old tree even though all twelve of its subtests reportSUBFAILEDwith correct tracebacks — a quirk of pytest 9's built-in subtests plugin, not of the test. So "all five fail" holds at the process exit code and for the substance of every assertion, but anyone re-running this should grepSUBFAILEDas well asFAILED.*_runtime.pymodules (test_ip_runtime.py,test_ipv6_extension_runtime.py,test_ipv6_reassembly_runtime.py) are excluded: they are fixture-dependent tier and their 5 failures reproduce identically on the pristine0c7f2b7c9snapshot, so they pre-date this change and are the missing generated captures.EXPECTED_FAILURESunchanged — imported rather than grepped, since**unpacking defeats a grep. Still 45 entries, same four HIP ones (ENCRYPTED,HIP_TRANSFORM,HOST_ID,R1_Counter). Nothing moved, and nothing was deleted.Coordination with #651
#651 is being fixed in the same two files. This PR stays entirely out of the padding expressions:
length=4 + schema.len + (8 - schema.len % 8) % 8in both readers andPaddingField(length=lambda pkt: (8 - (pkt['len'] % 8)) % 8)appear only as unchanged context, andHIP.make'stotal_length // 8 + 4(hip.py:615) has zero hits in the diff. The only% 8in an added line isrhash_len % 8, a whole-octets check on a bit count in the new width helper.Deriving the width from
Data_*Parameter.lengthinstead of addingrhash_lenwas considered and rejected precisely because it would require inverting that padding expression — coupling this change to #651's.Measured against #664, not assumed
git merge-treeof this branch and #664's head reports exactly one conflict, intests/protocols/internet/test_hip_unit.py— bothpcapkit/protocols/internet/hip.pyandpcapkit/protocols/schema/internet/hip.pyauto-merge with zero conflict markers. So the site partitioning held.On correctness the two are order-independent, and this was checked rather than inferred. #664's entire change to
pcapkit/protocols/internet/hip.pyis replacinglength=4 + schema.len + (8 - schema.len % 8) % 8with a call to a newparameter_total_len(schema.len)helper, in every reader; it never touchesschema.lenitself nor any builder'slen=. This PR changes whatschema.lenis for these two parameters, and theReservedoctet.schema.lenis the input to #664's helper, so the two compose: neither reads the other's quantity.Verified empirically by extracting the merged tree and running both sides' suites against the merged library:
test_hip_parameter_constructors_cover_data_model_and_default_paths, which constructsSolutionParameter(lifetime=...)— a call site this PR's rename requires updating, not a logic conflict.Getting there found a real interaction worth recording: the byte-exact assertions in this PR's #653 and #654 tests originally compared against a hardcoded literal that included four trailing padding octets, so on the merged library they failed with
b'…\x01' != b'…\x01\x00\x00\x00\x00'— the merged library correctly emits no padding for a 24-octet record. That was a property of the test fixtures, not of the fix: everyschema.len,rhash_len,reservedandProtocolErrorassertion passed on the merged library throughout, and the #655 version table test passed unchanged. Those assertions are now padding-agnostic, so this PR no longer encodes a padding rule and merges in either order without touching its expected bytes.Handover, whichever merges second: resolve the one test-file conflict (both sides append methods to the same class). If #665 merges first, #664 additionally needs its
test_hip_parameter_constructors_cover_data_model_and_default_pathsupdated for thelifetime→reservedrename and the new requiredrhash_len. If #664 merges first, this PR needs nothing beyond the conflict resolution.Breaking changes, deliberate
Data_PuzzleParameterandData_SolutionParametergain a requiredrhash_len;Data_SolutionParameter.lifetimebecomesreserved: int. Both appended or renamed in place, withrhash_lenlast so positional construction is least disturbed.SolutionParameter(schema) renameslifetimetoreserved._make_param_solutionno longer acceptslifetime=; it is swallowed by**kwargs, as the library does for every unknown keyword, and the RFC-mandated zero is written instead.reserved=isNone-sentinelled rather than defaulted to0, so an explicit value overrides the one a parsed parameter carries. Added after the cross-review pointed out thatData_SolutionParameteris immutable, so without it a caller holding a parsed parameter had no way to write the conformant zero over a peer's non-conformantReserved.rhash_len=works the same way; the plain data fields still letparamwin, as they did before.Found and deliberately not fixed
Lifetimeread is lossy below0x0c.2^(t - 32)seconds fort <= 11is undertimedelta's microsecond resolution, so every such octet parses totimedelta(0)and the original value is unrecoverable. That is why a PUZZLE withLifetime = 0x00now raises on re-serialisation rather than round-tripping: clamping would silently emit a different lifetime, which is the Re-serialising a parsed HIP PUZZLE or SOLUTION loses leading zero octets: a len=20 parameter rebuilds as len=6 #653 failure mode. Fixing it properly means carrying the raw octet, a third data-model change none of these three issues asks for. Worth its own issue.UInt8Fieldwraps silently on out-of-range input (300→0x2c,-1→0xff). Guarded here forLifetimeonly.reservedis left unguarded, because this is a property of everyUInt8Fieldin the library rather than a HIP one.examples/generators/options.py:975-977is now stale. SOLUTION's{'lifetime': 1}override is a no-op (the keyword is gone), and the comment "lifetime=0reachesmath.log2(0)" now describes aProtocolError. Left untouched on purpose: HIP parameter padding aligns the contents, not the record, so every parameter pcapkit emits is 4 (mod 8) octets #651's worker owns theHIP_COPIEScommentary in that file, and two workers editing it is the clobbering case. Flagged rather than raced.rhash_len == 0is accepted, i.e. a zero-width payload field, which no real hash produces. It is what the derived path yields for the defaultrandom=0, what aLength = 4parameter parses back to, and what this builder produced before the change — so rejecting it would turn a degenerate-but-self-consistent case into a new failure for callers passing no value at all. Documented in_make_puzzle_field_width's docstring rather than changed.versionis not validated: anything other than1is treated as HIPv2. That mirrors both readers, whose guards are writtenversion == 1rather than exhaustively, so reader and builder agree. Validating it belongs withHIP.make's public signature.NumberFieldraises a bareValueError: negative shift countwhen unpacking a PUZZLE or SOLUTION whoseLengthis below 4. Measured identically on0c7f2b7c9, so it pre-dates this change, and it lives in the field machinery rather than in HIP. It does mean a negativerhash_lenis unreachable through the parse path.2c4212a02. Not rebased here.