Skip to content

protocols: reject an int MN-ID identifier for every subtype but IPv6_Address - #468

Merged
JarryShaw merged 9 commits into
mainfrom
fix-467-mn-id-int-identifier
Sep 18, 2026
Merged

JarryShaw merged 9 commits into
mainfrom
fix-467-mn-id-int-identifier

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Closes #467.

MH._make_opt_mn_id accepted int as a documented identifier type, but for
every Enum_MNIDSubtype except IPv6_Address an int produced a schema that
could not be packed at all, with a declared length that was wrong regardless.

This is the other half of #448. PR #464 fixed the IPv6_Address subtype by
taking the width from subtype_val; the elif isinstance(identifier, int)
branch still sized from the identifier's Python type for the remaining seven.

Before

Measured on fa128959e, identifier=0x1234:

Subtype declared length pack()
NAI 3 AttributeError: 'int' object has no attribute 'encode'
IMSI 3 struct.error: argument for 's' must be a bytes object
P_TMSI 3 struct.error
EUI_48_address 3 struct.error
EUI_64_address 3 struct.error
GUTI 3 struct.error
DUID 3 struct.error

length=3 came from math.ceil(identifier.bit_length() / 8) + 1 — the
integer's own width, not the octet count the subtype's field packs. NAI fails
differently from the other six because its field is text (StringField) rather
than raw octets (BytesField).

The decision: reject, for all seven

Neither StringField nor BytesField has any int-conversion logic, unlike
IPv6AddressField, which does its own ipaddress.ip_address() conversion —
that is why an int only ever worked for IPv6_Address. Every other
subtype's field is variable-length, sized from the wire length header rather
than from anything subtype_val fixes, so there is no non-arbitrary width to
convert an int into; picking one would reintroduce the same type-vs-subtype
confusion without the crash.

So _make_opt_mn_id now raises ProtocolError — matching this file's own
construction-error convention — naming the subtype and the type it accepts
(str for NAI, bytes for the rest). IPv6_Address continues to accept int
unchanged.

After

All seven raise an in-library ProtocolError rather than a bare stdlib
exception, and the IPv6_Address path is untouched — verified independently of
the change's own tests:

NAI .. DUID (all seven):  ProtocolError, isinstance(e, BaseError) is True
IPv6_Address + int 0x1234: length=17, packed 19 octets   (unchanged)

Type union

Schema_MNIDOption's TYPE_CHECKING __init__ stub drops int, matching its
own class attribute's already-narrower annotation.

_make_opt_mn_id's top-level identifier union deliberately keeps int:
narrowing it made mypy flag the isinstance(identifier, int) branch as
unreachable (3 "disjoint bases" plus 1 "unreachable statement"), and int
remains genuinely handled for IPv6_Address, so the union is accurate as it
stands. The promise is corrected in the docstring's Args:/Raises: prose
instead.

Verification

  • Generated captures unchanged_mh_option_overrides has no
    MN_ID_OPTION_TYPE entry, so the generator exercises the real default
    (identifier='::', subtype IPv6_Address), a path this fix does not touch.
    All five examples/captures/options-*.pcap are cmp-identical against a
    pristine fa128959e worktree.
  • No conflict with corekit: let a nested schema's field callbacks reach the enclosing schema #457, which also edits
    tests/protocols/internet/test_mh_unit.py: git merge-tree auto-merges that
    file with no CONFLICT.
  • tests/protocols/test_option_roundtrip_unit.py untouched, and it has no
    MN_ID entry either before or after.
  • Full suite, Python 3.14.7: before 1006 passed / 17 skipped / 1553
    subtests; after 1007 passed / 17 skipped / 1560 subtests — exactly the new
    test and its 7 subtests. Zero failures either side.
  • mypy on pcapkit/: 125 errors before and after, identical once line and
    column numbers are normalised. No new # type: ignore.

Related, deliberately not fixed here

The same union lists bytes as accepted for NAI, but bytes has no
.encode(), so NAI fails identically for a bytes identifier — and the mirror
case holds too, a str identifier for one of the six octet subtypes dying in
struct.error. Both are the same family as this issue but are about bytes/str
rather than int, so they are filed separately rather than folded in.


Revision at a8c214ab4 — this supersedes the approach described above

Appended rather than edited in, because the review comments above quote the original text.

The body above is wrong on its central claim. It says there is no non-arbitrary width to convert an int into for the six BytesField subtypes. There is: math.ceil(identifier.bit_length() / 8), which is self-consistent with the declared length by construction and round-trips exactly. The pre-#467 defect was never the sizing — that was already right — it was that identifier stayed an int afterwards and reached BytesField unconverted, which struct.pack() cannot do anything with. I accepted that "no non-arbitrary width" conclusion from a subagent and repeated it here and in the issue without re-deriving it. It should not have gone in.

