Repository navigation
rmi: restrict parser to java.rmi.registry.Registry + add Decoder - #20
Merged
Merged
Conversation
This commit sharpens the rmi package's scope from "general JRMP parser that also decodes Registry" to "Registry-only JRMP parser", and ships a frame-by-frame Decoder API for live net.Conn consumers. Scope narrowing (rmi/call.go): - readCall gates on the full Registry dispatch triple: ObjID == REGISTRY_ID AND methodHash == RegistryInterfaceHash AND op ∈ [0..4]. Any mismatch is a parse-time error with a specific message. - readCallArgs loses its sentinel fallback; it now reads exactly registryArgCount(op) TCContents. This removes the only code path that ever peeked past the last arg, so a Registry Call over a live TCP reader returns the instant its own bytes arrive — no deadlock on clients that send one Call and then wait for the server's response. - CallMessage.Decoded is populated for every successfully parsed Call (modulo non-fatal decoder errors on malformed string args). Frame-by-frame API (rmi/decoder.go): - NewDecoder(io.Reader) returns a Decoder that yields one Message per Next() call, with an optional Opening() to consume the handshake prefix. Callers apply SetReadDeadline on the underlying net.Conn between Next() calls to bound how long to wait for the next frame. - Non-Registry Calls error out at parse time — no blocking. - ReturnData still uses a sentinel peek for its 0-vs-1 payload count, because direction-agnostic parsing can't correlate Returns to the originating Call's return type. That sentinel may block between frames on a live reader, and the Decoder doc makes this explicit. Internal cleanup: - parseTransmission, readMessage, readCall, readReturn, readCallArgs lose the streaming bool parameter. Each frame picks its own strategy from its header, not from the input source. FromBytes and FromStream are thin wrappers over the unified parseTransmission. - readOpening factored out so Decoder and parseTransmission share handshake/ack/endpoint logic. Tests: - rmi_test.go: three TestCallXLeavesDecodedNil tests and TestCallWithPrimitiveArgsInLeadingBlock consolidated into a single table-driven TestCallRejectsNonRegistry. Removed TestReturnWithPrimitiveValueInLeadingBlock (Registry methods don't return primitives). - streaming_test.go: non-Registry-specific streaming tests dropped (readCall is shared, rejection is covered by rmi_test.go). Return sentinel tests retained. - decoder_test.go (new): 7 tests including TestDecoderRegistryCall- ReturnsWithoutPeek (never-closed io.Pipe + 2s deadlock guard, proves exact-count fast path) and TestDecoderRejectsNonRegistryCall. CLAUDE.md: the rmi section now opens with an explicit "Scope: Registry only" paragraph; "Arg/payload-reading strategy" describes two clean paths (Call exact-count, Return sentinel) instead of a fast-path + fallback fork; "Live-TCP ergonomics" shows the Decoder usage pattern with SetReadDeadline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 of 4 tasks
Four Example* functions in rmi/example_test.go covering every exported entry point, each with // Output: verification so they run as tests: - ExampleFromBytes — buffered parse of a real rmiregistry capture (testcases/rmi/jdk17/lookup-c2s.bin), showing handshake / endpoint / decoded method + args. - ExampleFromStream — io.Reader parse of a minimal hand-crafted JRMI handshake + Ping, demonstrating that FromStream works with any reader. - ExampleNewDecoder — the frame-by-frame idiom: Opening() + Next() loop terminating on io.EOF. - ExampleDecoder_liveConnection — the net.Conn pattern sketched with an in-memory reader; documents where SetReadDeadline fits, and which errors the caller sees (EOF on close, deadline timeout, non-Registry rejection). Also fixes a gofmt issue on decoder.go flagged by CI: the Decoder's blocking-semantics doc comment had a nested bullet list that gofmt reformats to flat indentation. Inlined the three sub-bullets into the parent item's prose so gofmt is stable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FromStream was a trap for live net.Conn usage. Its message loop peeks the next byte after every frame to decide whether to keep going; on a typical synchronous-RPC TCP session the peer sends one Call and then waits for the reply, so the peek blocks forever. The function was only actually useful for bounded readers (io.Pipe that closes, os.File), which is exactly the shape FromBytes already serves once the bytes are read in. API is now two entry points, chosen by input shape: - FromBytes(data []byte) — bytes already in memory (.bin captures, files, HTTP bodies). Loops until io.EOF. - Decoder (NewDecoder + Opening + Next) — one frame per Next() call, the only viable choice for a live connection. Callers apply SetReadDeadline on the underlying net.Conn between Next() calls. FromBytes's doc now explicitly warns against net.Conn usage. parseTransmission inlined into FromBytes (only caller left). Tests: - streaming_test.go deleted. - TestStreamExactReadNoSentinelLeak moved to rmi_test.go as TestBackToBackRegistryCallsNoSeparator (via FromBytes). - TestStreamIoPipeDelivery moved to decoder_test.go as TestDecoderChunkedDelivery, rewritten to drive Decoder.Opening() + Decoder.Next() instead of FromStream's internal loop. The io.Pipe chunked-delivery regression is preserved. - Other FromStream tests deleted — they were redundant with FromBytes coverage in rmi_test.go and integration_test.go. ExampleFromStream removed. ExampleDecoder_liveConnection rewords "compared to FromStream" to just document the live-connection pattern directly. CLAUDE.md: "Three entry points, one parser" → "Two entry points", "Live-TCP ergonomics" updated to open with the typical session shape and explain why FromBytes deadlocks on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change FromBytes and Decoder.Next each had their own "PeekN(1) → check EOF → readMessage" loop — two parallel implementations of the same primitive. FromBytes now drives a Decoder instance to EOF and reassembles the Transmission from Opening() + the collected Next() results. The public API is unchanged: FromBytes still takes []byte and returns *Transmission. Decoder remains the live-connection entry point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner
Author
Code reviewFound 2 issues, both stale doc comments that contradict the code after the multi-commit refactor:
Lines 14 to 20 in ac3ac3a
Lines 23 to 35 in ac3ac3a 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- rmi/message.go readMessage: the "exact-count-for-Registry vs sentinel" phrasing implied Calls had two strategies, but the sentinel fallback for Calls was removed. Now describes the split correctly: Calls always exact-count, Returns always sentinel. - rmi/call.go CallMessage: type doc said "Decoded is always non-nil for a successfully parsed CallMessage", but the decoder swallows non-fatal errors (malformed string arg, etc.) and leaves Decoded nil. Now documents the contract and points callers at Raw / ObjectArgs as the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three sharpening passes on the
rmipackage. Together they shrink the public API, tighten the scope, and collapse the implementation onto a single primitive.1. Scope: Registry-only
Restrict the parser from "general JRMP + Registry decoding" to java.rmi.registry.Registry only.
readCallrejects any Call whose header fails the Registry dispatch triple (ObjID == REGISTRY_ID,methodHash == RegistryInterfaceHash,op ∈ [0..4]) at parse time with a specific error.readCallArgsdrops its sentinel fallback — the only peek-past-last-arg code path is gone. Registry Calls over a live TCP reader return the instant their own bytes arrive, with no risk of deadlock when the client waits for the server's reply.Decoded, sentinel peeks, fall-through error cases) was the single largest source of complexity. Registry coverage alone serves the project's use case (interceptingrmiregistrytraffic).2. API: two entry points, chosen by input shape
rmi.FromBytes(data []byte)— bytes already in memory (.bincaptures,io.ReadAllof an HTTP body). Loops untilio.EOFand returns the wholeTransmission. Not safe with a livenet.Conn: the loop would deadlock on the first post-last-frame peek when the peer keeps the connection open waiting for a reply (i.e. every synchronous RPC).rmi.Decoder(NewDecoder+Opening+Next) — one frame perNext()call, the only viable choice for a live connection. Caller appliesSetReadDeadlineon the underlyingnet.Connbetween calls to bound how long to wait for the next frame.The earlier PR (#19) introduced
FromStream(io.Reader)alongside these. That turned out to be a trap: its signature implied "any reader works" but in practicenet.Conndeadlocks the message loop. Removed in favor of the sharper two-way split —[]byteis unambiguously bounded,Decoderis explicitly frame-by-frame.3. Implementation: single primitive, thin wrapper
FromBytesis now a thin wrapper that drivesDecoderto EOF. Before, both had parallel "PeekN → EOF check → readMessage" loops. NowDecoderis the only place that loop lives, andFromBytesis ~15 lines:Return-side caveat (documented, not fixed)
ReturnDatastill uses a sentinel peek for its 0-vs-1 payload count — bind/rebind/unbind return void (0 payload), list/lookup/Exception return 1. Without call/response correlation (which direction-agnostic parsing can't do), the sentinel is the only viable strategy. It blocks between frames on a live reader until the next flag byte arrives or the reader's deadline fires.Decoder's doc comments and CLAUDE.md call this out explicitly.Documentation
Four runnable godoc examples in
rmi/example_test.go, each with verified// Output:blocks:ExampleFromBytes— parse a realrmiregistrycapture (testcases/rmi/jdk17/lookup-c2s.bin), navigate Handshake / ClientEndpoint / Decoded method + args.ExampleNewDecoder— frame-by-frame idiom (Opening()+for { Next() }).ExampleDecoder_liveConnection— thenet.Connpattern, withSetReadDeadlineplacement and the error-handling contract.CLAUDE.md's
rmisection rewritten: "Scope: Registry only" opens the section; "Arg/payload-reading strategy" documents the two clean paths (Call exact-count, Return sentinel); "Live-TCP ergonomics" explains why FromBytes deadlocks on a typical session and shows the Decoder usage pattern.Test plan
go test ./...— all tests green across 2 JDK fixture dirs (jdk17,jdk8)TestDecoderRegistryCallReturnsWithoutPeek— feeds exact Registry Call bytes into a never-closedio.Pipewith a 2s deadlock guard. Proves the exact-count fast path returns without peeking past the last arg.TestDecoderChunkedDelivery— feeds a real capture throughio.Pipein 7-byte chunks, proves Decoder stitches chunks across frame-internal boundaries.TestCallRejectsNonRegistry(table-driven) — covers non-zero ObjNum, wrong interface hash, unknown op-index.TestBackToBackRegistryCallsNoSeparator— two Calls concatenated without any intervening bytes, regression guard for the exact-count property.go vet ./.../go build ./...lint+ 15testsjobs (Go 1.18–1.22 × Linux/macOS/Windows) all passing.Commits
40117dddb5a08dba58371ac3ac3aSupersedes #19
That PR tried to expand scope (support any frame type in streaming). The non-Registry machinery turned out to be more ambiguity than it removed — closed and superseded by this one going the other direction.
🤖 Generated with Claude Code