Skip to content

Attach structured diagnostics to every ProtocolError - #127

Merged
EONRaider merged 1 commit into
masterfrom
claude/decoder-depth-polish-8m54to
Sep 3, 2026
Merged

Attach structured diagnostics to every ProtocolError#127
EONRaider merged 1 commit into
masterfrom
claude/decoder-depth-polish-8m54to

Conversation

@EONRaider

Copy link
Copy Markdown
Owner

Summary

Strictness is this library's security story — it raises where scapy silently
fills in defaults — but every exception carried only a formatted string.
Verified in the issue: the full attribute set on a raised
TruncatedHeaderError was args, add_note, with_traceback. A fuzzing
harness, conformance suite, or protocol-validation tool that wanted to know
where a parse failed had to regex the message.

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(e.protocol, e.field, e.offset, e.frame_offset, e.expected, e.actual)
# <class 'netprotocols.layer3.ip.IPv4'> ihl 0 14 >=5 0

Closes#91.

What's included

ProtocolError gains five optional attributesprotocol, field,
offset, frame_offset, expected/actual — all defaulting to None, all
subclasses inheriting the same __init__ automatically.

protocol is set at all 62 raise sites bar one, verified by an AST sweep
rather than by eye — tests/test_diagnostics.py::TestEveryRaiseSiteIsInstrumented
pins the sweep itself as a regression test (confirmed to fail when a site is
reverted, see Notes). The one exception is random_mac()'s manufacturer-code
check, which validates a free-standing argument belonging to no header;
documented at the site and asserted by its own test.

offset is scoped honestly rather than uniformly:

  • Relative to the data argument of a decode() call, for a fixed-header
    error (_unpack_fixed, IHL/data-offset checks).
  • Relative to the bytes attribute field names (options, body,
    sections), for an on-demand parse — a TCP option error's offset is
    relative to header.options, never the frame.
  • None, never guessed, for a __post_init__ validation error, which sees
    field values and never the bytes they came from.

decode_frame rebases to frame_offset. It's the only code holding the
cursor needed to translate a layer-relative offset to the whole frame, so it's
the only thing that sets it — added to the walk's except block, running
whether the walk is strict or lax, so a caller catching the re-raise in strict
mode also gets a correct frame_offset. A bare SomeClass.decode() call
leaves it None — there's no frame to rebase against.

validate_mac_addr()/validate_ipv4_addr() gained optional
protocol=/field= parameters so the three __post_init__ call sites
(Ethernet, ARP, IPv4) can attach the field they're validating; both
defaults keep the utilities usable standalone with no context, unchanged.

Docs — README gains a short example; ARCHITECTURE.md's error-hierarchy
section explains the offset-scoping rule and the MaxDepthExceededError
branch it was missing; CHANGELOG under ## [Unreleased]; docs/CLAIMS.md
gains 5.9.

Verification

  • uv run ruff check and uv run ruff format --check are clean
  • uv run mypy is clean (strict)
  • uv run pytest passes locally — 916 passed, coverage 99.88% (gate 98%)
  • CHANGELOG.md has an entry under ## [Unreleased]

No message string changed. Every existing str(err) and match=
assertion in the suite holds unchanged — test_message_text_is_unaffected_by_the_kwargs
pins this directly, and the full 916-test run (all pre-existing assertions
included) is the broader proof.

New protocol or dispatch change — also:

  • Followed the decode contract; no protocol's decode()/__post_init__
    behaviour changed, only what accompanies a raise
  • No new EtherType / IPProtocol numbers
  • No new protocol classes, so tests/test_fuzz.py::ALL_PROTOCOLS is unchanged
  • No new fixtures — exercised over synthetic and corpus-derived cases
  • ARCHITECTURE.md updated (error hierarchy section, rewritten)

Notes