At the repo owner's request, this revision converts instead of rejecting:

  • The six BytesField subtypes (IMSI, P_TMSI, EUI_48_address, EUI_64_address, GUTI, DUID) convert via identifier.to_bytes(max(1, math.ceil(identifier.bit_length() / 8)), 'big'). The max(1, …) floor matters: bit_length() is 0 for 0 itself, which would otherwise declare a zero-octet identifier and collapse "the identifier's value is 0" into "there is no identifier".
  • IPv6_Address is untouched — still ipaddress.IPv6Address(…), which both converts and validates, at the spec-fixed 16 octets (MH MN-ID option sizes its identifier from the Python type rather than the subtype, so even the default arguments mis-declare length #448).
  • NAI still rejects an int, and that is a judgement call rather than a mechanical limit. str(identifier) packs and round-trips fine; the argument for refusing is that an NAI is a network access identifier (user@realm, RFC 4283), so a bare decimal-digit string is mechanically valid and semantically nonsense — the same "accepts a value that means the wrong thing" MH MN-ID: an int identifier cannot be packed for any subtype except IPv6_Address, and its declared length is wrong #467 removed, only relocated. The message names str(…) so a caller who wants that can spell it explicitly.
  • A negative int is rejected for every subtype, by a guard placed before the subtype dispatch. Inside the int branch — where it was at 1ea8b56ab — the IPv6_Address dispatch never reaches it, so identifier=-5, subtype=IPv6_Address leaked AddressValueError: -5 (< 0) is not permitted as an IPv6 address. AddressValueError subclasses ValueError, i.e. a bare stdlib exception escaping the very handler whose purpose is to stop that. Measured directly at 1ea8b56ab, then hoisted.

Verification, through the maker → pack()unpack() rather than a hand-built schema carrying a self-consistent length the maker would never produce — all 8 subtypes × {0, 1, 0xff, 0x1234, 0x100000000, 2**128-1, True}. Every BytesField subtype round-trips identically; 0len=2/b'\x00', 0x1234len=3/b'\x124', 2**128-1len=17/16 × \xff. -5 raises ProtocolError on all eight. NAI + str(0x1234) packs to a payload decoding as '4660'.

tests/protocols/internet/test_mh_unit.py: 37 passed, 318 subtests, 0 failed.

Deliberately not fixed here: wrong-type identifiers still escape as bare stdlib exceptions (bytes for NAIAttributeError, str for the octet subtypes → struct.error, and so on). Unchanged from main, tracked as #469.

Follow-up at 6a9e4e427 — the upper bound

The review of a8c214ab4 found the mirror of the leak that sha's own guard had just closed: an int identifier at or above 2**128 with subtype=IPv6_Address still reached ipaddress.IPv6Address() and raised AddressValueError, a ValueError subclass. Verified before fixing — 2**128 and 2**140 both leaked, 2**128 - 1 packed normally at length 17.

The check went inside the IPv6_Address branch rather than into the shared negative-int guard, because this bound is subtype-dependent where the negative bound is not: 2**140 is a valid identifier for the six BytesField subtypes, producing length 19 (and 18 for 2**128). The test asserts that as well, so a future guard cannot be hoisted to cover them by mistake. It is also not implemented by wrapping the IPv6Address construction, which would have swallowed the wrong-type AddressValueError at the same line — #469's subject, not this PR's to annex.

Pre-existing since #448, not a regression from this branch: the construction line is byte-for-byte unchanged from origin/main.

Regression-proved: disabling only the new guard's condition fails exactly the two new subtests and nothing else; restoring it passes. tests/protocols/internet/test_mh_unit.py: 37 passed, 332 subtests, 0 failed (318 before).

Why it survived the first sweep, which is the transferable part: my probe covered every subtype but stopped at 2**128 - 1, one value below where the answer changes. Probing a large value is not probing the boundary.

5cf1740ad — docstring only. The review of 6a9e4e427 caught that its own Raises: clause had been left stale by that commit: it listed a negative int and an int with NAI, but not the third condition the new guard introduces, an int of 2**128 or above with IPv6_Address. All three now listed, each measured rather than read off the code, along with why the ceiling exists only for IPv6_Address. No executable line changed, so the GOOD TO MERGE at 6a9e4e427 stands by content.

…Address

_make_opt_mn_id's `elif isinstance(identifier, int)` branch sized the
identifier from the int's own bit_length() regardless of subtype, the same
type-vs-subtype confusion #464 fixed for IPv6_Address. mn_id_selector
resolves NAI to a StringField and every other non-IPv6 subtype to a
BytesField, and neither field type converts an int, so the declared length
was wrong and pack() always failed -- AttributeError for NAI ('int' has no
.encode()), struct.error for the rest. This is the other half of #448,
pre-existing on main and unrelated to #464. See #467.

- pcapkit/protocols/internet/mh.py: the int branch now raises ProtocolError
  naming the resolved subtype and the type it actually accepts (str for
  NAI, bytes for the rest), instead of silently building an unpackable
  schema. IPv6_Address is unaffected -- #464 already normalises it via
  ipaddress.IPv6Address independent of identifier's Python type, and int
  stays valid there.
- pcapkit/protocols/schema/internet/mh.py: MNIDOption's TYPE_CHECKING
  __init__ stub drops int from the identifier union, matching the class
  attribute's own already-narrower annotation two lines above it.
- tests/protocols/internet/test_mh_unit.py: new
  test_mh_mn_id_option_rejects_int_identifier_for_non_ipv6_subtypes, one
  subTest per rejected subtype (NAI, IMSI, P_TMSI, EUI_48_address,
  EUI_64_address, GUTI, DUID) plus a check that IPv6_Address still converts.

Full suite (PYTHONSAFEPATH=1, interpreter 3.14.7): 1007 passed, 17 skipped,
1560 subtests passed. Baseline at fa12895: 1006 passed, 17 skipped, 1553
subtests passed. mypy: 125 errors before and after, byte-identical modulo
line numbers.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewed at 9b26fa387 (base fa128959e). Everything below was re-measured independently in a fresh worktree checked out to that exact sha, not assumed from the PR body.

CI

gh pr checks 468 — all 24 checks green: every Compat/Python 3.1x/Integration Python 3.1x job, Analyze, CodeQL, deploy-pages, and pyup.io/safety-ci (No dependencies with known security vulnerabilities). Gate (full suite, Python 3.14) and Docs test gate are skipping, consistent with their being conditional gates rather than failures.

Field-class reasoning (Q1: is "reject" the right call?)

Read pcapkit/corekit/fields/strings.py and pcapkit/corekit/fields/ipaddress.py:

  • BytesField.pre_process (strings.py:80-94) returns value unchanged — no int-conversion path.
  • StringField.pre_process (strings.py:134-151) calls value.encode(...) — fails on int with AttributeError.
  • _IPAddressField.pre_process (ipaddress.py:57-75), which backs IPv6AddressField, calls ipaddress.ip_address(value), which does accept int.

This confirms the author's claim exactly: the int-conversion capability is unique to IPv6AddressField, and every other MN-ID subtype resolves (via mn_id_selector, pcapkit/protocols/schema/internet/mh.py:341-357) to StringField (NAI) or BytesField (the other six) — both variable-length, sized from the wire length, with no spec-fixed width to convert an int into. "Reject" is the correct call; there is no defensible conversion.

mypy trade-off (Q2: keeping int in the maker's own union)

Reproduced the claim directly: temporarily narrowed _make_opt_mn_id's identifier parameter to 'bytes | str | IPv6Address' (dropping int) and ran mypy pcapkit/protocols/internet/mh.py:

mh.py:7684:25: error: Subclass of "bytes" and "int" cannot exist: have distinct disjoint bases  [unreachable]
mh.py:7684:25: error: Subclass of "str" and "int" cannot exist: have distinct disjoint bases  [unreachable]
mh.py:7684:25: error: Subclass of "IPv6Address" and "int" cannot exist: have distinct disjoint bases  [unreachable]
mh.py:7700:13: error: Statement is unreachable  [unreachable]

Exactly the "3 disjoint bases + 1 unreachable" the PR body describes. Reverted the edit (git diff clean afterward). Also checked pcapkit/protocols/data/internet/mh.py:753,756Data_MNIDOption's own identifier annotation and __init__ stub already say 'bytes | str | IPv6Address' with no int, so _make_opt_mn_id's signature is the only remaining place carrying the wider union, and it's carrying it for a real reason (int is genuinely handled for IPv6_Address). Given mypy's unreachability complaint is real and a # type: ignore was ruled out, this is the right trade — the promise is correctly relocated to the docstring instead (Q4, below: accurate).

ProtocolError message and a defect it doesn't cover (Q3)

Confirmed the 7-subtype rejection and the unchanged IPv6_Address accept path by calling _make_opt_mn_id directly (same convention the file's other maker tests use):

NAI    -> ProtocolError: MH: [OptNo 8] MN-ID subtype <MNIDSubtype.NAI: 1> identifier must be str, not int
IMSI   -> ProtocolError: MH: [OptNo 8] MN-ID subtype <MNIDSubtype.IMSI: 3> identifier must be bytes, not int
... (P_TMSI, EUI_48_address, EUI_64_address, GUTI, DUID all ProtocolError, all isinstance(e, BaseError) True)
IPv6_Address + int 0x1234 -> length=17, packed 19 octets   (unchanged, still works)

Each message names the subtype (via Enum_MNIDSubtype(subtype_val)!r) and the required type — more informative than this same file's own existing convention (e.g. _make_opt_auth's f'{self.alias}: [OptNo {type}] invalid format', no type/subtype named). ProtocolError does subclass ValueError (pcapkit/utilities/exceptions.py:357, one line off from the PR's own "356"), but I found no except ValueError anywhere in mh.py or elsewhere in pcapkit/ on this call path, so the re-raise-ordering concern is theoretical here, not live.

However, I found a real, reproducible defect in this exact new code (pcapkit/protocols/internet/mh.py:7700-7703). The error message evaluates Enum_MNIDSubtype(subtype_val)!r before ProtocolError is constructed. _make_index (pcapkit/protocols/protocol.py:1164-1167) passes an int subtype straight through with no membership validation, and MNIDSubtype._missing_ (pcapkit/const/mh/mn_id_subtype.py:61-77) only auto-extends the enum for 9 <= value <= 15 (Reserved_N) and 16 <= value <= 255 (Unassigned_N) — it does not cover value == 0, negative values, or values > 255. For those, Enum_MNIDSubtype(subtype_val) itself raises a bare ValueError, before the intended ProtocolError is ever built — reintroducing exactly the "bare stdlib exception instead of an in-library one" problem this PR (and the #465/#458/#438 series it's part of) exists to fix. Measured directly:

proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=0,   identifier=0x1234)  # -> ValueError: 0 is not a valid MNIDSubtype
proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=-1,  identifier=0x1234)  # -> ValueError: -1 is not a valid MNIDSubtype
proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=256, identifier=0x1234)  # -> ValueError: 256 is not a valid MNIDSubtype
proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=999, identifier=0x1234)  # -> ValueError: 999 is not a valid MNIDSubtype
# for contrast, values that DO auto-extend work as intended:
proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=10,  identifier=0x1234)  # -> ProtocolError: ... MNIDSubtype.Reserved_10: 10 ... not int
proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=100, identifier=0x1234)  # -> ProtocolError: ... MNIDSubtype.Unassigned_100: 100 ... not int

