You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
SeekableReader's position bookkeeping is not maintained across operations: a refused seek leaves _tell moved, SEEK_CUR/SEEK_END can drive it negative, and peek desyncs the buffer cursor #643
SeekableReader maintains three position variables — _tell, _buffer_set/_buffer_cur, and self._buffer's own BytesIO cursor — and nothing keeps them in step across a failed seek, a negative-going seek, or a peek. Three separate defects, filed together because they are one problem: the bookkeeping has no invariant that survives an operation which does not complete normally.
The invariant that ought to hold is that self._buffer's octet 0 sits at absolute _buffer_set, and self._buffer.tell() tracks _tell - _buffer_set. seek is the only method that restores it, at pcapkit/corekit/io.py:298, and every path that skips that line leaves the object lying about where it is.
All three are silent wrong data, not crashes. None of them involves truncate, so none is affected by #633.
1. A refused seek leaves _tell already moved
seek assigns _tell in each whence branch before it validates the result. When the validation then refuses the seek, _tell has already been overwritten with the rejected target, and the resync at line 298 is never reached:
pcapkit/corekit/io.py:274 self._tell = offset # SEEK_SET
pcapkit/corekit/io.py:276 self._tell += offset # SEEK_CUR
pcapkit/corekit/io.py:278 self._tell = buf_end + offset # SEEK_END
pcapkit/corekit/io.py:301 raise SeekError(f'cannot seek before the beginning of the buffer: ...')
pcapkit/corekit/io.py:298 self._buffer.seek(self._tell - self._buffer_set, io.SEEK_SET) # the resync, skipped
A caller that catches SeekError and reasonably believes the position is unchanged instead reads from the wrong offset, with no exception at the read:
r=SeekableReader(io.BytesIO(b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), buffer_size=2)
r.read(13) # b'ABCDEFGHIJKLM'; _buffer_set=11, _buffer_cur=2r.seek(8) # raises SeekError('cannot seek before the beginning of the buffer: 8 < 11')r.tell() # 8 <- moved, despite the refusalr.read(1) # b'L' <- wrong; offset 8 is b'I'
Reproduced independently at a second parameterisation, so it is not an artefact of one buffer size:
r=SeekableReader(io.BytesIO(b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), buffer_size=4)
r.read(6) # b'ABCDEF'; _buffer_set=2, _buffer_cur=4r.seek(0) # raises SeekError('cannot seek before the beginning of the buffer: 0 < 2')r.tell() # 0 <- movedr.read(1) # b'C' <- wrong; offset 0 is b'A'
The invalid-whence branch at pcapkit/corekit/io.py:279-280 is correctly guarded — it raises before any of the three assignments run, and seek(0, 99) leaves tell() untouched. Only the three whence branches above are affected; noting it so nobody adds a redundant guard there.
2. SEEK_CUR and SEEK_END never check that the resulting _tell is non-negative
SEEK_SET rejects a negative offset outright at pcapkit/corekit/io.py:272-273. The other two branches do arithmetic and never look at the result, so an absolute position below zero is reachable:
r=SeekableReader(io.BytesIO(b'abcdef'), buffer_size=4)
r.seek(-100, io.SEEK_CUR) # SeekError: cannot seek before the beginning of the buffer: -100 < 0r.tell() # -100 <- a negative absolute stream position
r=SeekableReader(io.BytesIO(b'abcdef'), buffer_size=4)
r.seek(-100, io.SEEK_END) # SeekError: cannot seek before the beginning of the buffer: -96 < 0r.tell() # -96
The asymmetry is the defect: the same nonsensical request is a clean negative seek value under one whence and a misleading cannot seek before the beginning of the buffer under the other two. The message conflates a position that has legitimately slid out of the buffer window — normal, and recoverable with buffer_save=True — with an absolute position that cannot exist.
A negative _tell then compounds with the - 1 in the buffered read paths (filed separately), and the result is a length-contract violation as well as wrong data. With _buffer_cur == 0, min(size, self._buffer_cur - 1) is min(1, -1) == -1, and BytesIO.read(-1) means read-to-EOF:
r=SeekableReader(io.BytesIO(bytes(range(50))), buffer_size=16)
r.seek(-5, io.SEEK_CUR) # SeekError: cannot seek before the beginning of the buffer: -5 < 0r._tell# -5, and _buffer_cur is still 0r.read(1) # b'\x00' * 16 <- 16 octets for a 1-octet request,# all of them the never-populated bufferr.tell() # 11
read(1) returning sixteen octets of pristine zero-fill is the worst single observation in this issue: the data never came from the stream at all.
With buffer_save=True the same seek does not even raise
The refusal at line 301 is conditional on self._buffer_file is None. With a backing file, control falls to line 302 and seekreturns the negative position as if it had succeeded; the failure then surfaces on an unrelated call as a bare OS error:
r=SeekableReader(io.BytesIO(bytes(range(50))), buffer_size=16,
buffer_save=True, buffer_path='/tmp/buf.bin')
r.seek(-5, io.SEEK_CUR) # returns -5 <- no exception at allr.read(1) # OSError: [Errno 22] Invalid argument
That OSError comes from temp_file.seek(self._tell, io.SEEK_SET) at pcapkit/corekit/io.py:372, inside read — i.e. from a line that is only reachable because seek accepted something it should not have.
3. peek desyncs self._buffer's cursor from _tell
peek must not advance the stream position, and indeed it never touches _tell — unlike read, read1 and readline it has no self._tell += len(buf). But its buffered branch reads throughself._buffer, which does advance that BytesIO's own cursor:
pcapkit/corekit/io.py:492 buf = self._buffer.read(min(size, self._buffer_cur - 1))
pcapkit/corekit/io.py:503 return buf # no _tell update, and no cursor restore
So after a peek, _tell is right and self._buffer.tell() is wrong, and the next buffered read starts from the wrong place:
r=SeekableReader(io.BytesIO(bytes(range(50))), buffer_size=16)
r.read(8); r.seek(2)
r.peek(3) # b'\x02\x03\x04' correct preview# but now _tell=2 while self._buffer.tell()=5r.read(3) # b'\x05\x06\x07' <- WRONG; should equal the controlr.tell() # 5, in both runs -- tell() cannot distinguish them
tell() reports the same number in both runs while the returned bytes differ, so there is no way for a caller to detect this.
The other branch, at pcapkit/corekit/io.py:480-485, has the same shape by a different route. When _tell is at or past the end of buffered content and the raw stream has no peek, peek does a real consuming self._stream.read(size) followed by self._write_buffer(buf) — and _write_buffer writes through the memoryview at line 145 rather than through self._buffer.write(), so self._buffer's cursor is again left where it was:
r=SeekableReader(NoPeekStream(bytes(range(50))), buffer_size=16) # non-seekable, no peek, no read1r.read(8)
r.read(4) # b'\x08\t\n\x0b' <- control: correct
r=SeekableReader(NoPeekStream(bytes(range(50))), buffer_size=16)
r.read(8)
r.peek(4) # b'\x08\t\n\x0b' correct previewr.read(4) # b'\x00\x01\x02\x03' <- WRONG; replays the first four# octets ever buffered
This one is reachable from Extractor, on ordinary input
peek is not a latent API corner. Extractor calls it as the magic-number sniff:
pcapkit/foundation/extraction.py:966-968 if not self._ifile.seekable(): -> wrap in SeekableReader
pcapkit/foundation/extraction.py:991 self._magic = self._ifile.peek(4)[:4]
pcapkit/foundation/engines/pcapng.py:336 buffer = ext._ifile.peek(4)[:4]
So for any non-seekable input — a pipe, a socket, sys.stdin.buffer — peek(4) is the first operation performed on the stream, and it corrupts the read that follows it. Reproducing exactly that sequence, with a non-seekable stream that has no peek (as a pipe does not):
DATA=b'\xd4\xc3\xb2\xa1'+b'RESTOFHEADER'+b'FIRSTRECORD'# PCAP magic, then contentr=SeekableReader(Pipe(DATA), stream_closing=False) # as extraction.py:968 doesr.peek(4)[:4] # b'\xd4\xc3\xb2\xa1' correct magic, as read at :991r.read(16) # b'\xd4\xc3\xb2RESTOFHEADERF'# correct would be b'\xd4\xc3\xb2\xa1RESTOFHEADER'
The \xa1 — the fourth octet of the PCAP magic number — is dropped from the middle, and a stray F from the following record is spliced onto the end to make the length up. The control run, identical but without the peek, returns b'\xd4\xc3\xb2\xa1RESTOFHEADER' correctly.
That last reproduction is a joint consequence of this defect and the - 1 off-by-one filed separately: the peek populates the buffer without advancing _tell, and the - 1 is what then drops an octet. Either fix alone would change the symptom; it is recorded in both issues rather than in only one.
Measured
On origin/main (375e9d411), CPython 3.14.7, tree asserted as the repository's own rather than trusting the editable install — pcapkit.__file__ printed and checked on every run, because the __editable__ finder on this machine resolves to a checkout behind origin/main. Every reproduction above is io.BytesIO (or a small non-seekable wrapper over one) with no fixtures.
Why nothing catches it
The suite exercises all three paths and discriminates none of them.
tests/corekit/test_io.py:34-41 — test_seek_before_buffer_start_requires_saved_buffer drives defect 1's exact path: read(6) then assertRaises(SeekError) on seek(0). It never checks tell() afterwards and never reads afterwards, which is where the whole defect lives.
tests/corekit/test_io.py:139-140 — seek(-1, io.SEEK_CUR) and seek(-1, io.SEEK_END) are both asserted, but with offsets small enough that _tell stays non-negative. Defect 2 needs an offset large enough to cross zero, and no test uses one.
tests/corekit/test_io.py:31, :117, :185, :208, :240 — five peek assertions, all checking only the value peek returns. Defect 3 is entirely in what the next read returns, which none of them looks at.
The same shape as #622, #601 and #618: a test that runs the line without discriminating the behaviour.
Fixes in this file are known to be subtle, so no fix is proposed here. fix(corekit): keep SeekableReader's buffer content when truncating it (#622) #633's own first revision turned a loud ValueError into silent data corruption, because the buffer is a sliding window in which _buffer_set + _buffer_cur must stay invariant. The design is left to whoever picks this up.
The - 1 off-by-one that two of the reproductions above interact with is filed separately, as a distinct and independently fixable defect: four wrong expressions, versus the state-machine problem described here.
SeekableReadermaintains three position variables —_tell,_buffer_set/_buffer_cur, andself._buffer's ownBytesIOcursor — and nothing keeps them in step across a failedseek, a negative-goingseek, or apeek. Three separate defects, filed together because they are one problem: the bookkeeping has no invariant that survives an operation which does not complete normally.The invariant that ought to hold is that
self._buffer's octet 0 sits at absolute_buffer_set, andself._buffer.tell()tracks_tell - _buffer_set.seekis the only method that restores it, atpcapkit/corekit/io.py:298, and every path that skips that line leaves the object lying about where it is.All three are silent wrong data, not crashes. None of them involves
truncate, so none is affected by #633.1. A refused
seekleaves_tellalready movedseekassigns_tellin eachwhencebranch before it validates the result. When the validation then refuses the seek,_tellhas already been overwritten with the rejected target, and the resync at line 298 is never reached:A caller that catches
SeekErrorand reasonably believes the position is unchanged instead reads from the wrong offset, with no exception at the read:Reproduced independently at a second parameterisation, so it is not an artefact of one buffer size:
The invalid-
whencebranch atpcapkit/corekit/io.py:279-280is correctly guarded — it raises before any of the three assignments run, andseek(0, 99)leavestell()untouched. Only the threewhencebranches above are affected; noting it so nobody adds a redundant guard there.2.
SEEK_CURandSEEK_ENDnever check that the resulting_tellis non-negativeSEEK_SETrejects a negative offset outright atpcapkit/corekit/io.py:272-273. The other two branches do arithmetic and never look at the result, so an absolute position below zero is reachable:The asymmetry is the defect: the same nonsensical request is a clean
negative seek valueunder onewhenceand a misleadingcannot seek before the beginning of the bufferunder the other two. The message conflates a position that has legitimately slid out of the buffer window — normal, and recoverable withbuffer_save=True— with an absolute position that cannot exist.A negative
_tellthen compounds with the- 1in the buffered read paths (filed separately), and the result is a length-contract violation as well as wrong data. With_buffer_cur == 0,min(size, self._buffer_cur - 1)ismin(1, -1) == -1, andBytesIO.read(-1)means read-to-EOF:read(1)returning sixteen octets of pristine zero-fill is the worst single observation in this issue: the data never came from the stream at all.With
buffer_save=Truethe same seek does not even raiseThe refusal at line 301 is conditional on
self._buffer_file is None. With a backing file, control falls to line 302 andseekreturns the negative position as if it had succeeded; the failure then surfaces on an unrelated call as a bare OS error:That
OSErrorcomes fromtemp_file.seek(self._tell, io.SEEK_SET)atpcapkit/corekit/io.py:372, insideread— i.e. from a line that is only reachable becauseseekaccepted something it should not have.3.
peekdesyncsself._buffer's cursor from_tellpeekmust not advance the stream position, and indeed it never touches_tell— unlikeread,read1andreadlineit has noself._tell += len(buf). But its buffered branch reads throughself._buffer, which does advance thatBytesIO's own cursor:So after a
peek,_tellis right andself._buffer.tell()is wrong, and the next buffered read starts from the wrong place:tell()reports the same number in both runs while the returned bytes differ, so there is no way for a caller to detect this.The other branch, at
pcapkit/corekit/io.py:480-485, has the same shape by a different route. When_tellis at or past the end of buffered content and the raw stream has nopeek,peekdoes a real consumingself._stream.read(size)followed byself._write_buffer(buf)— and_write_bufferwrites through the memoryview at line 145 rather than throughself._buffer.write(), soself._buffer's cursor is again left where it was:This one is reachable from
Extractor, on ordinary inputpeekis not a latent API corner.Extractorcalls it as the magic-number sniff:So for any non-seekable input — a pipe, a socket,
sys.stdin.buffer—peek(4)is the first operation performed on the stream, and it corrupts the read that follows it. Reproducing exactly that sequence, with a non-seekable stream that has nopeek(as a pipe does not):The
\xa1— the fourth octet of the PCAP magic number — is dropped from the middle, and a strayFfrom the following record is spliced onto the end to make the length up. The control run, identical but without thepeek, returnsb'\xd4\xc3\xb2\xa1RESTOFHEADER'correctly.That last reproduction is a joint consequence of this defect and the
- 1off-by-one filed separately: thepeekpopulates the buffer without advancing_tell, and the- 1is what then drops an octet. Either fix alone would change the symptom; it is recorded in both issues rather than in only one.Measured
On
origin/main(375e9d411), CPython 3.14.7, tree asserted as the repository's own rather than trusting the editable install —pcapkit.__file__printed and checked on every run, because the__editable__finder on this machine resolves to a checkout behindorigin/main. Every reproduction above isio.BytesIO(or a small non-seekable wrapper over one) with no fixtures.Why nothing catches it
The suite exercises all three paths and discriminates none of them.
tests/corekit/test_io.py:34-41—test_seek_before_buffer_start_requires_saved_bufferdrives defect 1's exact path:read(6)thenassertRaises(SeekError)onseek(0). It never checkstell()afterwards and never reads afterwards, which is where the whole defect lives.tests/corekit/test_io.py:139-140—seek(-1, io.SEEK_CUR)andseek(-1, io.SEEK_END)are both asserted, but with offsets small enough that_tellstays non-negative. Defect 2 needs an offset large enough to cross zero, and no test uses one.tests/corekit/test_io.py:31,:117,:185,:208,:240— fivepeekassertions, all checking only the valuepeekreturns. Defect 3 is entirely in what the next read returns, which none of them looks at.The same shape as #622, #601 and #618: a test that runs the line without discriminating the behaviour.
Notes
no_eofa way to stop, so extract() returns (#620) #639), several by fuzzing; re-verified here from scratch on375e9d411before filing.pcapkit/corekit/io.pyonmaindoes not carry itstruncatefix. Everything above was measured againstmainas it stands, and no reproduction callstruncate, so none of it is affected either way.ValueErrorinto silent data corruption, because the buffer is a sliding window in which_buffer_set + _buffer_curmust stay invariant. The design is left to whoever picks this up.- 1off-by-one that two of the reproductions above interact with is filed separately, as a distinct and independently fixable defect: four wrong expressions, versus the state-machine problem described here.truncatepadding, same file), FieldBase.unpack pads a short read with rjust regardless of byte order, silently corrupting little-endian values #604 / OptionField.unpack never returns for a well-formed HOPOPT header with an SMF_DPD option #431 (the padding-side family).