Skip to main content

ghidra_ipc/
rpc.rs

1//! JSON-RPC 2.0 host <-> worker types + the versioned init handshake (spec §5).
2
3use serde::{Deserialize, Serialize};
4
5/// The protocol version this host speaks. The worker must announce a matching
6/// value in its init frame, else the host rejects it (WORKER_INCOMPATIBLE).
7pub const PROTOCOL_VERSION: u32 = 1;
8
9/// JSON-RPC 2.0 id: a number, a string, or **null**. Null is required by JSON-RPC 2.0 §5 on an
10/// error response to a request whose id couldn't be determined (parse error / invalid request), so
11/// the host must be able to receive it — otherwise a worker's parse-error reply fails to
12/// deserialize and its error is silently swallowed. (The host never *sends* a null id.)
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum RpcId {
16    Null,
17    Number(i64),
18    String(String),
19}
20
21/// A JSON-RPC 2.0 request (host -> worker).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RpcRequest {
24    pub jsonrpc: JsonRpcVersion,
25    pub id: RpcId,
26    pub method: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub params: Option<serde_json::Value>,
29}
30
31/// A JSON-RPC 2.0 response (worker -> host): `result` XOR `error`, never both, never neither
32/// (JSON-RPC 2.0 §5). Modeled as an **untagged enum of two complete structs** so the illegal
33/// states are unrepresentable. Deliberately NOT `#[serde(flatten)]` over an untagged inner enum
34/// — that serde combination double-buffers (flatten → map, untagged → `Content`) and is a known
35/// deserialize failure mode (fails outright or degrades integers to floats).
36///
37/// The XOR is enforced at the deserialize boundary by a hand-written `Deserialize` (below), not
38/// merely asserted by the type shape: a plain untagged derive would match `Success` first and
39/// silently DROP a present `error` when both keys appear, letting a failed worker call read back
40/// as a bogus success. `Serialize` stays derived (untagged → each variant emits just its fields).
41#[derive(Debug, Clone, Serialize)]
42#[serde(untagged)]
43pub enum RpcResponse {
44    Success {
45        jsonrpc: JsonRpcVersion,
46        id: RpcId,
47        result: serde_json::Value,
48    },
49    Failure {
50        jsonrpc: JsonRpcVersion,
51        id: RpcId,
52        error: serde_json::Value,
53    },
54}
55
56impl<'de> Deserialize<'de> for RpcResponse {
57    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
58        // Parse to a JSON object and enforce the invariants by KEY PRESENCE. Presence (not
59        // Option/None) is what distinguishes `result XOR error`: a legitimately-null success
60        // (`"result": null`, allowed by JSON-RPC 2.0) has the `result` KEY present, so it is a
61        // Success — an `Option<Value>` intermediate would collapse `null` to `None` and wrongly
62        // reject it. Unknown top-level keys are rejected (strict wire contract). `jsonrpc`/`id`
63        // reuse their own types (so `id: null` is accepted per §5, and large u64s stay exact).
64        let value = serde_json::Value::deserialize(d)?;
65        let obj = value
66            .as_object()
67            .ok_or_else(|| serde::de::Error::custom("JSON-RPC response must be a JSON object"))?;
68        for key in obj.keys() {
69            if !matches!(key.as_str(), "jsonrpc" | "id" | "result" | "error") {
70                return Err(serde::de::Error::custom(format!(
71                    "unexpected field `{key}` in JSON-RPC response"
72                )));
73            }
74        }
75        let jsonrpc: JsonRpcVersion = obj
76            .get("jsonrpc")
77            .ok_or_else(|| serde::de::Error::custom("JSON-RPC response missing `jsonrpc`"))
78            .and_then(|v| serde_json::from_value(v.clone()).map_err(serde::de::Error::custom))?;
79        let id: RpcId = obj
80            .get("id")
81            .ok_or_else(|| serde::de::Error::custom("JSON-RPC response missing `id`"))
82            .and_then(|v| serde_json::from_value(v.clone()).map_err(serde::de::Error::custom))?;
83        match (obj.contains_key("result"), obj.contains_key("error")) {
84            (true, false) => Ok(RpcResponse::Success {
85                jsonrpc,
86                id,
87                result: obj["result"].clone(),
88            }),
89            (false, true) => Ok(RpcResponse::Failure {
90                jsonrpc,
91                id,
92                error: obj["error"].clone(),
93            }),
94            (true, true) => Err(serde::de::Error::custom(
95                "JSON-RPC response has both `result` and `error`; exactly one is required",
96            )),
97            (false, false) => Err(serde::de::Error::custom(
98                "JSON-RPC response has neither `result` nor `error`; exactly one is required",
99            )),
100        }
101    }
102}
103
104/// A JSON-RPC 2.0 notification (no `id`). The worker's **init handshake is a notification** —
105/// `{ jsonrpc:"2.0", method:"worker/init", params:<InitAnnounce> }` — so the first frame rides
106/// the SAME JSON-RPC parse path as every later frame (spec §5): the host reads frame 1, parses
107/// it as a notification, checks `method == INIT_METHOD`, and deserializes `params` as
108/// `InitAnnounce`. `InitAnnounce` is never sent as a naked object.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RpcNotification {
111    pub jsonrpc: JsonRpcVersion,
112    pub method: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub params: Option<serde_json::Value>,
115}
116
117/// The method name of the worker's init-handshake notification (spec §5).
118pub const INIT_METHOD: &str = "worker/init";
119
120/// A ZST that (de)serializes as the constant string "2.0", rejecting anything else.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct JsonRpcVersion;
123
124impl Serialize for JsonRpcVersion {
125    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
126        s.serialize_str("2.0")
127    }
128}
129
130impl<'de> Deserialize<'de> for JsonRpcVersion {
131    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
132        let v = String::deserialize(d)?;
133        if v == "2.0" {
134            Ok(JsonRpcVersion)
135        } else {
136            Err(serde::de::Error::custom(format!(
137                "unsupported jsonrpc version: {v}"
138            )))
139        }
140    }
141}
142
143/// The worker's first frame after connect (spec §5 init handshake). Carries the connection
144/// token (§6): the host generated it and passed it to the worker out-of-band (a 0600 file);
145/// the host verifies it here to reject any other local process that raced the loopback port.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct InitAnnounce {
148    pub protocol_version: u32,
149    pub ghidra_version: String,
150    pub worker_script_version: String,
151    pub token: String,
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn request_renders_jsonrpc_version_and_omits_absent_params() {
160        let req = RpcRequest {
161            jsonrpc: JsonRpcVersion,
162            id: RpcId::Number(7),
163            method: "decompile_function".to_string(),
164            params: None,
165        };
166        let json = serde_json::to_value(&req).unwrap();
167        assert_eq!(json["jsonrpc"], "2.0");
168        assert_eq!(json["id"], 7);
169        assert_eq!(json["method"], "decompile_function");
170        assert!(json.get("params").is_none());
171    }
172
173    #[test]
174    fn id_accepts_string_and_number() {
175        let s: RpcId = serde_json::from_str("\"abc\"").unwrap();
176        let n: RpcId = serde_json::from_str("42").unwrap();
177        assert_eq!(s, RpcId::String("abc".to_string()));
178        assert_eq!(n, RpcId::Number(42));
179    }
180
181    #[test]
182    fn rejects_wrong_jsonrpc_version() {
183        let bad = r#"{"jsonrpc":"1.0","id":1,"method":"x"}"#;
184        assert!(serde_json::from_str::<RpcRequest>(bad).is_err());
185    }
186
187    #[test]
188    fn init_announce_roundtrips_and_version_mismatch_is_detectable() {
189        let ann = InitAnnounce {
190            protocol_version: PROTOCOL_VERSION,
191            ghidra_version: "12.1.2".to_string(),
192            worker_script_version: "0.0.0".to_string(),
193            token: "0123456789abcdef0123456789abcdef".to_string(),
194        };
195        let s = serde_json::to_string(&ann).unwrap();
196        let back: InitAnnounce = serde_json::from_str(&s).unwrap();
197        assert_eq!(back, ann);
198        assert_eq!(back.protocol_version, PROTOCOL_VERSION);
199        assert_eq!(back.token, "0123456789abcdef0123456789abcdef");
200
201        let skewed = InitAnnounce {
202            protocol_version: PROTOCOL_VERSION + 1,
203            ..ann
204        };
205        assert_ne!(skewed.protocol_version, PROTOCOL_VERSION);
206    }
207
208    #[test]
209    fn response_is_result_xor_error_both_ways() {
210        let ok = RpcResponse::Success {
211            jsonrpc: JsonRpcVersion,
212            id: RpcId::Number(1),
213            result: serde_json::json!({"ok": true}),
214        };
215        let jv = serde_json::to_value(&ok).unwrap();
216        assert_eq!(jv["result"]["ok"], true);
217        assert!(jv.get("error").is_none());
218
219        let err = RpcResponse::Failure {
220            jsonrpc: JsonRpcVersion,
221            id: RpcId::Number(1),
222            error: serde_json::json!({"code": "X"}),
223        };
224        let jv = serde_json::to_value(&err).unwrap();
225        assert_eq!(jv["error"]["code"], "X");
226        assert!(jv.get("result").is_none());
227
228        // Deserialize BOTH arms (the untagged enum selects by which key is present).
229        let back_ok: RpcResponse = serde_json::from_value(serde_json::json!({
230            "jsonrpc": "2.0", "id": 1, "result": {"ok": true}
231        }))
232        .unwrap();
233        assert!(matches!(back_ok, RpcResponse::Success { .. }));
234        let back_err: RpcResponse = serde_json::from_value(serde_json::json!({
235            "jsonrpc": "2.0", "id": 1, "error": {"code": "X"}
236        }))
237        .unwrap();
238        assert!(matches!(back_err, RpcResponse::Failure { .. }));
239
240        // Regression guard: a large u64 in `result` survives intact (the enum shape avoids the
241        // flatten+untagged number-degradation bug that would round it through f64).
242        let back_big: RpcResponse = serde_json::from_value(serde_json::json!({
243            "jsonrpc": "2.0", "id": 1, "result": {"n": 9_007_199_254_740_993_u64}
244        }))
245        .unwrap();
246        match back_big {
247            RpcResponse::Success { result, .. } => {
248                assert_eq!(result["n"].as_u64(), Some(9_007_199_254_740_993));
249            }
250            RpcResponse::Failure { .. } => panic!("expected Success"),
251        }
252    }
253
254    #[test]
255    fn response_rejects_both_result_and_error() {
256        // JSON-RPC 2.0 §5: a response has result XOR error, never both. An object carrying BOTH
257        // must be REJECTED, not silently deserialized as Success with the error dropped (which
258        // would let a failed worker call read back as a bogus success — the write-safety hole).
259        let both = serde_json::json!({
260            "jsonrpc": "2.0", "id": 1, "result": {"ok": true}, "error": {"code": "BOOM"}
261        });
262        assert!(
263            serde_json::from_value::<RpcResponse>(both).is_err(),
264            "a response with BOTH result and error must be rejected"
265        );
266
267        // ...and neither (already guaranteed by the two complete structs, pinned here too).
268        let neither = serde_json::json!({ "jsonrpc": "2.0", "id": 1 });
269        assert!(
270            serde_json::from_value::<RpcResponse>(neither).is_err(),
271            "a response with NEITHER result nor error must be rejected"
272        );
273    }
274
275    #[test]
276    fn response_accepts_null_result_and_null_id() {
277        // JSON-RPC 2.0: `result: null` is a valid (null) success — the KEY is present, so it must
278        // deserialize as Success, NOT be rejected as "neither" (an Option<Value> intermediate would
279        // collapse null→None and wrongly reject it).
280        let null_result: RpcResponse = serde_json::from_value(serde_json::json!({
281            "jsonrpc": "2.0", "id": 1, "result": null
282        }))
283        .unwrap();
284        match null_result {
285            RpcResponse::Success { result, .. } => assert!(result.is_null()),
286            RpcResponse::Failure { .. } => panic!("`result: null` must deserialize as Success"),
287        }
288
289        // JSON-RPC 2.0 §5: an error reply to an unparseable request carries `id: null`; the host
290        // must receive it, not choke on the id type and swallow the worker's error.
291        let null_id: RpcResponse = serde_json::from_value(serde_json::json!({
292            "jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}
293        }))
294        .unwrap();
295        match null_id {
296            RpcResponse::Failure { id, error, .. } => {
297                assert_eq!(id, RpcId::Null);
298                assert_eq!(error["code"], -32700);
299            }
300            RpcResponse::Success { .. } => panic!("expected Failure"),
301        }
302
303        // An unknown top-level field is still rejected (strict wire contract preserved).
304        let extra = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "result": {}, "surprise": 9 });
305        assert!(
306            serde_json::from_value::<RpcResponse>(extra).is_err(),
307            "an unknown top-level field must be rejected"
308        );
309    }
310
311    #[test]
312    fn rpc_id_null_roundtrips() {
313        assert!(serde_json::to_value(RpcId::Null).unwrap().is_null());
314        let back: RpcId = serde_json::from_value(serde_json::json!(null)).unwrap();
315        assert_eq!(back, RpcId::Null);
316    }
317
318    #[test]
319    fn notification_without_params_roundtrips() {
320        let note = RpcNotification {
321            jsonrpc: JsonRpcVersion,
322            method: "worker/ping".to_string(),
323            params: None,
324        };
325        let s = serde_json::to_string(&note).unwrap();
326        assert!(!s.contains("params")); // skipped when None
327                                        // Deserializes from JSON that omits the `params` key entirely.
328        let back: RpcNotification =
329            serde_json::from_str(r#"{"jsonrpc":"2.0","method":"worker/ping"}"#).unwrap();
330        assert_eq!(back.method, "worker/ping");
331        assert!(back.params.is_none());
332    }
333
334    #[test]
335    fn init_is_a_worker_init_notification() {
336        let ann = InitAnnounce {
337            protocol_version: PROTOCOL_VERSION,
338            ghidra_version: "12.1.2".to_string(),
339            worker_script_version: "0.0.0".to_string(),
340            token: "t".to_string(),
341        };
342        let note = RpcNotification {
343            jsonrpc: JsonRpcVersion,
344            method: INIT_METHOD.to_string(),
345            params: Some(serde_json::to_value(&ann).unwrap()),
346        };
347        let jv = serde_json::to_value(&note).unwrap();
348        assert_eq!(jv["jsonrpc"], "2.0");
349        assert_eq!(jv["method"], "worker/init");
350        // Host-side parse of frame 1: it's a notification whose method is INIT_METHOD, and
351        // params deserialize back to InitAnnounce — one uniform JSON-RPC path, no naked object.
352        let parsed: RpcNotification = serde_json::from_value(jv).unwrap();
353        assert_eq!(parsed.method, INIT_METHOD);
354        let back: InitAnnounce = serde_json::from_value(parsed.params.unwrap()).unwrap();
355        assert_eq!(back, ann);
356    }
357}