Pre-PR (fa128959e), this exact combination never hit Enum_MNIDSubtype(...) at all (the old branch just did math.ceil(identifier.bit_length()/8)), so this failure mode is newly introduced by this PR, not pre-existing. It's narrow — it needs a caller-supplied subtype that is itself a raw, out-of-range/undefined int (0, negative, or >255) and an int identifier — but it sits squarely in the code this PR added, in the branch whose whole purpose is producing a clean in-library error. Not part of #469 (that's about bytes/str mismatch); this is a distinct new finding, reported per your instruction rather than filed.

Docstring accuracy (Q4)

_make_opt_mn_id's new Args:/Raises: prose (mh.py:7648-7663) matches verified behavior exactly: "str for NAI, bytes for the rest" matches mn_id_selector's dispatch precisely, and the int-stays-accepted-for-IPv6_Address claim is verified above. A reader would not be misled.

Coverage (Q5)

pcapkit/const/mh/mn_id_subtype.py defines exactly 8 members (NAI=1, IPv6_Address=2, IMSI=3, P_TMSI=4, EUI_48_address=5, EUI_64_address=6, GUTI=7, DUID=8). The new test's 7 rejected subtypes plus the existing accept path cover all 8 with no gaps. Crucially, the new test (tests/protocols/internet/test_mh_unit.py:2366-2371) does pin the accepting path: after the 7-subtype rejection loop, it asserts IPv6_Address + int 0x1234 still gives schema.length == 17 and len(schema.pack()) == 19 — reinforcing the pre-existing test_mh_mn_id_option_length_matches_packed_octets, which already covers the same int case for the default subtype. An over-rejection would not slip through silently.

Ran the new test directly: pytest tests/protocols/internet/test_mh_unit.py -k mn_id -v2 passed, 13 subtests passed (6 from the pre-existing length test + 7 from the new one).

Other claims re-measured

  • Generated captures: ran examples/generators/make_samples.py at 9b26fa387 (PYTHONPATH forced to this worktree, verified via pcapkit.__file__), then again at fa128959e after saving the first set aside. cmp on all five examples/captures/options-{internet,ipv4,ipv6,tcp,transport}.pcap — all five exit 0 (byte-identical). Confirms _mh_option_overrides (examples/generators/options.py:799-847) has no MN_ID_OPTION_TYPE entry, so the generator only ever exercises the untouched IPv6_Address default path.
  • No conflict with corekit: let a nested schema's field callbacks reach the enclosing schema #457: git merge-tree origin/fix-445-nested-packet-context 9b26fa387 → prints only a tree oid (c0b9bd2a...), exit 0, no CONFLICT text. Confirmed clean auto-merge.
  • mypy total: mypy pcapkit/125 errors at both 9b26fa387 and fa128959e; diffed with line/col numbers stripped — 0 lines of difference. No new # type: ignore in production code (the two new # type: ignore[arg-type] are in the new test, calling a private method on an object.__new__(MH) test double — the same pattern every other maker test in this file already uses, e.g. _make_opt_timestamp, _make_opt_ani).
  • Full suite: pytest tests/ at 9b26fa387 (venv has tbtrim/aenum/chardet/dictdumper, so HAS_RUNTIME=True, matching the "17 skipped" branch) → 1007 passed, 17 skipped, 0 failed, 865.51s. Matches the claimed "after" figures exactly.

Verdict