On "carries at least protocol" being genuinely enforced, not just asserted.
TestEveryRaiseSiteIsInstrumented walks the AST of every file under src/,
finds every raise of a ProtocolError subclass, and asserts protocol= is a
keyword at each one (with a named, tested exemption for the one that isn't). I
verified the test actually catches a regression before trusting it: reverted
one site's kwargs locally, watched the test fail naming that exact
file:line, then restored it. That's stronger than the acceptance criterion
asks for — the criterion is satisfiable by a one-time sweep, this makes it a
standing gate.

On the offset design. The issue asks the two named design questions to be
decided: "Decide whether the exception carries a relative offset that
decode_frame rebases, or whether both are exposed" (both — offset stays
layer-relative and immutable regardless of call path, frame_offset is the
walker's rebased view, set only when a walker was involved) and "Keep the
message strings as they are" (verified directly, see above). The corpus figure
in the issue's own count (~55) was off — the real count is 62, which
docs/CLAIMS.md 5.9 states precisely rather than repeating the estimate.

On field for on-demand parses. TCP options, IPv4 options, DHCP options,
IGMP group records, ICMP NDP options, and DNS's section parsing all raise
from a property reading an already-materialized bytes attribute, not from
decode() itself. Rather than either omitting offset there (losing real
information) or falsely claiming it's relative to a decode() buffer that
isn't in scope, field names which attribute the offset is relative to —
consistent across all six call sites, and it's what let the DHCP/TCP/IPv4/DNS
tests assert exact offsets rather than just "an offset was set."

🤖 Generated with Claude Code

https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt


Generated by Claude Code

Strictness is this library's security story -- it raises where scapy
silently fills in defaults -- but every exception carried only a
formatted string. Verified: the full attribute set on a raised
TruncatedHeaderError was args, add_note, with_traceback. There was no
offset, no field, no protocol, no expected/actual, so a fuzzing
harness, conformance suite, or protocol-validation tool that wanted to
know where a parse failed had to regex the message.
Every raise site in src/ now attaches:
try:
packet = decode_frame(frame)
except ProtocolError as e:
print(e.protocol, e.field, e.offset, e.frame_offset, e.expected, e.actual)
# <class 'netprotocols.layer3.ip.IPv4'> ihl 0 14 >=5 0
protocol is set at all 62 raise sites bar one, verified by an AST
sweep rather than by eye (tests/test_diagnostics.py pins the sweep
itself as a regression test, confirmed to fail when a site is
reverted). The one exception is random_mac()'s manufacturer check,
which validates a free-standing argument belonging to no header --
documented at the site and asserted by its own test.
offset is scoped honestly rather than uniformly: relative to the data
argument of a decode() call for a fixed-header error, relative to the
bytes attribute field names (options, body, sections) for an on-demand
parse -- a TCP option error's offset is relative to header.options,
never the frame -- and None, never guessed, for a __post_init__
validation error, which sees field values and never the bytes they
came from.
decode_frame is the only code holding the cursor needed to rebase a
layer-relative offset to the whole frame, so it is the only thing that
sets frame_offset: added to the walk's except block, running whether
the walk is strict or lax, so a caller catching the re-raise in strict
mode also gets a correct frame_offset. A bare SomeClass.decode() call
leaves it None -- there is no frame to rebase against.
validate_mac_addr() and validate_ipv4_addr() gained optional
protocol=/field= parameters so the three __post_init__ call sites
(Ethernet, ARP, IPv4) can attach the field they are validating; both
defaults keep the utilities usable standalone with no context.
No message string changed to make any of this true -- every existing
str(err) and match= assertion in the suite holds unchanged, which
tests/test_diagnostics.py also pins directly. New public name:
MaxDepthExceededError, introduced by decode_frame, gets the same
structured attributes as every other ProtocolError subclass.
Closes#91.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt
@EONRaider
EONRaider merged commit e9132e4 into masterSep 3, 2026
7 checks passed
This was referenced Sep 4, 2026
Sign up for freeto 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.

Structured parse diagnostics on ProtocolError

2 participants

@EONRaider@claude