Repository navigation
adt-rfc-bridge: a stock Eclipse project reaches an HTTP backend over RFC - #2
Merged
Merged
Conversation
The NI framing, the bounds-checked byte reader/writer and the framing-aware proxy are the parts of this module that are not RFC: a second protocol on the same transport wants all three, and DIAG (open-diag-go-pro) is that protocol. Moved out of internal/ with their tests; import paths change from internal/ni to ni, and likewise for wire and sniffer. Nothing else does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162uKF31Qeg14pwMwj4dmts
A real Eclipse ADT session was captured between the client and an AS ABAP 1909 sandbox, on the gateway port. Eleven conversations, all the same shape: the 64-byte gateway record and its acknowledgement, F_INITIALIZE with an 80-byte reply, F_SET_PARTNER_LU_NAME answered by silence, F_ALLOCATE with its reply, and then F_SAP_SEND carrying the calls. Seven of those frames are now a fixture and a test replays them against ServeConscious through a pipe, asserting that we answer what the system answered. It fails, and the failure is the specification. It already found one thing and fixed it. The gateway acknowledgement does not echo the client's protocol level: a client offering "1100" and a client offering "4103" were both answered "4103", over fourteen conversations. We were echoing. Four bytes differ between request and reply and we were patching two of them. It now fails one exchange later, at F_INITIALIZE, which ServeConscious does not implement — it knows the 0x06 0x03 CPIC init and not the 0x06 0x01 APPC verb Eclipse opens with. The reply is within reach: across eleven conversations seventy-four of its eighty bytes are identical, and the six that vary are four digits of the conversation id at offsets 40..47 and a two-byte counter. ServeConscious already reads a conversation id from those same offsets in the branch it does implement. The fixture's bytes are as captured except that addresses, host and user names are replaced by documentation values of the same length, so offsets are unchanged. This repository is public and a capture is not a fixture until nobody's name is in it. The branch is red on purpose: the test is the work order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
Seven frames, byte for byte, against a real Eclipse conversation captured
from the wire. The test that was the work order now passes.
Three verbs were missing and each turned out to be the client's own bytes
with a few changes, the same changes in all eleven captured conversations:
gateway record the protocol level is the gateway's own, not an echo —
"1100" and "4103" are both answered "4103"
F_INITIALIZE the first eighty bytes back, a level byte raised, eight
spaces replaced by a conversation id we choose, and the
client's sequence counter copied over the 0xffff at the
end
F_ALLOCATE the request back with two status bytes set, the same
protocol level written where the client left zeroes, and
that sequence put back again
F_SET_PARTNER_LU_NAME is answered by silence, which the capture shows and
the test now requires: a server that replied there would be wrong in a way
nothing else would catch.
The conversation id is the one field we choose rather than match, so the
test masks it and checks its shape instead — eight ASCII digits. A test that
accepted anything there would accept an empty one.
Twenty-five packages green, vet clean. Next is the first F_SAP_SEND, which
is where the calls start and where the real work begins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
A server has nobody to ask what it is being sent — the client is the one calling — so the shape of SADT_REST_RFC_ENDPOINT is stated here rather than fetched. It was measured with RFC_METADATA_GET(DEEP) against a real system and it is small enough to read: a request is a line, a table of headers and a body, which is what an HTTP request is. Describing that is describing an interface, not copying a dictionary, and no length is carried because nothing in it is fixed-width. Two tests put a whole exchange through the codec and back. The body is binary on purpose: an xstring that round-trips as text would pass a weaker test and fail on the first ADT answer, which is XML with a byte order mark more often than not. Two things the graph needed that the measurement does not mention, both found by the codec refusing it. A scalar field must say so — the dispatch reads Reference.Kind, and a field that leaves it empty is taken for a reference to a type with no name, which fails a long way from the cause. And the reference must repeat the field's internal type, because the validator compares them; when they disagree it reports "xRFC scalar type g is not implemented", which is not what went wrong. Both are noted where they are, so the next reader does not spend the same twenty minutes. Twenty-five packages green, vet clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…rry one Two gaps stood between a decoded Eclipse request and an answer, and both are now closed with a test that failed first. The name of an xRFC parameter is not on the wire. EncodeCutFunctionRequest checks it and then writes both boundaries with no value at all, so the only place it survives is the root element of the XML between them. The server was reading the boundary and getting "", which cannot tell REQUEST from anything else and starts to matter the moment a function has two parameters. It now reads the XML, and if a boundary does carry a name that disagrees, that is an error rather than a preference. And a response had no way to carry one. Response held exports and tables, which is everything a classic function needs and nothing a recursive one does. SADT_REST_RFC_ENDPOINT exports a structure holding a structure, a table and an xstring; that travels as XML between two empty 0x3c02 boundaries in 0x3c05 chunks, exactly as the request arrives. Both encoders learned it, including the S4 one — which is the one that matters, because it is the path ServeConscious takes and therefore the path Eclipse takes. Twenty-five packages green, vet clean. What is left is the handler itself: unwrap REQUEST into an http.Request, call a backend, wrap what comes back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
The handler unwraps SADT_REST_RFC_ENDPOINT into an http.Request, makes it against a backend and wraps what comes back. It has no opinion about ADT — everything that knows what /sap/bc/adt means stays behind the backend URL, which is what lets the same binary prove two different things. Pointed at a real system it proves the transport, because the backend is known good. Pointed at an implementation it proves the implementation, because the transport already is. Two unknowns that would otherwise hide inside each other, separated by one flag, and the flag is there from the first line rather than added once that lesson has been paid for again. Details that are easy to get wrong and are tested rather than hoped: the query survives the URI, hop-by-hop headers are dropped because the connection they belonged to never existed here, Host is set rather than sent, and the status code travels as text because the dictionary calls it a short string and the codec refuses an integer. cmd/adt-rfc-bridge is the binary, and it refuses to start without a backend rather than guessing one. Twenty-five packages green, vet clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
On a real system the logon happens in the RFC conversation and the ICF call inherits it: by the time a request reaches /sap/bc/adt it is already somebody. A bridge is an outsider to that. It hands the exchange to an HTTP backend that never saw the RFC logon, so without credentials the answer is 401 and nothing else — which is exactly what a curl against that system returned this morning, and which I had walked past. Credentials come from a JSON file, or from the environment for the two secret fields. Deliberately not from a flag: an argument is visible to every user on the machine through ps, and a password that has been in a process list has been published. They are optional, because a backend that wants no authentication — our own facade, for one — is configured by leaving them out. The process prints who it will be at startup, without the password, so an authenticated run and an anonymous one are told apart at a glance instead of by a 401 later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
The bridge was making every HTTP request from a clean client, so the sap-contextid cookie that selects an ADT stateful context was thrown away between calls. Lock, write and activate are three calls that must land in the same context; three unrelated sessions instead means the write presents a handle whose session has gone. Per conversation rather than per process: a jar shared between two clients would share an ADT context between them, which is a data leak wearing the costume of a caching bug. So the handler, its client and its jar are made when a connection is accepted, and the backend is still checked once at startup so a bad one fails then rather than on somebody first request. The CSRF dance is deliberately not implemented. Eclipse does it itself — it asks for a token and sends it back — and a bridge that joined in would be answering a question nobody asked. What a bridge owes that exchange is not to get in its way: keep the cookies, keep the headers, drop only what belonged to a connection that never existed here. Read off vsp pkg/adt/http.go, which has the full client-side dance and the scars to go with it, and then deliberately not copied: a pipe needs the jar and not the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
Run against Eclipse rather than the fixture: the gateway record, F_INITIALIZE and F_ALLOCATE are all answered, five times over, and the conversation then stops at the first F_SAP_SEND. Everything built today is confirmed against a real client. The reason is worth writing where the next reader will be standing. An SM59 client logs on with its own verb and the branch above answers it from a template. Eclipse opens the conversation first and sends the logon inside an F_SAP_SEND, so it arrives at the call branch and DecodeCutFunctionRequest says, correctly, that it is not a call. The answer has the same shape as everything else here. Across eleven captured conversations that exchange is always 374 bytes in and 746 out, and those 746 differ in nineteen bytes: the conversation id and the counter, which this file already produces, one byte at 7, and nine more at 606..615 and 735..736 that nobody has identified yet. So tomorrow starts at "identify nine bytes" rather than at "find out what happens", which is the difference between a note and a dead end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…k right
Eclipse got as far as complaining about the uuid, which means the logon was
accepted and the session went on to the next thing. It said:
Invalid uuid detected own: 2BBDE0E0-… given: 05140010-2BBD-E0E0-…
The difference analysis over eleven captured logons reported ten varying
bytes at 606, so the first version copied ten. The field is sixteen. The six
it left behind were identical in all eleven captures because all eleven came
from one machine, and those six are that machine's address packed into the
uuid's node part — so the template's copy was already correct for the only
client that had ever been measured, and wrong for every other.
Measured again across the same eleven, this time by looking for the field
rather than the offset: the request carries 0514 0010 and then sixteen bytes,
the reply carries the same sixteen at 606, and they match in eleven of
eleven. So the GUID is now found by its tag and copied whole. The tag search
is not caution for its own sake: every capture puts it at 225 and every
capture is the same client on the same host, which is not evidence that the
offset is fixed — only that nothing has varied yet.
The logon itself arrives inside F_SAP_SEND once the conversation is open, so
it is told from a call by the absence of the CUT prefix rather than by length.
The same investigation found the leak this file had been carrying. Working
out that the six bytes were an address meant reading the template as data
instead of as a blob, and the rest of it turned out to be a captured system's
host name, instance, address, logon string and user — in UTF-16LE, which is
why the scan that pronounced it clean had missed them. That scan looked for
runs of printable ASCII and every string here has a NUL after each character.
A comment in this file said "the template carries no identifiers" on the
strength of it.
All of them are replaced by same-length documentation values, so the offsets
this file counts by still count to the same place, and the two fields the
bridge overwrites anyway are zeroed instead of left plausible. The values now
describe this bridge, which is what they should have been in the first place:
answering Eclipse with another system's name was a lie as well as a leak.
Nothing here has been pushed, so none of it was public.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…han guessing A first ABAP project against the bridge failed four times, each time further in, and each symptom named something different. "Invalid uuid": the session GUID is sixteen bytes and ten were being copied. The six left behind were identical across all eleven captures because all eleven came from one machine, and those six are that machine's address packed into the uuid's node field. Found by its 0514 0010 tag now, not its offset — every capture puts it at 225 and every capture is the same client, which is not evidence that the offset is fixed. "Unexpected exception in logon operation": scrubbing the captured system's name out of the template left a hard-coded OSD in its place, and the ABAP project is configured with a system id it expects to hear back. Traded a leak for a lie. The identity now comes from the bridge's own configuration. "Response status code is not an integer value": a logon was told from a call by the absence of the CUT prefix, and Eclipse's calls have no CUT prefix either — so every call was answered with a logon template and Eclipse tried to read an HTTP status out of it. A logon opens d9 c6 c3: "RFC" in EBCDIC. The test is what a payload is, not what it is not. Its calls are also framed without the leading chain record, so the two bytes it does not send are supplied and the existing decoder reads the rest unchanged. "FU_NOT_FOUND": the function name arrived as 匀䄀䐀吀开刀䔀匀吀… — Eclipse writes UTF-16 big-endian and this read it little-endian. Not a garbled string but a different one, which reaches the dispatcher as an ordinary lookup miss with nothing in it to suggest an encoding. The byte order is declared, and a review is why this says so rather than guessing. The first fix detected it from where the zero bytes fell, which works for ASCII names and is an inference. The opening field of every chain carries it: across 824 captured frames Eclipse has 0x02 at index 1 in all 444 of its own and the system has 0x01 in all 380 of its, and that is exactly the split between the text each sends. The detection stays as a fallback, applied to names only — a parameter's value never passes through it. The codepage field was the other candidate and the capture rules it out: tag 0x0016 comes only from the system, always "1100", and Eclipse sends none. Dispatcher.Invoke now returns the handler's error alongside the exception key. SYSTEM_FAILURE says "something" to the client and, until this, said only that to us as well. The bridge hashes itself at startup. "Is the running binary the new one" came up at nearly every step of this, and an unchanged symptom cannot otherwise be told from a fix that never shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…ct seam
A live Eclipse "Custom Application Server" logon reached the bridge and stopped
at RFC_GET_FUNCTION_INTERFACE with FU_NOT_FOUND ("CPIC-CALL: 'CMRCV'; null" on
the client). Behind it sat two more walls, each named by measuring a real
Eclipse<->A4H session through the gateway tap rather than by guessing.
Eclipse does not call a function it has not been described. A destination whose
metadata cache does not match the system it is talking to — and every bridge is
such a destination — opens with RFC_GET_FUNCTION_INTERFACE for the function's
parameters, then DDIF_FIELDINFO_GET for each structure they name, and only then
SADT_REST_RFC_ENDPOINT. So both are answered here, generated from the interface
this bridge already declares (internal/rfcserver/adt_metadata.go) and not from
any captured dictionary: the fixed-width rows a client reads are written from
field descriptors, and the descriptors say what an interface is — the parameter
names, the type of each, its width. The rows this server writes are read back
by the client-side decoders this repository already ports from a client that
reads live systems, which is the test that they are what a system sends. The
output order is the system's: a DDIF answer is DFIES_WA, then the LINES_DESCR
xRFC, then X030L_WA — the function's parameter order, not the caller's request
order — confirmed against every metadata answer in the capture.
The request itself rides in a wire family nothing here spoke: 0x4000 a flag,
0x4001 the request, 0x4002 the response, chunked, and the payload is SAP Binary
XML, not the text xRFC of the rest of the package. internal/bxml reads it and
writes it: a token stream with a name table, UTF-8 payloads, lengths written as
code points. The grammar was measured, and the measurement is the test — the
reader consumes every one of 962 captured documents to its last byte and the
writer reproduces each from what the reader made of it. The old note that the
response compressor was unknown was wrong: 0x4000's flag 01 is raw DEFLATE,
which compress/flate reads and writes directly; the metadata tables the client
sends small enough go back uncompressed as 0x0303, which is the form the system
itself uses for the PARAMS table.
A call or an answer longer than one 28000-byte record continues in F_RECEIVE
records, the last one final, the way the system's own long answers do; and a
call that arrives split is joined before it is read. Eclipse-ness is a property
of the conversation, set once its logon is accepted, because a metadata call's
chain opens with the same four bytes as a classic CUT prefix and cannot be told
from an SM59 call by its bytes alone.
Offline against the capture: 100 ADT calls decode and carry a REQUEST document,
384 more on a second capture, and all 18 metadata answers match the system's
tag sequence. The whole suite is green under -race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
The bridge now decodes the ADT request, calls the backend and wraps the answer, and a live Eclipse read that answer and refused it: "CPIC-CALL: 'CMRCV'; null", a refusal at the CPIC receive, below the answer's content. The content was right — measured against the system's own discovery response, the chain is the same thirteen fields in the same order, the session GUID is the one the call carried, the RESPONSE echo matches. What was wrong was the record the answer travelled in. A response leaves the gateway in a header the gateway writes, and it is not the one the encoder built. Measured over 125 responses of a live session, that header is a near-constant frame: gateway id 6, time 00010000, timeout 000001f4, info4 2, sequence 1, and a 32-byte operation-info block whose first four bytes are the message length without its 8-byte final trailer and whose last four are 00060002. The old path built an all-zero header the gateway never emits — no sequence, no timing, an empty operation-info — and that is what CMRCV rejected; the logon was accepted only because it is sent from a captured template that already carries the block. The record builder now reproduces the measured header, with a golden check against the captured bytes (uid, conversation and length blanked — routing only), and carries it on every record of a split answer too. go test -race green; both oracle replays still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
…nection's Capturing what the bridge actually sent (STG_DUMP) and diffing it against the system settled CMRCV. The chain and its trailer were already byte-identical to the system's (…0523 ffff 0000 ffff); only the record header differed, and the previous fix had reproduced the wrong one. A live session's responses wear two header shapes, and they belong to two connection roles, not two protocols. The connection Eclipse's ADT calls arrive on — the one that opens with 0203 — answers with what is here called format B: operation-info 00006d60 00000002, this record's full data length, 00000001 00000000, the gateway level "4103", and a varying last word; info3 is 1; the data ends at the chain's end, no trailer. The other connection (0204) answers in format A: the length less eight, a 00060002 tail, and eight trailing bytes. The previous commit built A; Eclipse aborted each answer with an F_0x0b and reported CMRCV. Confirmed independently by a second reviewer's diff of the same capture. The builder now writes B, and a scratch byte-diff against the captured SADT discovery response header found no differing byte outside uid, gateway id, conversation and length. STG_DUMP records both directions to a file for the next such question. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
…a session With the transport fixed, a live Eclipse logged on and ran the whole project-open sequence against the bridge — discovery, compatibility graph, the 25 KB discovery document, feeds, system components, system messages, the gateway error log, runtime dumps, the object-type list at 606 KB across two records — every one of them 200. The first POST, of /sap/bc/adt/repository/typestructure, came back 403 with "X-CSRF-Token: Required" and a body saying validation failed. The handler had a comment claiming Eclipse does the CSRF dance itself, so the bridge should stay out of the way. That is true of Eclipse over HTTPS and false here, and the dump says so: the POST carried no token and nothing in the exchange had ever asked for one. Over RFC there is no HTTP session between Eclipse and anything — it hands over a request and expects the far side to be somebody already. The bridge terminates HTTP and re-originates it, so the session is the bridge's and so is the token. Ported from vsp pkg/adt/http.go, which has this worked out against real systems: probe /sap/bc/adt/core/discovery with X-CSRF-Token: fetch, HEAD first and GET after, because some systems mint nothing on a HEAD; keep the token beside the jar whose session it belongs to; set it on POST, PUT, DELETE and PATCH. "Required" arrives in the same header as a token and is not one. Narrowed on the way, twice. vsp refreshes on any 403, which suits a tool driving its own session; here a 403 that is not a CSRF refusal is the backend's answer to Eclipse and is passed through untouched, retried never. And a backend that mints no token is not a failure but a backend without CSRF — our own façade is one — so the request goes without the header rather than erroring, which two existing tests caught before this shipped. A token the caller put on the request was minted for a session the backend never saw, so it is replaced rather than forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
…g anything The façade this bridge exists to serve is reachable two ways in the lab, plain on 3030 and behind TLS on 44300, and the TLS one carries a private certificate. Pointing the bridge at it needed a way to trust that certificate. There is deliberately no flag to skip verification. A bridge that skips it is a bridge that can be answered by anyone on the path, and the whole point of the thing is to carry somebody's development session. So the configuration names a PEM instead — ca_file — and its certificates are added to the system roots rather than replacing them, with server_name for a backend reached by an address its certificate does not carry. The test is the pair: the same https backend is refused when the certificate is not named and reached when it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
Pointing a plain RFC client at the bridge — not Eclipse, the `rfc` CLI in this repository — found a defect the Eclipse work had hidden and then a gap. The defect. The gateway header added for Eclipse carries the communication and connection index in the last four bytes of its operation-info, and those were a constant. Eclipse never looks; an RFC client does, and refused every reply with "APPC response identity does not match the active conversation". Echoing the call's own bytes back is also wrong: a request arrives with the communication index set to 0xffff, meaning unset, and handing 0xffff back is a reply to nobody. A reply carries a communication index of zero — which is what the logon accept already carried, and what the client accepts — and the connection index the call came in on. One shape still serves both clients, so the branch by client kind that this first grew is gone again. The gap. Eclipse learns a structure through DDIF_FIELDINFO_GET. An RFC client asks RFC_METADATA_GET and then RFC_GET_STRUCTURE_DEFINITION, neither of which existed here, so it got as far as the function interface and stopped. The latter is now answered from the same dictionary, and two things had to be right for it to be read: RFC_FIELDS is a FLAT view, so a component of a substructure cannot be listed beside its parent — the offsets overlap and the client says so — and the line structures have to be types in their own right, because a client that resolves field by field asks for them by name. A caller may also name a table among its outputs without sending one, and is answered in the classic framing, by name, because it gave no number to be answered by. What this buys, measured: `rfc describe SADT_REST_RFC_ENDPOINT` through the bridge returns the interface with both parameters typed. That is the metadata bootstrap proven against a live client rather than against a capture — it had never run live, because the Eclipse that tested it had the answers cached. Where it stops: `rfc call` then fails in the client's own classic structure codec, which does not implement a nested structure (type v). This function's parameters are recursive and travel as BASXML, which is a different codec in that client. The bridge is not in the way; the client needs the recursive path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
The push gate stopped this branch over a "private address" in internal/cpic/grammar_test.go. It is the name of a test, forty-six letters and digits long, which is exactly what the scanner's base64-run pattern looks for: decode those characters as base64 and the bytes contain a sequence that reads back as an address in the 172.16/12 range. The file holds no address in any form. Every literal in it was decoded as hex and as base64 and searched for both the dotted text and the packed prefix, and the only hit is the identifier itself. Allowlisted with that as the reason, which is the point of the list being tracked and of every entry needing one. It is the sixtieth entry, and all sixty are this same collision — a long CamelCase identifier is indistinguishable from a base64 payload to a pattern that only counts characters. The tool's own comment says a check that cries wolf gets read once and ignored after; sixty says the base64 half of it has reached that point and wants a narrower rule, which is a change to make deliberately and not inside a push. The value itself is deliberately not written here: a commit message is scanned too, and quoting it was what made the first attempt at this commit trip the gate on itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: oisee <ooisee@gmail.com>
The newest and largest thing the repo does now gets the top banner: a stock Eclipse project reaching an HTTP backend over RFC, what it took, and a link to where the protocol facts are written down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SjjSbEc2aTNBW6rVQsn9S
Main advanced in parallel (v0.2.0, the fast serializer, a new README with a
logo and banner history, the rfc→orfc rename, a trusted-RFC type-T server).
Resolved:
- README: main's canonical version, with the bridge banner added on top as the
newest headline.
- CHANGELOG: main's structure, the bridge noted under Unreleased.
- internal/{sniffer,ni,wire}: restored main's shims, kept alongside the public
packages, so the lab tools that still import them build.
- serve_typet.go (main's trusted-RFC server): adapted to this branch's new
signatures — Invoke returns the handler's cause as a third value, and
EncodeCutFunctionResponseS4 takes the session GUID.
The bridge's own packages did not conflict. Whole tree green: build, vet, and
go test -race across 29 packages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SjjSbEc2aTNBW6rVQsn9S
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds an ADT-over-RFC bridge with Binary XML support, Eclipse handshake handling, backend forwarding, CSRF/session management, and public NI/wire/sniffer packages.
Changes:
- Adds bounded wire and NI framing implementations with tests and fuzzing.
- Implements ADT metadata, Binary XML, HTTP backend, cookies, and CSRF handling.
- Adds Eclipse/RFC server support, bridge CLI, documentation, and integration fixtures.
File summaries
| File | Description |
|---|---|
| wire/wire.go | Updated as part of this pull request. |
| wire/wire_test.go | Updated as part of this pull request. |
| sniffer/sniffer.go | Updated as part of this pull request. |
| sniffer/sniffer_test.go | Updated as part of this pull request. |
| sniffer/recorder.go | Updated as part of this pull request. |
| rfc/callback.go | Updated as part of this pull request. |
| README.md | Updated as part of this pull request. |
| ni/property_test.go | Updated as part of this pull request. |
| ni/frame.go | Updated as part of this pull request. |
| ni/frame_test.go | Updated as part of this pull request. |
| internal/transport/transport.go | Updated as part of this pull request. |
| internal/transport/transport_test.go | Updated as part of this pull request. |
| internal/rfcserver/wire_constants.go | Updated as part of this pull request. |
| internal/rfcserver/testdata/eclipse-gateway-conversation.json | Updated as part of this pull request. |
| internal/rfcserver/serve_eclipse_test.go | Updated as part of this pull request. |
| internal/rfcserver/serve_conscious.go | Updated as part of this pull request. |
| internal/rfcserver/response.go | Updated as part of this pull request. |
| internal/rfcserver/response_test.go | Updated as part of this pull request. |
| internal/rfcserver/response_eclipse.go | Updated as part of this pull request. |
| internal/rfcserver/request_eclipse_test.go | Updated as part of this pull request. |
| internal/rfcserver/oracle_eclipse_test.go | Updated as part of this pull request. |
| internal/rfcserver/eclipse_test_helpers_test.go | Updated as part of this pull request. |
| internal/rfcserver/dispatcher.go | Updated as part of this pull request. |
| internal/rfcserver/compact_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_response_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_request_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_metadata.go | Updated as part of this pull request. |
| internal/rfcserver/adt_metadata_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_logon.go | Updated as part of this pull request. |
| internal/rfcserver/adt_logon_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_handler.go | Updated as part of this pull request. |
| internal/rfcserver/adt_handler_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_ddic_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_csrf.go | Updated as part of this pull request. |
| internal/rfcserver/adt_csrf_test.go | Updated as part of this pull request. |
| internal/rfcserver/adt_backend.go | Updated as part of this pull request. |
| internal/rfcserver/adt_backend_test.go | Updated as part of this pull request. |
| internal/metadata/structure_definition.go | Updated as part of this pull request. |
| internal/gateway/gateway.go | Updated as part of this pull request. |
| internal/bxml/oracle_test.go | Updated as part of this pull request. |
| internal/bxml/encode.go | Updated as part of this pull request. |
| internal/bxml/bxml.go | Updated as part of this pull request. |
| internal/bxml/bxml_test.go | Updated as part of this pull request. |
| internal/appc/records.go | Updated as part of this pull request. |
| internal/appc/fragmentation.go | Updated as part of this pull request. |
| internal/appc/appc.go | Updated as part of this pull request. |
| docs/protocol-primer.md | Updated as part of this pull request. |
| docs/architecture.md | Updated as part of this pull request. |
| conformance/ni_vectors_test.go | Updated as part of this pull request. |
| cmd/rfc-lab/main.go | Updated as part of this pull request. |
| cmd/adt-rfc-bridge/main.go | Updated as part of this pull request. |
| CHANGELOG.md | Updated as part of this pull request. |
| AGENTS.md | Updated as part of this pull request. |
| .gitignore | Updated as part of this pull request. |
Review details
Suppressed comments (17)
AGENTS.md:62
- This new command correctly points at
./ni, but the repository's CI fuzz step still invokes./internal/ni; as a result every PR workflow fails before running these decoder fuzz checks. Update.github/workflows/ci.ymlalongside this package move so the documented verification actually runs.
go vet ./...
CHANGELOG.md:14
- This release note declares the public moves complete, but
docs/provenance.md:40-44still records the destinations asinternal/ni,internal/wire, andinternal/sniffer, paths that no longer exist. Update the provenance index alongside the move so the required port attribution and upstream-fix map remain usable.
- `cmd/adt-rfc-bridge`: a stock Eclipse "Custom Application Server" project
reaches an HTTP backend over RFC. It terminates the RFC/CPIC conversation,
unwraps the ADT HTTP request tunnelled in `SADT_REST_RFC_ENDPOINT`, forwards
it to `--backend`, and wraps the answer. New package `internal/bxml` (SAP
Binary XML), the dictionary bootstrap handlers, the CSRF dance, and an
cmd/adt-rfc-bridge/main.go:93
- This startup check only calls
ADTRestHandler, which validates the URL but does not callBackend.Transport. A missing or invalidca_fileis therefore discovered only afterAcceptwhen the first client connects, contrary to the adjacent “fails now” promise; validatetarget.Transport()before opening the listener.
if _, err := rfcserver.ADTRestHandler(target, nil); err != nil {
internal/bxml/bxml.go:167
Decodeaccepts and materializes an arbitrarily large BXML document, while the RFC request decoder allows a compact field up to CPIC's 256 MiB default and there is no BXML-specific configurable limit. The bridge is unauthenticated on its RFC side, so a caller can submit a valid large document and force the tree plus string copies into memory; enforce a configured BXML/document bound before parsing (and keep it distinct from the protocol's maximum).
func Decode(doc []byte) (*Element, error) {
if !bytes.HasPrefix(doc, magic) {
return nil, fmt.Errorf("%w: no BXML magic", ErrBXML)
}
i := len(magic)
var names []string
root := &Element{Name: "#document"}
stack := []*Element{root}
internal/rfcserver/adt_ddic.go:419
- Both
RFC_FUNINTdescriptors leavePositionat its zero value. The function-interface wire field is 1-based (the client-side metadata fixtures encode position 1), so the bootstrap response advertises invalid parameter geometry even though the rows are otherwise ordered correctly. Set the appropriate 1-based position when constructing each descriptor.
{ParameterClass: "E", ParameterName: "RESPONSE", TableName: adtRestResponse, Exid: "v", InternalLength: 40, ParameterText: "ADT response"},
{ParameterClass: "I", ParameterName: "REQUEST", TableName: adtRestRequest, Exid: "v", InternalLength: 40, ParameterText: "ADT request"},
internal/rfcserver/adt_ddic.go:554
- For a table type,
fieldsis replaced with the row type's fields, but the filter still comparesprecfieldwith the table type name. EveryIHTTPNVPfield hasprecfield == IHTTPNVP, soRFC_GET_STRUCTURE_DEFINITION(TIHTTPNVP)returns an emptyFIELDStable despite the comment promising the line structure; use the row type as the owner for this branch so clients can resolve the table's fields.
var top []ddicField
for _, f := range fields {
if f.precfield == "" || f.precfield == t.name {
top = append(top, f)
}
internal/rfcserver/adt_handler.go:295
- The bridge's per-connection cookie jar is supposed to own the backend session, but this forwards an RFC-supplied
Cookieheader intohttp.Client. That lets the caller select or inject a different backend context and bypasses the jar's session isolation; dropCookieheaders from the decoded request.
if name == "" || strings.Contains(hopByHop, strings.ToLower(name)) {
continue
internal/rfcserver/adt_handler.go:231
- This is substring matching rather than token matching: an end-to-end
Authorizationheader is considered hop-by-hop becauseauthorizationoccurs insideproxy-authorization. That can silently remove valid ADT request headers; use an exact case-insensitive hop-header set and reuse the predicate in every filter.
if strings.Contains(hopByHop, strings.ToLower(name)) {
internal/rfcserver/adt_handler.go:313
- The response paths copy
Set-CookieandX-CSRF-Tokeninto the RFC HTTP response. Those are backend session credentials/tokens that should remain in the per-connection jar/state; exposing them lets the RFC caller steal or replay the backend session and can feed caller-owned cookies back on later requests. Filter bridge-owned state headers from the response as well.
if strings.Contains(hopByHop, strings.ToLower(name)) {
continue
}
for _, value := range values {
headers = append(headers, map[string]any{"NAME": name, "VALUE": value})
internal/rfcserver/request.go:255
- The
maxCompactBytesbound is enforced only after inflation. An uncompressedtagCompactRequestcan therefore be as large as the transport's 256 MiB NI limit and is passed tobxml.Decode, which builds its tree without the 64 MiB ADT bound. Apply the same size check to the final compact bytes before storing/decoding them, otherwise an unauthenticated caller can force excessive memory use.
internal/rfcserver/request.go:443 - After stripping a CUT prefix, the first chained field still names
TagContextEndas its predecessor, but this diagnostic decoder always starts with0x0000. Consequently every valid classic request is reported asundecodablewhenServeConsciouslogs an otherwise unrecognized call; selectcpic.TagContextEndfor the CUT path and0x0000only for Eclipse framing.
internal/rfcserver/request.go:245 - An opening
TagXRfcParametersetshaveXrfc, but the final validation before the function-name check never tests it. A request with a missing closing xRFC boundary is therefore accepted and the partial XML is silently discarded, so a handler may answer as though the parameter was absent; rejecthaveXrfcbefore this check.
internal/rfcserver/response.go:144 - Unlike
EncodeCutFunctionResponse, this S/4 response path writesRowByteLengthin the table header but never verifies the rows before appending them. A handler can therefore generate a response whose table geometry disagrees with its row data, causing the client to misparse the following fields; add the same per-row validation used by the other encoder.
internal/rfcserver/response.go:110 - The classic/S4 path has no way to receive
Response.Outputs: this encoder only acceptsexports,tables, andxRFCvalues, even thoughFunctionInterfaceHandlerandFieldInfoHandlerput requested scalar/xRFC values such asREMOTE_BASXML_SUPPORTED,DDOBJTYPE, andX030L_WAinResponse.OutputswhileExportsstays empty. Classic metadata clients therefore miss those outputs; pass the orderedOutputsthrough this API (and its caller) or convert them before encoding.
internal/rfcserver/response_eclipse.go:115 - The unnumbered-table branch skips the row-size validation that the numbered branch performs below. A handler can therefore emit a table header claiming
RowByteLengthwhile sending rows of another size, producing a malformed response that the client cannot decode; apply the same check before appending each unnumbered row.
internal/rfcserver/serve_eclipse_test.go:37 - This test is run as a normal passing assertion, but the comment still says it is expected to fail and that
ServeConsciousdoes not implement the opening sequence. That contradicts the test's purpose and the implementation under review, so future maintainers may treat a real regression as intentional; update the comment to describe the replay as an executable handshake specification.
ni/frame.go:8 - The port has moved these files to public
ni/,wire/, andsniffer/, butdocs/provenance.md:40-44,101still points at the deletedinternal/...paths. That leaves the required provenance index unable to locate the new files and prevents upstream-fix tracing; update the index as part of the package move.
- Files reviewed: 56/57 changed files
- Comments generated: 10
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ) | ||
|
|
||
| func main() { | ||
| listen := flag.String("listen", ":3300", "gateway address Eclipse connects to") |
| case n < 0x10000: | ||
| return append(out, byte(0xe0|(n>>12)), byte(0x80|((n>>6)&0x3f)), byte(0x80|(n&0x3f))) | ||
| default: | ||
| return append(out, byte(0xf0|(n>>18)), byte(0x80|((n>>12)&0x3f)), byte(0x80|((n>>6)&0x3f)), byte(0x80|(n&0x3f))) |
Comment on lines
+286
to
+291
| func PayloadRoot(document *Element) (*Element, error) { | ||
| abap := document.Child("abap") | ||
| if abap == nil && len(document.Children) > 0 { | ||
| abap = document.Children[0] | ||
| } | ||
| values := abap.Child("values") |
Comment on lines
+160
to
+163
| body, err := io.ReadAll(io.LimitReader(answer.Body, adtMaxBodyBytes)) | ||
| if err != nil { | ||
| return Response{}, fmt.Errorf("rfcserver: the backend's answer could not be read: %w", err) | ||
| } |
Comment on lines
+280
to
+285
| ref, err := url.Parse(target) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("rfcserver: REQUEST_LINE URI %q: %w", target, err) | ||
| } | ||
| body, _ := fields["MESSAGE_BODY"].([]byte) | ||
| outgoing, err := http.NewRequestWithContext(ctx, method, base.ResolveReference(ref).String(), bytes.NewReader(body)) |
Comment on lines
+131
to
+139
| payload := got[80:] | ||
| if pending != nil { | ||
| pending = append(pending, payload...) | ||
| payload = pending | ||
| } | ||
| if got[30] != recordInfoFinal { | ||
| if pending == nil { | ||
| pending = append([]byte(nil), payload...) | ||
| } |
| // is answered in the gateway's own header; a classic client checks | ||
| // the APPC extended info instead, and the gateway block makes it | ||
| // say the reply belongs to another conversation. | ||
| records, werr := responseRecords(respCUT, convID, binary.BigEndian.Uint16(got[4:6]), got[76:80]) |
Comment on lines
+36
to
+37
| // So this test is expected to fail, and the failure is the specification: it | ||
| // names the first frame where we diverge and shows both sides of it. |
Comment on lines
10
to
11
| exports = append(exports, cpic.NamedValue{Name: name, Value: val}) | ||
| } | ||
| encoded, err := rfcserver.EncodeCutFunctionResponse(exports, nil) | ||
| encoded, err := rfcserver.EncodeCutFunctionResponse(exports, nil, nil) |
oisee
added a commit
that referenced
this pull request
Sep 16, 2026
adt-rfc-bridge: a stock Eclipse project reaches an HTTP backend over RFC
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.
A Custom Application Server project in Eclipse does not open an HTTP port. It
logs on over RFC on the gateway port and tunnels every ADT request inside one
RFC call,
SADT_REST_RFC_ENDPOINT. This addscmd/adt-rfc-bridge: itterminates that RFC conversation, unwraps the HTTP exchange, forwards it to
--backend, and wraps the answer. Pointed at a real system it proves thetransport; pointed at an HTTP façade it proves the façade.
Measured end to end against a real system and against the sibling
open-steamgate: logon, discovery, identity, the repository tree expanded,
a source opened, unit-test metadata and check runs — all answered.
Four things each refuse in silence, and each is a commit here:
been described, so
RFC_GET_FUNCTION_INTERFACE,DDIF_FIELDINFO_GETandRFC_GET_STRUCTURE_DEFINITIONare answered from a stated type graph — theinterface, not a bundled dictionary — and in the function's own parameter
order.
protocol uses.
internal/bxmlreads and writes the token stream (namereferences are index-plus-two; lengths are byte counts written as UTF-8
scalars), verified by round-tripping every document of a captured session.
the wrong one is refused at the CPIC receive (
CMRCV) before the content isread. The communication and connection index a client verifies are answered,
not echoed.
owns the session, its cookies and its token, or every read works and the
first write is a 403. Ported from vsp
pkg/adt, narrowed to retry only anexplicit CSRF refusal and to treat a backend that mints no token as one
without CSRF.
An https backend is reached by naming its certificate (
ca_file); there isdeliberately no option to skip verification.
The protocol facts are written up in the sibling repo,
docs/adt-over-rfc.md.🤖 Generated with Claude Code