Skip to content

THRIFT-6300: Read a whole value before decoding it in TBinaryProtocol - #3915

Open
Jens-G wants to merge 3 commits into
apache:masterfrom
Jens-G:THRIFT-6300
Open

Jens-G wants to merge 3 commits into
apache:masterfrom
Jens-G:THRIFT-6300

Conversation

@Jens-G

@Jens-G Jens-G commented Sep 20, 2026

Copy link
Copy Markdown
Member

Stacked on #3914 (THRIFT-6258) → #3913 (THRIFT-6063). Review those first; this branch contains them.

readInt:, readRawInt:, readString and readByte each took whatever one transport read: answered and decoded that. A read: is allowed to answer fewer bytes than asked for — ordinary socket behaviour — and TTransport has readAll: to loop until the rest arrives. None of the four used it.

The harm is a wrong answer, not an exception

intFromByteArray: takes its width from the size of the buffer it was handed, not from the number of bytes asked for. So a short read decodes to a well-formed integer that happens to be wrong, and the sign comes from whatever byte is first.

Measured on this branch, with two of four bytes delivered:

readI32 with 2 of 4 bytes delivered -> 32767  (correct: 2147483647)

No error. A plausible number. readString is the same shape: it checks the declared size against the string limit but never how many bytes arrived, so it can answer a truncated string as if it were complete.

That is why the tests assert the decoded value rather than merely that something was raised — a raise-only test passes on the unfixed code.

The issue's suggested fix was not sufficient

The ticket says "Use readAll: in all three places. It already exists and already has the loop; nothing new has to be written." That turns out not to hold:

readAll: accumulated into a String, and a real transport does not answer one. TSocket>>connect sets its stream binary, so read: answers a ByteArray, and the protocol decodes it by shifting the elements as integers. Handing a ByteArray to a String stream raises:

Improper store into indexable object

readAll: had no callers at allgrep finds only its own definition — so nothing had ever run it against a real transport. Routing the decoders into it as-is would have swapped a wrong answer for a crash.

So readAll: now builds its result with the species of whatever read: answered, and clamps to the requested size. A test pins that directly (testReadAllKeepsTheSpeciesTheTransportAnswered).

Coverage

New TProtocolShortReadTest, 10 cases, driven by a transport that holds the whole wire content but answers at most n bytes per call:

  • i32/i16 assembled across short reads, including the misleading case where the sign byte arrives but the magnitude does not (-1 delivered 3 bytes at a time)
  • a peer that stops mid-value raises instead of answering a 3-byte integer
  • readString assembled across chunks; a string truncated by the peer raises instead of returning short
  • readDouble, which goes through readRawInt: twice
  • readByte on an exhausted transport raises TTransportError rather than a doesNotUnderstand: #first
  • readAll: preserves the transport's species

Two-state verification

With the four call sites reverted and the reworked readAll: kept — isolating exactly the routing — 9 of 10 fail. With them routed, all 10 pass. (The tenth is the species test, which does not depend on routing.)

Whole binding on this branch: 33 tests, all green.

TProtocolStringSizeLimitTest   11 of 11
TTransportReadAllTest           6 of 6
TProtocolShortReadTest         10 of 10
TProtocolRecursionDepthTest     6 of 6

Note on scope

readByte is not named in the issue. It reads exactly one byte, so it cannot receive a short-but-non-empty result — but on an exhausted transport (read: 1) first is a doesNotUnderstand, not a transport error. It is one line in the same family and routing it makes the failure mode consistent; happy to drop it if you would rather keep strictly to the three.

JIRA: THRIFT-6300

🤖 Generated with Claude Code

Jens-G and others added 3 commits September 20, 2026 23:41
Client: st

lib/st has no CI, so nothing runs its tests. It has two SUnit suites -
TProtocolStringSizeLimitTest, added with the string size limit, and
TProtocolRecursionDepthTest, added with the recursion depth limit - and
until now neither had ever been run by anything but the person who wrote
them.

The binding cannot be wired up the way the others are. It is not in
configure.ac and not in a SUBDIRS, so there is no "make check" to hang a
job on: lib/st is a Squeak/Pharo file-in and nothing else. GNU Smalltalk
is no help either, because it cannot parse the chunk format thrift.st is
written in. Pharo files it in cleanly, so that is what the job uses.

