Skip to content

rmi: restrict parser to java.rmi.registry.Registry + add Decoder - #20

Merged
phith0n merged 5 commits into
masterfrom
rmi-registry-only-decoder
Apr 18, 2026
Merged

phith0n merged 5 commits into
masterfrom
rmi-registry-only-decoder

Conversation

@phith0n

@phith0n phith0n commented Apr 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

Three sharpening passes on the rmi package. 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.

  • readCall rejects any Call whose header fails the Registry dispatch triple (ObjID == REGISTRY_ID, methodHash == RegistryInterfaceHash, op ∈ [0..4]) at parse time with a specific error.
  • readCallArgs drops 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.
  • Every non-Registry Remote interface has its own method-hash table we don't carry; the ambiguity machinery (optional Decoded, sentinel peeks, fall-through error cases) was the single largest source of complexity. Registry coverage alone serves the project's use case (intercepting rmiregistry traffic).

2. API: two entry points, chosen by input shape

  • rmi.FromBytes(data []byte) — bytes already in memory (.bin captures, io.ReadAll of an HTTP body). Loops until io.EOF and returns the whole Transmission. Not safe with a live net.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 per Next() call, the only viable choice for a live connection. Caller applies SetReadDeadline on the underlying net.Conn between 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 practice net.Conn deadlocks the message loop. Removed in favor of the sharper two-way split — []byte is unambiguously bounded, Decoder is explicitly frame-by-frame.

3. Implementation: single primitive, thin wrapper

FromBytes is now a thin wrapper that drives Decoder to EOF. Before, both had parallel "PeekN → EOF check → readMessage" loops. Now Decoder is the only place that loop lives, and FromBytes is ~15 lines:

func FromBytes(data []byte) (*Transmission, error) {
    d := NewDecoder(bytes.NewReader(data))
    opening, err := d.Opening()
    if err != nil { return nil, err }
    t := &Transmission{
        Handshake:      opening.Handshake,
        Acknowledge:    opening.Acknowledge,
        ClientEndpoint: opening.ClientEndpoint,
    }
    for {
        msg, err := d.Next()
        if errors.Is(err, io.EOF) { return t, nil }
        if err != nil { return nil, err }
        t.Messages = append(t.Messages, msg)
    }
}

Return-side caveat (documented, not fixed)

ReturnData still 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 real rmiregistry capture (testcases/rmi/jdk17/lookup-c2s.bin), navigate Handshake / ClientEndpoint / Decoded method + args.
  • ExampleNewDecoder — frame-by-frame idiom (Opening() + for { Next() }).
  • ExampleDecoder_liveConnection — the net.Conn pattern, with SetReadDeadline placement and the error-handling contract.

CLAUDE.md's rmi section 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-closed io.Pipe with a 2s deadlock guard. Proves the exact-count fast path returns without peeking past the last arg.
    • TestDecoderChunkedDelivery — feeds a real capture through io.Pipe in 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 ./...
  • CI: lint + 15 tests jobs (Go 1.18–1.22 × Linux/macOS/Windows) all passing.

Commits

40117dd rmi: restrict parser to java.rmi.registry.Registry + add Decoder
db5a08d rmi: add runnable godoc examples + fix gofmt on Decoder doc
ba58371 rmi: drop FromStream, simplify to FromBytes + Decoder
ac3ac3a rmi: make FromBytes a thin wrapper over Decoder

Supersedes #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

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>
phith0n and others added 3 commits April 19, 2026 00:09
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>
@phith0n

phith0n commented Apr 18, 2026

Copy link
Copy Markdown
Owner Author

Code review

Found 2 issues, both stale doc comments that contradict the code after the multi-commit refactor:

  1. rmi/message.go:15-18 — readMessage doc says "the per-reader arg strategies (exact-count-for-Registry vs sentinel) are chosen internally based on each frame's header". That phrasing implies Calls can take either strategy, but after commit 40117dd the sentinel fallback for Calls was removed: readCallArgs always reads exact count. Only Returns still use a sentinel. The comment should say something like "Calls use exact count; Returns use a sentinel peek for 0-vs-1 payload."

zkar/rmi/message.go

Lines 14 to 20 in ac3ac3a

// readMessage peeks the next byte and dispatches to the matching reader.
// Works identically for buffered and streaming input — the per-reader arg
// strategies (exact-count-for-Registry vs sentinel) are chosen internally
// based on each frame's header, not on the input source.
func readMessage(outer *commons.Stream) (Message, error) {
next, err := outer.PeekN(1)

  1. rmi/call.go:24-27 — the CallMessage type doc states "Decoded is always non-nil for a successfully parsed CallMessage", but the actual decoder path at call.go:154-156 swallows decoder errors (if decoded, derr := registryDecoders[op](args); derr == nil { call.Decoded = decoded }), so a well-formed-but-semantically-bad Call (e.g. the name arg isn't a TC_STRING) parses successfully with Decoded == nil. The inline comment in ToString at call.go:46-48 correctly documents this nil possibility — so the type doc is the one to fix.

zkar/rmi/call.go

Lines 23 to 35 in ac3ac3a

//
// Decoded is always non-nil for a successfully parsed CallMessage. Its
// Args slice is the human-oriented view (scalar values inlined, stub
// subtrees referenced by handler). ObjectArgs and Raw expose the raw
// TCContent tree for callers that want to walk the embedded stream.
type CallMessage struct {
ObjID ObjID
Operation int32
MethodHash int64
Raw *serz.Serialization
ObjectArgs []*serz.TCContent
Decoded *DecodedCall
}

🤖 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>
@phith0n
phith0n merged commit 7b4ff6b into master Apr 18, 2026
17 checks passed
@phith0n
phith0n deleted the rmi-registry-only-decoder branch April 18, 2026 17:54
Sign up for free to 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.

1 participant