…em silently (#477)
`pcapkit/foundation/reassembly/ip.py:133` slice-assigned an arriving
fragment's payload into the datagram buffer unconditionally, with no
check of `RCVBT` beforehand and no record kept when two fragments
claimed the same octets with different bytes. This is the IP analogue
of #443 (fixed for TCP in unmerged PR #478), but is a separate
implementation and, per research below, a different resolution.
- pcapkit/foundation/reassembly/data/ip.py: add `Buffer.conflict`
(list, mutable, accumulated) and `Datagram.conflict` (tuple,
additive) -- `(first, last)` absolute, inclusive octet ranges,
mirroring #478's TCP `conflict` field/convention.
- pcapkit/foundation/reassembly/ip.py: add `IP._detect_conflicts`,
called before the existing overwrite. It consults `RCVBT` block bits
clipped by `TDL` (see below) to find which octets already held real
data, then compares bytes directly against the arriving payload; a
differing run is recorded, an equal one (a duplicate) is not. The
overwrite itself is UNCHANGED -- last-write-wins, not first-write-wins.
- tests/foundation/reassembly/test_ip.py: 7 new cases -- identical
duplicate (uncontested), full block-aligned conflict, partial overlap
extending each direction, a conflict confined to the final fragment's
partial 8-octet block (the RCVBT-granularity edge case), a three-way
conflict, and a conflict resolved before a later fragment completes
the datagram. tests/foundation/reassembly/data/test_models.py updated
for the new constructor field (IP section only).
- docs/source/pcapkit/foundation/reassembly/ip/{ipv4,ipv6}.rst: document
`conflict` on both Datagram and Buffer, and the RCVBT/TDL clipping.
RFC 791 answers the "which fragment wins" question explicitly, and the
answer is the OPPOSITE of TCP's RFC 9293 first-write-wins: "In the case
that two or more fragments contain the same data either identically or
through a partial overlap, this procedure will use the more recently
arrived copy in the data buffer and datagram delivered." RFC 815's
hole-descriptor algorithm says nothing about differing content
directly, but its unconditional "copy the data from the fragment into
the reassembly buffer" is consistent with the same last-write-wins
resolution. So this fix keeps the existing overwrite direction and adds
only the missing record of disagreement -- it does not port #478's
first-write-wins merge logic, which would contradict RFC 791's own text.
RCVBT records receipt in 8-octet blocks, coarser than an octet
conflict needs. Every fragment but the last is required to be
block-aligned (and FO is always a multiple of 8 -- it is wire-encoded
in 8-octet units), so the only block that can be partially real is the
one holding the final fragment's own tail. `_detect_conflicts` treats
octet `pos` as genuinely already-received iff `rcvbt[pos // 8]` is set
AND (`tdl < 0` or `pos < tdl`) -- `tdl` being `Buffer.TDL` as it stood
before this fragment's own update. Verified against a case built for
exactly this: a final fragment covering only 3 of an 8-octet block's
octets, then a later fragment claiming the full block -- conflict is
reported as (8, 10), not (8, 15); bytes 11-15 were never really sent by
anyone and are correctly treated as new territory, not a conflict.
Comparing bytes directly (per the brief's suggestion) was sufficient
once combined with this TDL clip -- no new per-octet bytearray needed.
Reproduced pre-fix through the public API: two conflicting 8-octet
fragments at the same offset silently produced a complete datagram with
the second fragment's bytes and `AttributeError` on `.conflict` (field
did not exist). Post-fix: same payload (last-write-wins, per RFC 791),
plus `conflict == ((0, 7),)`. Reverted the fix via a tagged stash and
confirmed all 7 new boundary cases fail immediately with
`AttributeError: 'Datagram' object has no attribute 'conflict'`,
restored via `git stash apply` + `git stash drop`.
Build/test: tests/foundation/reassembly/ -- 63 passed (7 new), 0
failed. tests/foundation/ -- 200 passed, 11 skipped. mypy pcapkit,
mypy --config-file mypy.ini pcapkit, and the Makefile's
--follow-imports=silent --ignore-missing-imports --show-column-numbers
--show-error-codes pcapkit all report zero errors in the two files
touched here (122 total errors in 39 files, all pre-existing and
unrelated, e.g. toolkit/scapy.py, protocols/internet/ipv6.py).
Accepted behaviour break, same reasoning as #478: a capture that hits
this path is anomalous by definition, and callers reading `.conflict`
on an existing `Datagram` for the first time is the point of the fix.
Noticed but not fixed here (out of scope, filing separately):
pcapkit/toolkit/scapy.py:160 sets `fo=ipv4.frag` with no `* 8`, but
scapy's `IP.frag` is the raw 13-bit wire field in 8-octet units, not a
byte offset -- confirmed by constructing `scapy.all.IP(frag=5)` and
observing the raw fragment-offset field on the wire is `5`, not `40`.
Every other adapter (`dpkt.py`, `pypcapfile.py`, `pcap.py`, `pcapng.py`,
native `ipv4.py`) multiplies by 8 or uses an already-scaled `.offset`.
This looks like a real defect in the scapy IPv4 fragment-offset
handling, separate from #477 and not yet filed as its own issue.
Closes #477
Summary
pcapkit/foundation/reassembly/ip.py:133slice-assigned an arriving fragment's payload into the datagram buffer unconditionally, with no check ofRCVBTbeforehand and no record kept when two fragments claimed the same octets with different bytes. This is the IP analogue of #443 (fixed for TCP in unmerged PR #478), but a separate implementation, governed by a different RFC, and — per the research below — resolved the opposite way.pcapkit/foundation/reassembly/data/ip.py: newBuffer.conflict(list, accumulated) andDatagram.conflict(tuple, additive) —(first, last)absolute, inclusive octet ranges, mirroring reassembly: resolve conflicting TCP overlaps first-write-wins, per RFC 9293 #478's TCPconflictfield and its naming/convention.pcapkit/foundation/reassembly/ip.py: newIP._detect_conflicts, called before the existing overwrite. It consultsRCVBTblock bits clipped byTDLto find which octets already held genuinely-received data, then compares those bytes directly against the arriving payload; a differing run is recorded, an equal one (a duplicate) is not. The overwrite itself is unchanged — still last-write-wins, not first-write-wins.tests/foundation/reassembly/test_ip.py: 7 new cases (identical duplicate, full block-aligned conflict, partial overlap extending each direction, a conflict confined to the final fragment's partial 8-octet block, a three-way conflict, and a conflict resolved before a later fragment completes the datagram).docs/source/pcapkit/foundation/reassembly/ip/{ipv4,ipv6}.rst: documentconflicton bothDatagramandBuffer, and the RCVBT/TDL clipping that keeps a reported conflict range from over-reaching into a fragment's never-really-sent padding.RFC finding — the resolution is the opposite of TCP's
RFC 791 answers "which fragment wins" explicitly, and it is the opposite of TCP's RFC 9293 first-write-wins:
RFC 815's hole-descriptor algorithm says nothing directly about differing content, but its unconditional "copy the data from the fragment into the reassembly buffer" is consistent with the same last-write-wins resolution. So this fix keeps the existing overwrite direction and adds only the missing record of disagreement — it does not port #478's first-write-wins merge logic, which would contradict RFC 791's own text.
RCVBT's 8-octet granularity
RCVBT records receipt in 8-octet blocks, coarser than an octet conflict needs. Every fragment but the last is required to be block-aligned (and
FOis always a multiple of 8 — it is wire-encoded in 8-octet units), so the only block that can be partially real is the one holding the final fragment's own tail._detect_conflictstreats octetposas genuinely already-received iffrcvbt[pos // 8]is set and (tdl < 0orpos < tdl), wheretdlisBuffer.TDLas it stood before the current fragment's own update.Verified against a case built for exactly this: a final fragment covering only 3 of an 8-octet block's octets, then a later fragment claiming the full block — the reported conflict is
(8, 10), not(8, 15); octets 11-15 were never really sent by anyone and are correctly treated as new territory, not a conflict. Comparing bytes directly was sufficient once combined with theTDLclip — no new per-octet bytearray was needed.Reproduction and revert-proof
Pre-fix, through the public API: two conflicting 8-octet fragments at the same offset silently produced a complete datagram with the second fragment's bytes, and
AttributeErroron.conflict(the field did not exist). Post-fix: same payload (last-write-wins, per RFC 791), plusconflict == ((0, 7),).Reverted the fix via a tagged
git stashand confirmed all 7 new boundary cases fail immediately withAttributeError: 'Datagram' object has no attribute 'conflict'; restored viagit stash apply+git stash drop.Test plan
tests/foundation/reassembly/— 63 passed (7 new), 0 failedtests/foundation/— 200 passed, 11 skippedmypy pcapkit,mypy --config-file mypy.ini pcapkit, and the Makefile's--follow-imports=silent --ignore-missing-imports --show-column-numbers --show-error-codes pcapkit— all three report zero errors in the two files touched here (122 total errors in 39 files is the pre-existing, unrelated baseline:toolkit/scapy.py,protocols/internet/ipv6.py,foundation/engines/*, etc.)Accepted behaviour break
Same reasoning as #478: a capture that hits this path is anomalous by definition, and a caller reading
.conflicton an existingDatagramfor the first time is the point of the fix, not a compatibility concern.Noticed but not fixed here
pcapkit/toolkit/scapy.py:160setsfo=ipv4.fragwith no* 8, but scapy'sIP.fragis the raw 13-bit wire field in 8-octet units, not a byte offset — confirmed by constructingscapy.all.IP(frag=5)and observing the raw fragment-offset field on the wire is5, not40. Every other adapter (dpkt.py,pypcapfile.py,pcap.py,pcapng.py, the nativeipv4.py) multiplies by 8 or uses an already-scaled.offset. This looks like a real, separate defect in the scapy IPv4 fragment-offset handling; not yet filed as its own issue for lack of time in this session — flagging here so it isn't lost.