No defects in the 7-subtype rejection itself, the kept-int union trade-off, the docstring, or the test coverage — all confirmed as claimed. One real, newly-introduced (not pre-existing) defect found in the error-formatting code at mh.py:7700-7703: an out-of-range/undefined int subtype (0, negative, or >255) combined with an int identifier makes the new rejection path itself raise a bare ValueError instead of the intended ProtocolError, via Enum_MNIDSubtype(subtype_val)!r failing before construction. This is narrow (requires a malformed subtype on top of the int identifier this PR targets) and doesn't regress the PR's primary fix for any of the 8 real subtypes, so I'm not blocking on it — but it's worth a follow-up given the whole point of this PR (and #465/#458/#438) is eliminating exactly this class of bare-stdlib-exception leak.

GOOD TO MERGE at sha 9b26fa387, with the Enum_MNIDSubtype(subtype_val) edge case above flagged for a follow-up rather than blocking this change.

…Error

_make_opt_mn_id's new int-identifier rejection (9b26fa3) named the resolved
subtype by round-tripping it through Enum_MNIDSubtype(subtype_val) for a
friendly repr. MNIDSubtype._missing_ only auto-extends 9-15 and 16-255, so an
out-of-range subtype (0, negative, or above 255) made that constructor call
itself raise a bare ValueError, escaping in place of the ProtocolError this
branch exists to raise instead. #468 review.

- pcapkit/protocols/internet/mh.py: wrap the round-trip in try/except
  ValueError and fall back to the raw subtype_val for the message on
  failure, so the branch always raises ProtocolError regardless of what
  subtype_val holds. Every real subtype (and the Reserved_N/Unassigned_N
  extensions in 9-255) still gets the named repr; only genuinely
  unrepresentable values fall back to the bare int.
- tests/protocols/internet/test_mh_unit.py: extends
  test_mh_mn_id_option_rejects_int_identifier_for_non_ipv6_subtypes with
  subtype 0, -1, 300, 999, asserting isinstance(e, BaseError).

Checked mh.py for the same pattern elsewhere (an enum constructor called
while formatting a raised message): none found; the only occurrence was the
one being fixed here. _make_index (protocol.py) is unrelated -- for an int
or Enum argument it returns the value/`.value` unvalidated, by design, for
every enum it is used with, not specific to MNIDSubtype.

Full suite (PYTHONSAFEPATH=1, interpreter 3.14.7): 37 passed in
test_mh_unit.py, 283 subtests. mypy: 125 errors, unchanged.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Re-review of the follow-up commit 9b26fa387..1ea8b56ab ("protocols: don't let MN-ID's own rejection message raise a bare ValueError"), dispatched after the earlier verdict at 9b26fa387 was held on a defect found post-hoc. Everything below was re-measured independently in a fresh checkout of 1ea8b56ab (base fa128959e), not assumed from the commit message.

CI

gh pr checks 468 — all 24 checks green (was 10/24 with 11 pending when dispatched): every Compat/Python 3.1x/Integration Python 3.1x job, Analyze, CodeQL, deploy-pages, pyup.io/safety-ci. Docs test gate and Gate (full suite, Python 3.14) show skipping, consistent with being conditional gates, not failures.

The fix itself

try:
    subtype_repr = repr(Enum_MNIDSubtype(subtype_val))
except ValueError:
    subtype_repr = repr(subtype_val)
raise ProtocolError(f'{self.alias}: [OptNo {type}] MN-ID subtype '
                    f'{subtype_repr} identifier must be '
                    f'{expected}, not int')

This is a hybrid of "guard the constructor" and "format the raw value": the round-trip is attempted for a friendly <MNIDSubtype.X: N> repr, and only an unrepresentable subtype_val (0, negative, >255) falls back to the bare int. I agree this is the right scope for the fix. The third option the dispatcher raised — validating subtype_val early and rejecting an out-of-range subtype outright — would be a materially bigger change: right now subtype=0 with a valid bytes identifier already succeeds silently (confirmed below), and early-rejecting the subtype would change that accepting path too, not just this error-formatting branch. That's a legitimate idea but a separate, broader concern from the narrow defect being fixed here.

Re-measuring the six values, plus my own adversarial probing

Confirmed the six measured values exactly (ProtocolError, isinstance(e, BaseError) == True for all of them, IPv6_Address + int still gives length=17/19 packed octets). Then probed further, both through the normal keyword path and through the option= path (which bypasses _make_index entirely and lets subtype_val be anything an attacker-controlled option.subtype holds):

  • Out-of-range ints, huge ints (10**30, -10**30): all ProtocolError, in-library.
  • Non-int subtype on the keyword path (str, float, None, decimal.Decimal) resolve through _make_index's own ProtocolNotImplemented path before ever reaching this code — in-library.
  • A foreign IntEnum member: resolves via value-equality to a real MNIDSubtype, no crash.
  • Via option=, exotic subtype_val — an unhashable list, an unhashable dict, a bare object(), a str — all still come back as ProtocolError, not a bare exception; Enum_MNIDSubtype(...) apparently raises ValueError (not TypeError) even for unhashable inputs, so the except ValueError catches all of these too.

So for every input that reaches the branch this commit touches, the general claim holds: no non-BaseError exception.

However, I found a distinct, pre-existing bare exception in the same function that survives this fix, and it does break the fully general claim for _make_opt_mn_id as a whole (not for the branch this commit fixes). At mh.py:7717, the else: id_len = len(identifier) branch — reached whenever identifier is not an int and subtype_val != IPv6_Address — calls len() unconditionally. An identifier that is neither int nor anything supporting __len__ (e.g. None, a float, or an ipaddress.IPv6Address instance passed for a non-IPv6_Address subtype) raises a bare TypeError, not a BaseError:

subtype=NAI,  identifier=None                -> TypeError: object of type 'NoneType' has no len()
subtype=IMSI, identifier=1.5                 -> TypeError: object of type 'float' has no len()
subtype=0,    identifier=None                -> TypeError: object of type 'NoneType' has no len()

I verified this is pre-existing, not introduced by either commit in this PR: reproduced identically on a clean checkout of main (fa128959e), before 9b26fa387 touched the function at all — the else branch is untouched by both commits under this PR. It's also a different shape from the two issues already filed against this function: #467 was about int identifiers (fixed here), #469 is about bytes vs str mismatch per subtype (both of which support len() and fail later, at pack()); this is about identifier values that don't support len() at all and fail earlier, at construction. Reporting it rather than filing it, per instruction — it doesn't bear on this follow-up's correctness since the follow-up doesn't touch that branch.

"Same trap elsewhere" — checked independently

  1. Other makers in mh.py formatting an enum constructor into a message the same way: grep -n "Enum_[A-Za-z]*(" pcapkit/protocols/internet/mh.py (excluding comments/annotations) turns up exactly two constructor calls in the whole file: the one this commit fixes, and Enum_DHCPSupportMode(schema.flags['S']) at line 4321 — inside a read_opt_* parse method building a Data object, not formatting an error message. Confirms the commit message's claim: no other occurrence of this pattern.
  2. Should _make_index reject an out-of-range value itself? Read protocol.py:1138-1195: for an int or Enum argument it takes index = name / name.value directly (lines 1164-1167), completely unvalidated, and this is the shared path for every enum/index _make_index is used with across the codebase — not specific to MNIDSubtype. Adding range validation there would be a much broader behavioral change than this narrow fix warrants. Agree with leaving it alone here.

Test adequacy, verified to actually discriminate

The extended test adds subtype values 0, -1, 300, 999 to the existing 7-subtype loop and asserts isinstance(e, BaseError) (previously just ProtocolError, still satisfied since ProtocolError subclasses BaseError). To confirm it isn't a test that passes regardless:

  • Replaced pcapkit/protocols/internet/mh.py in the working tree with 9b26fa387's content (pre-fix), keeping the post-fix test file.
  • pytest tests/protocols/internet/test_mh_unit.py -k mn_id -v4 SUBFAILED (subtype=0,-1,300,999), with the actual traceback landing exactly where expected:
    pcapkit/protocols/internet/mh.py:7702: in _make_opt_mn_id
        f'{Enum_MNIDSubtype(subtype_val)!r} identifier must be '
    ...
    ValueError: 999 is not a valid MNIDSubtype
    
  • git checkout -- pcapkit/protocols/internet/mh.py to restore, then git status/git diff --stat — clean, no leftover changes.
  • Re-ran post-restore: pytest tests/protocols/internet/test_mh_unit.py -v37 passed, 283 subtests, matching the commit message's own figure exactly.

Spot-checking the rest of the PR at the new sha

Re-verified myself:

  • mypy pcapkit/125 errors, matching the prior review's count at both 9b26fa387 and fa128959e.
  • pytest tests/protocols/internet/ (171 tests) → 171 passed, 469 subtests, 0 failed.
  • pytest tests/ --collect-only1024 tests collected, no collection errors (full-suite import/collection sanity, not a full run).
  • Generated captures: ran examples/generators/make_samples.py at 9b26fa387 and independently at 1ea8b56ab (two separate worktrees, PYTHONPATH forced and pcapkit.__file__ checked in each) — cmp on all five examples/captures/options-*.pcap : all identical.
  • git merge-tree pr457 1ea8b56ab → prints only a tree oid, exit 0, no CONFLICT — clean against corekit: let a nested schema's field callbacks reach the enclosing schema #457's branch at the new sha.

Taken on trust from the prior review rather than re-run myself: the full 1007-test/17-skipped suite figure (I ran the tests/protocols/internet/ subset plus a full collection check instead, which is proportionate for a 2-file, 34-line follow-up) and the field-class reasoning (BytesField/StringField/_IPAddressField behavior) underlying why "reject" is the right call for #467 itself — unchanged by this commit and not worth re-deriving.

Verdict

This supersedes the verdict at 9b26fa387. The follow-up is correct, proportionate, and adequately tested: the specific defect (an out-of-range subtype making the naming round-trip itself raise a bare ValueError) is fixed for every input that reaches that branch, including inputs beyond the six originally measured, and the new test genuinely fails without the fix. It does not disturb any of the previously-verified behavior (subtype rejection, IPv6_Address accept path, generated captures, merge-tree, mypy count). One distinct, pre-existing, out-of-scope defect in the same function's else branch (a bare TypeError for len()-incompatible identifiers) was found and is being reported separately rather than filed here.

GOOD TO MERGE at sha 1ea8b56ab.

Comment thread pcapkit/protocols/internet/mh.py Outdated
subtype_namespace: MN-ID subtype namespace.
subtype_reversed: MN-ID subtype reversed flag.
identifier: Identifier.
identifier: Identifier. An :obj:`int` remains accepted for the

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must not accept int? But can't we directly use Ipv6Address class to convert and validate an int?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — int is accepted again as of a8c214ab4, and you were right that the earlier rejection was the wrong call.

On IPv6Address: that path never changed and still does exactly what you describe — ipaddress.IPv6Address(identifier) both converts and validates, at the spec-fixed 16 octets (#448). The rejection I had added only ever covered the other subtypes, and the reasoning behind it was wrong: I claimed there was no non-arbitrary width for them. There is. id_len = math.ceil(identifier.bit_length() / 8) was right all along — self-consistent with the declared length by construction. The actual pre-#467 defect was never the sizing; it was that identifier stayed an int afterwards and reached BytesField unconverted, which struct.pack() cannot do anything with. So the six BytesField subtypes now convert via identifier.to_bytes(id_len, 'big'), floored at one octet so identifier 0 does not collapse into "no identifier" (bit_length() is 0 for 0).

Measured through the maker → pack()unpack(), not a hand-built schema — all 8 subtypes × {0, 1, 0xff, 0x1234, 0x100000000, 2**128-1, True}:

subtype 0 0x1234 2**128-1
IPv6_Address len=17 :: len=17 ::1234 len=17 ffff:…:ffff
IMSI / P_TMSI / EUI_48_address / EUI_64_address / GUTI / DUID len=2 b'\x00' len=3 b'\x124' len=17 16×\xff

Every one round-trips identically through unpack().

NAI is the one subtype that still refuses an int, and that is a judgement call rather than a mechanical limit — say the word and I will change it. Its field is a StringField, so str(identifier) packs and round-trips perfectly well; the argument for refusing is that an NAI is a network access identifier (user@realm, RFC 4283), so a bare decimal-digit string is mechanically valid and semantically nonsense — the same "accepts a value that means the wrong thing" #467 removed, just relocated. The error message names str(...) explicitly so a caller who really wants that can spell it, and subtype=NAI, identifier=str(0x1234) packs to a payload decoding as '4660'.

One thing I found while checking this, which was not in the revision you commented on: the negative-int guard lived inside the elif isinstance(identifier, int) branch, which the IPv6_Address dispatch never reaches. So identifier=-5, subtype=IPv6_Address leaked AddressValueError: -5 (< 0) is not permitted as an IPv6 address — and AddressValueError subclasses ValueError, i.e. a bare stdlib exception escaping the very handler meant to stop that. Hoisted the guard above the subtype dispatch; -5 now raises ProtocolError on all eight subtypes. Measured before and after, not inferred.

Wrong-type identifiers (bytes for NAI, str for the octet subtypes, and so on) still escape as bare stdlib exceptions. That is unchanged from main and tracked separately as #469, deliberately not folded in here.

tests/protocols/internet/test_mh_unit.py: 37 passed, 318 subtests, 0 failed.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the __init__ stub: the maker does already accept intpcapkit/protocols/internet/mh.py:7637 declares identifier: 'bytes | str | IPv6Address | int'. What excludes int is the schema stub and the data-model stub, and that is deliberate, because the three annotations describe three different boundaries:

annotation what it describes int?
MH._make_opt_mn_id (mh.py:7637) what a caller may pass yes
Schema_MNIDOption.__init__ (schema/internet/mh.py:734) what the schema can hold no
Data_MNIDOption.__init__ (data/internet/mh.py:756) what a parse produces no

The maker converts before it ever constructs the schema, so no int reaches either of the lower two. Measured through the maker rather than read off the code:

IMSI           identifier=b'\x124'                type=bytes
DUID           identifier=b'\x124'                type=bytes
IPv6_Address   identifier=IPv6Address('::1234')   type=IPv6Address

All three from identifier=0x1234. So widening the schema stub would document a value the schema can never hold — and it would actively mislead, because it would imply Schema_MNIDOption(identifier=0x1234) works. It does not: mn_id_selector resolves every subtype but IPv6_Address to a StringField or BytesField, and handing either a raw int is precisely the #467 defect, since struct.pack() cannot consume it. The data-model stub is the same story from the other direction — a parse yields str, bytes or IPv6Address, never an int, so int there would describe a state that cannot occur.

Your question did expose a real problem with the comment rather than the annotation, now fixed in 4bebfd4c0 (comment only, no annotation and no executable line changed). The old note said just "neither of which accepts an int (c.f. #467)", which was true but read as though int were rejected outright — no longer the case anywhere a caller can see, and confusing precisely because the maker above now takes one happily. It now states the boundary distinction and carries the measurement.

If you would rather the schema stub admit int anyway — for symmetry with the maker, on the grounds that a reader looking at one will look at the other — say so and I will widen it, but it would be documenting an unreachable state and I would want to note that inline.

Also merged origin/main (f7b5cc5cd, #470) in; clean, and it touches only ipaddress.py and its tests, neither owned by this branch. tests/protocols/internet/test_mh_unit.py unchanged at 37 passed, 332 subtests, 0 failed. Since 4bebfd4c0 is comment-only, the GOOD TO MERGE at 6a9e4e427 still stands by content.

)

The owner asked for int to stay accepted rather than be turned away, and
the reasoning behind the earlier rejection was wrong. `id_len =
math.ceil(identifier.bit_length() / 8)` was the right, non-arbitrary
width all along -- self-consistent with the declared `length` by
construction. The pre-#467 defect was never the sizing; it was that
`identifier` stayed an `int` afterwards and reached `BytesField`
unconverted, which `struct.pack()` cannot do anything with. So the six
`BytesField` subtypes (IMSI, P_TMSI, EUI_48/64_address, GUTI, DUID) now
convert via `identifier.to_bytes(id_len, 'big')`, with the width floored
at one octet so that identifier 0 does not collapse into "no identifier"
(`bit_length()` is 0 for 0).

NAI still rejects an int, and that is a judgement call rather than a
mechanical limit: its field is a `StringField`, and `str(identifier)`
packs and round-trips perfectly well -- but an NAI is a network access
identifier (`user@realm`, RFC 4283), so a bare decimal-digit string is
mechanically valid and semantically nonsense. That is the same "accept a
value that means the wrong thing" #467 removed, only relocated. The
message names `str(...)` so a caller who really wants that can spell it.

A negative int is rejected for *every* subtype, and the guard sits before
the subtype dispatch rather than inside the int branch. Inside it, the
`IPv6_Address` dispatch never reaches the guard, so `identifier=-5,
subtype=IPv6_Address` leaked `AddressValueError: -5 (< 0) is not
permitted as an IPv6 address` -- a bare ValueError escaping the handler
whose whole purpose is to stop that. Measured before the hoist, not
inferred.

`IPv6_Address` itself is untouched: still `ipaddress.IPv6Address(...)`,
which converts and validates, at a fixed 16 octets (#448).

Verified through the maker, not a hand-built schema: every one of the
eight subtypes x {0, 1, 0xff, 0x1234, 0x100000000, 2**128-1, True} run
maker -> pack() -> unpack(), all round-tripping identically, and -5
raising ProtocolError on all eight. Wrong-*type* identifiers (bytes for
NAI, str for the octet subtypes, and so on) still escape as bare stdlib
exceptions -- unchanged by this PR, tracked separately as #469.

tests/protocols/internet/test_mh_unit.py: 37 passed, 318 subtests, 0
failed. Merged origin/main (5182ad0) first.
Comment thread pcapkit/protocols/internet/mh.py
@JarryShaw

JarryShaw commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Review of PR #468 at a8c214ab4

CI

gh pr view 468 --json statusCheckRollup --jq '[.statusCheckRollup[]|select(.__typename=="CheckRun")|.conclusion//.status]|group_by(.)|map("\(.[0]):\(length)")|join(", ")'

Final result (after waiting for a few in-flight runs to finish): SKIPPED:2, SUCCESS:21. The two skips are Docs test gate and Gate (full suite, Python 3.14), both by design on pull_request. The one StatusContext (pyup.io/safety-ci) also reports SUCCESS. Fully green.

What I verified independently

The governing fact. Read mn_id_selector at pcapkit/protocols/schema/internet/mh.py:341-357 via git show origin/fix-467-mn-id-int-identifier:pcapkit/protocols/schema/internet/mh.py: it has exactly three branches -- StringField for NAI, IPv6AddressField for IPv6_Address, BytesField(length=pkt['length'] - 1) for everything else. Confirmed: EUI_48_address/EUI_64_address have no spec-fixed width in this codebase to violate.

The negative-guard hoist. git diff origin/main origin/fix-467-mn-id-int-identifier -- pcapkit/protocols/internet/mh.py shows the if isinstance(identifier, int) and identifier < 0: guard sitting before the if subtype_val == Enum_MNIDSubtype.IPv6_Address: dispatch in a8c214ab4 -- it's already hoisted at this sha (there's no separate uncommitted fix to check; it's baked into the reviewed commit).

Full existing suite, fixtures regenerated first:

PYTHONPATH=<worktree> python examples/generators/make_samples.py   # 18 captures written
PYTHONPATH=<worktree> python -m pytest tests/protocols/internet/test_mh_unit.py -q
# 37 passed, 8 warnings, 318 subtests passed in 33.56s

Matches the claimed baseline exactly.

Independent maker -> pack -> unpack round trips (own script, not the existing tests), verified with pcapkit.__file__ asserted to start with the worktree root before every run:

  • All 8 subtypes x {0, 1, 255, 256, 2**64-1, 2**64, 2**128-1, True}: for the six BytesField subtypes, declared_len == id_len == max(1, ceil(bit_length/8)) == len(actual octets), and int.from_bytes(packed_identifier, 'big') == original int in every case, through a real Schema_MNIDOption.unpack() of the packed bytes (not a hand-built schema). Full command output for this is in my working notes, not reproduced here in full; e.g. 2**64 -> 9 octets b'\x01\x00...', 2**128-1 -> 16 octets of \xff, True -> b'\x01', 0 -> b'\x00' with declared length 1 (the floor).
  • -5 on all 8 subtypes (including IPv6_Address) raises ProtocolError (a BaseError) -- no leak anywhere, confirming the hoist works: IPv6_Address + -5 -> MH: [OptNo 8] MN-ID subtype <MNIDSubtype.IPv6_Address: 2> identifier must be a non-negative int, not -5.
  • NAI + positive int 0x1234 -> ProtocolError containing str(4660); NAI + -5 -> the generic "non-negative" guard message, not the NAI-specific one (correct, since the guard runs before subtype dispatch); NAI + str(0x1234) packs and unpacks back to '4660'.
  • import math is used ~30 other places in the file (lifetime_val = math.ceil(...), timestamp handling, etc.) -- still clearly warranted.
  • Enum_MNIDSubtype._missing_ (pcapkit/const/mh/mn_id_subtype.py:62-77) confirmed to raise a bare ValueError for 0, negatives, and >255 -- exactly what the code comment claims, and exactly what the try/except ValueError: subtype_repr = repr(subtype_val) guards against. Verified this can't accidentally swallow the guard's own raise ProtocolError(...), since that raise is textually after the try/except, and it's the only except ValueError in the file.

Wrong-type identifiers (declared out of scope, #469). Ran bytes for NAI, str/float/None/list/dict for IMSI, against both a8c214ab4 and origin/main (checked out file-by-file with git checkout <ref> -- <path>, same worktree, then restored). Byte-for-byte identical bare exceptions on both: NAI+bytes -> AttributeError: 'bytes' object has no attribute 'encode' at pack time; IMSI+str/list/dict -> struct.error: argument for 's' must be a bytes object at pack time; IMSI+float/None -> TypeError: object of type '...' has no len() at construction time (the else: id_len = len(identifier) branch). Confirmed genuinely unchanged, not worsened by this PR.

New, non-blocking finding (posted inline)

While attacking "does anything still escape as a non-BaseError", found that identifier = ipaddress.IPv6Address(identifier) (mh.py:7711) still leaks a bare ipaddress.AddressValueError (a ValueError subclass) for an int identifier >= 2**128 under subtype=IPv6_Address -- the upper-bound mirror of the exact -5 leak this PR's guard just closed on the lower bound. Confirmed via git checkout origin/main -- pcapkit/protocols/internet/mh.py that this is byte-for-byte unchanged from main (pre-existing from #448), so it is not a regression introduced by this diff and doesn't contradict anything the PR's docstring claims. Posted as an inline comment rather than a blocker; flagged as a plausible follow-up parallel to #469.

On the NAI judgment call (the thing most likely to draw pushback)

I think rejecting int for NAI while converting it for the other six subtypes is defensible, not a contradiction of "the owner asked int to be accepted." The owner's ask was about the general case of #467 -- an int shouldn't crash with a bare struct.pack failure -- and for IMSI/P_TMSI/EUI_48_address/EUI_64_address/GUTI/DUID, the identifier is numeric, so int.bit_length()-sized big-endian bytes is a non-arbitrary, canonical wire form. NAI (RFC 4283 user@realm) is not numeric -- str(identifier) would pack and round-trip fine, but it manufactures a decimal-digit string that is syntactically an NAI and semantically nothing a caller meant. Rejecting it, with the exact str(...) spelling in the error message so nothing is actually lost, converts "silently produces wrong output" into "loudly asks for the explicit call" -- which is exactly the category of defect #467 was filed over, just relocated. I'd call this correct engineering judgment rather than a departure from the ask, but it's a judgment call and the one place I'd expect the owner to push back if they disagree.

Arithmetic boundary check (item 3)

max(1, math.ceil(identifier.bit_length() / 8)) at 0, 1, 255, 256, 2**64-1, 2**64, 2**128-1 -- all verified above via full pack/declared-length/unpack agreement. No off-by-one anywhere; the max(1, ...) floor is exercised only at identifier == 0 (and False), where bit_length() is 0 and would otherwise declare a zero-octet identifier.

Docstring / NOTE accuracy (item 4)

The Raises: clause names exactly the two cases the function itself explicitly raises for (negative int; int with NAI) and doesn't overclaim completeness -- it's accurate, if (by design, and already tracked separately) not exhaustive of every possible escape. The long NOTE: comments' factual claims -- int.to_bytes raising OverflowError on negative input, AddressValueError subclassing ValueError, _missing_'s 9-15/16-255 auto-extension range -- all checked out exactly against the actual stdlib/enum behavior.

Verdict

The fix does what it says: converts an int identifier to the wire form each BytesField subtype's schema actually expects, leaves IPv6_Address on its existing (#448) path, keeps NAI rejecting int for a defensible reason, and the negative-int guard is correctly hoisted above the subtype dispatch so no subtype -- including IPv6_Address -- leaks a bare exception for a negative identifier. Full existing suite passes (37/318/0), CI is green, and my own independent round-trip/boundary/leak testing agrees with every claim in the PR description. The one new gap I found (oversized positive int + IPv6_Address leaking AddressValueError) is pre-existing on origin/main, unchanged by this diff, and posted as a non-blocking inline note rather than a defect in this PR.

GOOD TO MERGE at a8c214ab4

The review of a8c214a found the upper-bound mirror of the leak that
sha's own guard had just closed: an int identifier at or above 2**128
with subtype=IPv6_Address still reached ipaddress.IPv6Address() and
raised AddressValueError, which subclasses ValueError -- a bare stdlib
exception escaping the handler whose purpose is to stop that. Verified
before fixing: 2**128 and 2**140 both leaked, while 2**128-1 packed
normally at length 17.

The bound is subtype-*dependent*, so the check goes inside the
IPv6_Address branch rather than into the shared negative-int guard above
it: 2**140 is a perfectly good identifier for the six BytesField
subtypes, which simply produce more octets (length 19 for 2**140, 18 for
2**128). The test now asserts that too, so a future guard cannot be
hoisted to cover them by mistake.

Checked explicitly rather than by wrapping the IPv6Address construction,
because wrapping would also swallow the wrong-TYPE AddressValueError a
str or None produces there -- that is #469's subject and not this PR's
to annex.

Pre-existing since #448, not a regression from this branch: the
construction line is byte-for-byte unchanged from origin/main.

How the gap survived the first pass, since it is the more useful part:
my probe swept every subtype but stopped at 2**128-1, exactly one value
below where the answer changes. Probing a large value is not probing the
boundary.

Verified: 2**128 and 2**140 now raise ProtocolError for IPv6_Address and
still pack for the six BytesField subtypes. Disabling only the new guard
condition fails the two new subtests and nothing else; restoring it
passes. tests/protocols/internet/test_mh_unit.py: 37 passed, 332
subtests, 0 failed (318 before, +14 new).
Comment thread pcapkit/protocols/internet/mh.py Outdated

Raises:
ProtocolError: If ``identifier`` is a negative :obj:`int` (no
subtype has a wire form for one), or an :obj:`int` of any value

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, minor: this Raises: enumeration is now stale.

It lists two conditions -- a negative int, and any int with NAI -- but the guard added a few lines below in this same commit (if isinstance(identifier, int) and identifier >= 1 << 128: ... raise ProtocolError(...)) introduces a third: an int identifier >= 2**128 with subtype=IPv6_Address. Verified directly against 6a9e4e427:

proto._make_opt_mn_id(Option.MN_ID_OPTION_TYPE, subtype=MNIDSubtype.IPv6_Address, identifier=2**128)
# ProtocolError: MH: [OptNo 8] MN-ID subtype IPv6_Address identifier must be an int below 2**128, not 340282366920938463463374607431768211456

That's a third way this method raises ProtocolError that the docstring doesn't mention. Suggest appending something like:

or an ``int`` of ``2**128`` or above with the ``IPv6_Address`` subtype

Not requesting changes for it -- it's a doc-completeness gap, not a behavioral one, and everything the docstring does say remains true.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and fixed in 5cf1740ad — docstring only, no executable line changed.

You caught a defect I introduced in the very commit you were reviewing: I rewrote that Raises: clause two commits earlier, then added the upper-bound guard without going back to it. Three ways to raise ProtocolError, two documented.

The clause now lists all three, and I measured each rather than reading the code and assuming:

negative int, any subtype            -> ProtocolError (BaseError=True)
int with NAI                         -> ProtocolError (BaseError=True)
int >= 2**128 with IPv6_Address      -> ProtocolError (BaseError=True)

I also added why the ceiling exists only for IPv6_Address — its wire form is a fixed 16 octets, where the other subtypes have no ceiling and simply pack into more — since the asymmetry is the part a caller would otherwise read as an inconsistency rather than a rule.

tests/protocols/internet/test_mh_unit.py unchanged at 37 passed, 332 subtests, 0 failed. Since this is a docs-only follow-up, your GOOD TO MERGE at 6a9e4e427 stands by content; only the sha has moved.

Two other things from your review worth acknowledging rather than letting pass:

Your worktree started at 5182ad0ce, not at the PR head, despite the branch name matching — and you caught it yourself by diffing test-method names and checking git rev-parse HEAD, then re-ran everything from git checkout 6a9e4e427. That is the most useful thing in the report. An agent worktree does not start at the PR head, and a grep or a pytest run against the plain working tree silently measures mainline. Every brief from here on will say so explicitly.

You declined to confirm the "disabling the guard fails exactly the two new subtests" claim rather than repeating it, because reproducing it needs an edit to tracked source and your mandate was review-only — and because setUp's purge_modules(['pcapkit']) reloads the package from disk before every test, defeating an in-memory monkeypatch. Both correct. For the record, that claim is mine and I did verify it by editing the guard's condition to if False: in my own worktree: exactly the two new IPv6_Address subtests failed, nothing else, and restoring it passed. Reporting it as unverified-but-plausible was the right call on your side.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of PR #468 at 6a9e4e427

Picking this up because the delta since a8c214ab4 (where the previous reviewer left GOOD TO MERGE plus a non-blocking inline finding, comment 4047512161) is exactly one commit -- 6a9e4e427, "protocols: guard the IPv6_Address identifier's upper bound too (#467)" -- which fixes that finding. Confirmed the scope directly:

git log a8c214ab4..origin/fix-467-mn-id-int-identifier --oneline
6a9e4e427 protocols: guard the IPv6_Address identifier's upper bound too (#467)

git diff a8c214ab4..origin/fix-467-mn-id-int-identifier touches only pcapkit/protocols/internet/mh.py (the new guard, inside the if subtype_val == Enum_MNIDSubtype.IPv6_Address: branch, before the ipaddress.IPv6Address(identifier) construction) and tests/protocols/internet/test_mh_unit.py (14 new subtests). No other files changed.

CI

gh pr view 468 --json statusCheckRollup --jq '[.statusCheckRollup[]|select(.__typename=="CheckRun")|.conclusion//.status]|group_by(.)|map("\(.[0]):\(length)")|join(", ")'
SKIPPED:2, SUCCESS:21

Docs test gate and Gate (full suite, Python 3.14) are the 2 skips (skip-by-design on pull_request). The pyup.io/safety-ci StatusContext (not a CheckRun) is SUCCESS. Two Integration jobs (Python 3.10, 3.12) were still IN_PROGRESS on my first check; I waited them out -- both completed SUCCESS, giving the 21+2 fully-green rollup above. Confirmed the head sha under review matches the PR's live head: gh pr view 468 --json headRefOid6a9e4e427d145427c53ae9e940535217ce17fc06.

One thing I got wrong before trusting any of my own numbers, worth flagging for anyone repeating this

My worktree's checked-out HEAD was 5182ad0ce (mainline), not 6a9e4e427, despite the branch name. A first pass of grep/pytest against the plain working tree was silently testing the wrong commit (36 tests / 272 subtests, and a test method that looked "missing"). Caught it by diffing test-method names between git show a8c214ab4:... and the working tree and finding a real discrepancy, then checking git rev-parse HEAD. Fixed by git checkout 6a9e4e427 in this worktree before doing anything else. All results below are from that checkout, with pcapkit.__file__ printed and asserted to start with the worktree root before every measurement, per the brief's requirement.

1. Guard placement and bound

if isinstance(identifier, int) and identifier >= 1 << 128:
    raise ProtocolError(...)
if not isinstance(identifier, ipaddress.IPv6Address):
    identifier = ipaddress.IPv6Address(identifier)

Probed the boundary and the type-safety edges directly against 6a9e4e427:

input result
2**128 - 1 OK, length=17
2**128 ProtocolError: "...must be an int below 2**128, not 340282366920938463463374607431768211456"
2**128 + 1 ProtocolError
2**140 ProtocolError
True (bool, IPv6_Address) OK, length=17 -- isinstance(True, int) is True but True == 1 < 2**128, so the guard correctly leaves it alone
False (bool, IPv6_Address) OK, length=17
ipaddress.IPv6Address(2**128-1) passed directly OK, length=17 -- not an int instance, guard doesn't fire, unaffected
'::1' (str) OK -- bypasses the guard, reaches ipaddress.IPv6Address('::1') as before

1 << 128 == 2**128 and the comparison is >=, so the boundary is exactly right: the last value ipaddress.IPv6Address accepts (2**128-1) still packs, the first value it rejects (2**128) is now caught in-library instead of leaking. The guard is isinstance-scoped to int specifically, so it does not affect bool (still an int subtype but numerically in-range), pre-built IPv6Address instances, or strings -- all confirmed above.

2. Was the narrow fix (vs. wrap-in-try/except) the right call?

Agree with the scoping. Verified the two behaviors the PR body claims are the deciding factor:

A try/except wrapper would have been the shorter diff, but it would have silently pre-empted #469 with an untested, undiscussed exception-message shape for the wrong-type cases, and conflated two independent defects (wrong magnitude vs. wrong type) under one CR. The explicit isinstance + bound check is a few more lines for a fix that stays inside its own issue's boundary and keeps #469 fully open and well-defined. I'd have made the same call.

3. Did the fix disturb anything confirmed at a8c214ab4?

Re-ran, against 6a9e4e427, everything the previous review recorded as confirmed:

  • All 8 subtypes (NAI excluded from the int round-trip, IPv6_Address, IMSI, P_TMSI, EUI_48_address, EUI_64_address, GUTI, DUID) × {0, 1, 255, 256, 2**64-1, 2**64, 2**128-1, True}, through maker → pack()unpack(): declared length matches actual packed octet count, and the recovered value (int.from_bytes for the six BytesField subtypes, int(ipaddress.IPv6Address(...)) for IPv6_Address) equals the original input exactly, for all 56 combinations. Zero mismatches.
  • identifier=-5 on all 8 subtypes: all raise ProtocolError (none leak a different exception type).
  • NAI + 0x1234: message contains str(4660) -- confirmed ('str(4660)' in str(e) is True).
  • NAI + -5: gives the generic non-negative-int message ("...must be a non-negative int, not -5"), not the NAI-specific "must be str, not int" message -- confirmed, since the negative-int guard runs before the subtype dispatch.
  • NAI + str(0x1234): round-trips, packed payload decodes to '4660' -- confirmed.

Also re-derived the subtest counts independently rather than trusting the brief's numbers: checked out a8c214ab4, regenerated fixtures, ran tests/protocols/internet/test_mh_unit.py37 passed, 8 warnings, 318 subtests passed. Checked out 6a9e4e427 (PR head), regenerated fixtures, ran the same file → 37 passed, 8 warnings, 332 subtests passed. 332 - 318 = 14, matching the diff's two new subTest loops (2 cases for IPv6_Address over-bound + 6 subtypes × 2 id_len cases = 14).

4. Wrong-type leaks vs. origin/main

Compared the same battery of wrong-type inputs (bytes/str/float/None/list/dict) against NAI, IMSI, and IPv6_Address on origin/main (5182ad0ce, which predates the entire #467 branch) and on 6a9e4e427. Exception type and message text are identical in every case I tested -- e.g. IPv6_Address + bytes(b'xyz') gives AddressValueError: b'xyz' (len 3 != 16) is not permitted as an IPv6 address on both; NAI + bytes gives AttributeError: 'bytes' object has no attribute 'encode' on both; IMSI + str gives struct.error: argument for 's' must be a bytes object on both. The only input where the two commits diverge is IPv6_Address + int >= 2**128, which goes from AddressValueError on origin/main to ProtocolError on 6a9e4e427 -- exactly the intended change, and exactly nothing else. These wrong-type leaks belong to #469 and are unaffected by this PR, as claimed.

5. Comment block, docstring, and PR body -- anything measurably false?

  • The new in-code comment block (explaining why the guard sits inside the IPv6_Address branch rather than the shared negative-int guard, and why it's an explicit check rather than a try/except) matches everything I measured above.
  • One real gap, filed as an inline comment (id 4047785617, line 7666): the docstring's Raises: clause still lists only two ProtocolError conditions (negative int; any int with NAI) and was not updated to mention the third this same commit adds (IPv6_Address int >= 2**128). Non-blocking doc-completeness issue, not a behavioral one.
  • PR body claim "the construction line is byte-for-byte unchanged from origin/main" (referring to if not isinstance(identifier, ipaddress.IPv6Address): identifier = ipaddress.IPv6Address(identifier)) -- confirmed via git diff origin/main..6a9e4e427 -- pcapkit/protocols/internet/mh.py: those two lines appear only as unchanged context, never touched by either commit in this branch's history.
  • PR body claim "disabling only the new guard's condition fails exactly the two new subtests and nothing else" -- could not independently execute this exact mutation without editing the tracked source file, which is outside my review-only mandate. I tried an in-memory-only equivalent (monkeypatching a modified copy of MH._make_opt_mn_id onto the class), but test_mh_unit.py's setUp calls purge_modules(['pcapkit']) before every test, which forces a fresh disk-based re-import of the whole pcapkit package (and hence a brand-new, unpatched MH class) before each test method runs -- so a pure in-memory patch has no effect on the suite. I confirmed the patch mechanism itself was sound (calling the patched function directly, outside the test harness, correctly raised the underlying AddressValueError instead of ProtocolError when the guard was disabled), which is consistent with the claim, but I can't independently execute the exact mutation-testing claim as stated without editing mh.py. Flagging this as an unverified-but-plausible claim rather than repeating it as confirmed.

Test suite

tests/protocols/internet/test_mh_unit.py, at a8c214ab4:  37 passed, 8 warnings, 318 subtests passed in 34.60s
tests/protocols/internet/test_mh_unit.py, at 6a9e4e427:  37 passed, 8 warnings, 332 subtests passed in 34.22s

Both runs used PYTHONPATH=<worktree root> with pcapkit.__file__ asserted to start with the worktree root beforehand, and examples/generators/make_samples.py re-run against each checkout before testing.

Verdict

One non-blocking documentation gap filed inline (stale Raises: enumeration); everything else -- the guard's placement, its exact boundary, its bool/IPv6Address-instance/str bypass behavior, the narrow-vs-wrap scoping decision, all previously-confirmed round-trip and rejection behavior, and the wrong-type leaks' byte-for-byte equivalence to origin/main -- checks out. CI is fully green (21 SUCCESS + 2 SKIPPED-by-design CheckRuns, plus pyup.io/safety-ci SUCCESS).

GOOD TO MERGE at 6a9e4e427

Docstring only; no executable line changes.

The review of 6a9e4e4 caught that its Raises: clause was left stale by
that same commit. It enumerated two conditions -- a negative int, and an
int of any value with NAI -- while the guard added three lines below
introduces a third: an int of 2**128 or above with IPv6_Address. Three
ways to raise ProtocolError, two documented.

Verified all three raise ProtocolError (a BaseError) at this sha, rather
than reading the code and assuming.

The clause now also says why the ceiling exists only for IPv6_Address --
its wire form is a fixed 16 octets, where the other subtypes have no
ceiling and simply pack into more -- because the asymmetry is the part a
caller would otherwise read as an inconsistency.

tests/protocols/internet/test_mh_unit.py: 37 passed, 332 subtests, 0
failed, unchanged.
# subtype resolves to a :class:`~pcapkit.corekit.fields.strings.StringField`
# or :class:`~pcapkit.corekit.fields.strings.BytesField`, neither of
# which accepts an ``int`` (c.f. #467).
def __init__(self, type: 'Enum_Option', length: 'int', subtype: 'Enum_MNIDSubtype', identifier: 'bytes | str | IPv6Address') -> 'None': ...

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we still drop the int here?

…r takes one (#467)

Comment only; no executable line changes and no annotation changes.

The owner asked, reasonably, why the __init__ type stub does not accept
int now that the maker does. The answer is that the two annotations
describe different boundaries and the old comment did not say so: the
maker's `identifier: 'bytes | str | IPv6Address | int'` is what a caller
may pass, while the schema's is what the schema can hold, and the maker
converts between them before constructing the schema at all.

Measured through the maker rather than read off the code: identifier
0x1234 arrives at the schema as b'\x124' for IMSI and DUID and as
IPv6Address('::1234') for IPv6_Address. No int ever reaches the
constructor, so admitting one in the stub would document a value the
schema cannot hold -- and would mislead, because handing a raw int to the
StringField or BytesField that mn_id_selector resolves for every subtype
but IPv6_Address is exactly the #467 defect: struct.pack() cannot consume
it.

The previous comment said only "neither of which accepts an int", which
was true but read as though int were rejected outright, which is no
longer the case anywhere the caller can see.

Merged origin/main (f7b5cc5, #470) first -- clean, touching only
ipaddress.py and its tests, neither owned by this branch.
tests/protocols/internet/test_mh_unit.py: 37 passed, 332 subtests, 0
failed, unchanged.
@JarryShaw
JarryShaw merged commit d0f765a into main Sep 18, 2026
12 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MH MN-ID: an int identifier cannot be packed for any subtype except IPv6_Address, and its declared length is wrong

1 participant