lib/st/test/run-tests.sh does the work, so that a developer runs the
same thing CI does: it files thrift.st and the suites into a Pharo
image, generates the code TProtocolRecursionDepthTest needs, runs both
suites and reports.

The script has to do its own asserting. Pharo reports a failed test on
stdout and still exits 0, so the exit status says nothing; the run and
pass counts are the only signal there is. It checks the number of tests
that ran as well as the number that passed, because a suite that fails
to file in registers no tests at all and would otherwise report
"RUN=0 PASS=0 FAIL=0" and read as success. Both were verified by
mutation: removing the checkStringSize: call from readString turns 11
passes into 6 passes, 4 failures and 1 error, and a wrong expected count
is reported as such.

The job pins the Pharo release line rather than following "stable",
which will move to Pharo 14 one day and turn the job red for reasons
that have nothing to do with Thrift.

Current state, on an unmodified tree: 11 of 11 and 6 of 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client: st

TTransport>>readAll: asks read: for the bytes it still needs and
appends whatever comes back, until it has enough:

    [str size < anInteger] whileTrue:
        [str nextPutAll: (self read: anInteger - str size)]

A read: that answers an empty string appends nothing, and asking the
same source again for the same bytes cannot answer differently, so the
loop has no way to end.

Nothing in thrift.st answers an empty string from read: today -
TSocket>>read: signals instead - so this is not reachable as the library
stands. It is a contract any new or third-party transport can break, and
breaking it hangs the caller rather than raising. c_glib had the same
shape in thrift_transport_real_read_all() and was given an explicit
progress check.

readAll: now signals a TTransportError when read: answers nothing,
which is what TSocket>>read: already does for the same condition.

The tests are in a new TTransportReadAllTest suite, driven by two stub
transports: one that answers nothing at all, one that answers at most a
fixed number of bytes per call. They pin down that a stalled transport
raises instead of spinning, that it is asked exactly once rather than
repeatedly, that bytes arriving before the source dries up still end in
an error rather than a short result, and - the case the loop exists for
- that a transport answering in chunks is still assembled correctly.

Each of them runs inside valueWithin:onTimeout:, because the failure
being tested for is a hang, not an exception. Without that guard a
regression would not report a failure at all: it would sit there until
the CI job hit its own timeout.

Against the unmodified library the three stalled cases fail and the
three others pass; with the fix all six pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client: st

readInt:, readRawInt:, readString and readByte each took whatever one
transport read: answered and decoded that. A read: is allowed to answer
fewer bytes than it was asked for - that is ordinary socket behaviour,
and TTransport has readAll: to loop until the rest arrives - but none of
the four used it.

The result is not an exception but a wrong answer. intFromByteArray:
takes its width from the size of the buffer it was handed rather than
from the number of bytes asked for, so a short read decodes to a
well-formed integer that happens to be wrong, and the sign is taken from
whatever byte happens to be first. Measured, with two of four bytes
delivered: readI32 answers 32767 where the correct value is 2147483647.
readString checks the declared size against the string limit but not how
many bytes arrived, so it can answer a truncated string as if it were
complete.

The four now read through readAll:.

That needed more than routing, contrary to what the issue suggested.
readAll: accumulated into a String, and a real transport does not answer
one: TSocket connects its stream 'binary', so read: answers a ByteArray,
and the protocol decodes it by shifting the elements as integers. Handing
a ByteArray to a String stream raises 'Improper store into indexable
object'. readAll: had no callers at all, so nothing had ever exercised
it against a real transport. It now builds its result with the species
of what read: answered, and clamps to the requested size.

The tests drive a transport that holds the whole wire content but
answers at most n bytes per call, which is what a socket does. They
assert the decoded VALUE rather than merely that something was raised,
because the broken behaviour is a plausible wrong number: a test that
only checked for a raise would pass on the unfixed code.

With the four call sites reverted and the reworked readAll: kept, 9 of
the 10 cases fail; with them routed, all 10 pass. The suites together
are 33 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mergeable mergeable Bot added the github_actions Pull requests that update GitHub Actions code label Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant