Skip to main content

sqry_daemon/ipc/
validation.rs

1//! JSON-RPC 2.0 request validator.
2//!
3//! Separates transport-level parse/validate failures (which produce
4//! `-32700`/`-32600` responses with `id: null`) from method-level
5//! dispatch errors (`-32601`/`-32602`/daemon-specific codes).
6//!
7//! The validator operates on `serde_json::Value` so the batch router
8//! can parse the top-level array/object split once and pass individual
9//! elements through here. See [`super::router`] for the batch flow.
10
11use serde_json::json;
12use thiserror::Error;
13
14use super::protocol::{JsonRpcRequest, JsonRpcResponse};
15
16/// Transport-level validation failure.
17#[derive(Debug, Error)]
18pub enum ValidationError {
19    /// `-32700 Parse error` — the raw frame bytes did not deserialise
20    /// as JSON. The connection is closed after the response is sent
21    /// because the stream is in an indeterminate state.
22    #[error("parse error: {0}")]
23    ParseError(#[from] serde_json::Error),
24
25    /// `-32600 Invalid Request` — JSON parsed but violated the
26    /// JSON-RPC 2.0 request object shape rules.
27    #[error("invalid request: {reason}")]
28    InvalidRequest {
29        reason: &'static str,
30        context: Option<String>,
31    },
32}
33
34impl ValidationError {
35    /// Whether this error should close the connection immediately
36    /// after the response frame is written. Parse errors leave the
37    /// framing state indeterminate; invalid-request errors do not.
38    #[must_use]
39    pub fn is_terminal(&self) -> bool {
40        matches!(self, Self::ParseError(_))
41    }
42
43    /// Translate to the wire-level JSON-RPC response. Both variants
44    /// carry `id: null` per spec.
45    #[must_use]
46    pub fn into_jsonrpc_response(self) -> JsonRpcResponse {
47        match self {
48            Self::ParseError(e) => JsonRpcResponse::error(
49                None,
50                -32700,
51                "Parse error",
52                Some(json!({ "reason": e.to_string() })),
53            ),
54            Self::InvalidRequest { reason, context } => {
55                let mut data = json!({ "reason": reason });
56                if let Some(ctx) = context {
57                    data["context"] = serde_json::Value::String(ctx);
58                }
59                JsonRpcResponse::error(None, -32600, "Invalid Request", Some(data))
60            }
61        }
62    }
63}
64
65/// Validate a single request `Value` and convert it into a typed
66/// [`JsonRpcRequest`]. The caller is responsible for the outer
67/// batch-vs-single split and for producing the top-level parse-error
68/// response on failing `serde_json::from_slice`.
69///
70/// Rejects at `-32600`:
71/// - root value not an object
72/// - missing or non-string `jsonrpc` field
73/// - `jsonrpc` value not exactly `"2.0"`
74/// - missing or empty `method`
75/// - `id` that is not `null`, a string, an `i64`, or a `u64`
76///   (explicitly rejects fractional, exponent-form, boolean, object,
77///   and array ids)
78/// - `params` that is not an object, array, or null
79pub fn validate_request_value(value: serde_json::Value) -> Result<JsonRpcRequest, ValidationError> {
80    let serde_json::Value::Object(obj) = &value else {
81        return Err(ValidationError::InvalidRequest {
82            reason: "request must be an object",
83            context: None,
84        });
85    };
86
87    match obj.get("jsonrpc").and_then(|v| v.as_str()) {
88        Some("2.0") => {}
89        Some(other) => {
90            return Err(ValidationError::InvalidRequest {
91                reason: "jsonrpc must be exactly \"2.0\"",
92                context: Some(other.to_owned()),
93            });
94        }
95        None => {
96            return Err(ValidationError::InvalidRequest {
97                reason: "missing jsonrpc field",
98                context: None,
99            });
100        }
101    }
102
103    match obj.get("method").and_then(|v| v.as_str()) {
104        Some(m) if !m.is_empty() => {}
105        Some(_) => {
106            return Err(ValidationError::InvalidRequest {
107                reason: "method must be non-empty",
108                context: None,
109            });
110        }
111        None => {
112            return Err(ValidationError::InvalidRequest {
113                reason: "missing method field",
114                context: None,
115            });
116        }
117    }
118
119    if let Some(id) = obj.get("id") {
120        let ok = id.is_null() || id.is_string() || id.is_i64() || id.is_u64();
121        if !ok {
122            return Err(ValidationError::InvalidRequest {
123                reason: "id must be null, string, or an integer number \
124                         (no fractional parts, no exponent form)",
125                context: Some(id.to_string()),
126            });
127        }
128    }
129
130    if let Some(params) = obj.get("params")
131        && !(params.is_object() || params.is_array() || params.is_null())
132    {
133        return Err(ValidationError::InvalidRequest {
134            reason: "params must be object, array, or null",
135            context: None,
136        });
137    }
138
139    let req: JsonRpcRequest =
140        serde_json::from_value(value).map_err(|e| ValidationError::InvalidRequest {
141            reason: "request failed schema decode after validation",
142            context: Some(e.to_string()),
143        })?;
144    Ok(req)
145}
146
147/// Build the top-level parse-error response for a frame whose bytes
148/// could not be decoded as JSON.
149#[must_use]
150pub fn parse_error_response(err: serde_json::Error) -> JsonRpcResponse {
151    // Round-trip through the `ValidationError` constructor so the
152    // response shape (id: null, code: -32700, "Parse error") stays
153    // consistent with the batch path.
154    ValidationError::ParseError(err).into_jsonrpc_response()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn err_code(resp: &JsonRpcResponse) -> i32 {
162        match &resp.payload {
163            super::super::protocol::JsonRpcPayload::Error { error } => error.code,
164            super::super::protocol::JsonRpcPayload::Success { .. } => panic!("not an error"),
165        }
166    }
167
168    #[test]
169    fn valid_request_roundtrips() {
170        let v = serde_json::json!({
171            "jsonrpc": "2.0",
172            "id": 7,
173            "method": "daemon/status",
174            "params": {},
175        });
176        let req = validate_request_value(v).unwrap();
177        assert_eq!(req.method, "daemon/status");
178    }
179
180    #[test]
181    fn missing_jsonrpc_rejected() {
182        let v = serde_json::json!({
183            "id": 1,
184            "method": "x",
185        });
186        let e = validate_request_value(v).unwrap_err();
187        let resp = e.into_jsonrpc_response();
188        assert_eq!(err_code(&resp), -32600);
189    }
190
191    #[test]
192    fn wrong_jsonrpc_version_rejected() {
193        let v = serde_json::json!({
194            "jsonrpc": "1.0",
195            "id": 1,
196            "method": "x",
197        });
198        let resp = validate_request_value(v)
199            .unwrap_err()
200            .into_jsonrpc_response();
201        assert_eq!(err_code(&resp), -32600);
202    }
203
204    #[test]
205    fn missing_method_rejected() {
206        let v = serde_json::json!({"jsonrpc": "2.0", "id": 1});
207        let resp = validate_request_value(v)
208            .unwrap_err()
209            .into_jsonrpc_response();
210        assert_eq!(err_code(&resp), -32600);
211    }
212
213    #[test]
214    fn empty_method_rejected() {
215        let v = serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": ""});
216        let resp = validate_request_value(v)
217            .unwrap_err()
218            .into_jsonrpc_response();
219        assert_eq!(err_code(&resp), -32600);
220    }
221
222    #[test]
223    fn non_object_root_rejected() {
224        let v = serde_json::json!("not an object");
225        let resp = validate_request_value(v)
226            .unwrap_err()
227            .into_jsonrpc_response();
228        assert_eq!(err_code(&resp), -32600);
229    }
230
231    #[test]
232    fn numeric_id_shape_matrix() {
233        // Accepted shapes
234        for v in [
235            serde_json::json!(0_i64),
236            serde_json::json!(1_i64),
237            serde_json::json!(-1_i64),
238            serde_json::json!(i64::MAX),
239            serde_json::json!(u64::MAX),
240            serde_json::json!("abc"),
241            serde_json::Value::Null,
242        ] {
243            let req = serde_json::json!({
244                "jsonrpc": "2.0",
245                "id": v,
246                "method": "x",
247            });
248            validate_request_value(req).expect("valid id shape must pass");
249        }
250        // Rejected shapes
251        let fractional = serde_json::Number::from_f64(1.5).unwrap();
252        let rejected: &[serde_json::Value] = &[
253            serde_json::Value::Number(fractional.clone()),
254            serde_json::from_str(r#"1e3"#).unwrap(),
255            serde_json::from_str(r#"42.0E0"#).unwrap(),
256            serde_json::json!(true),
257            serde_json::json!({}),
258            serde_json::json!([]),
259        ];
260        for v in rejected {
261            let req = serde_json::json!({
262                "jsonrpc": "2.0",
263                "id": v,
264                "method": "x",
265            });
266            let resp = validate_request_value(req)
267                .unwrap_err()
268                .into_jsonrpc_response();
269            assert_eq!(err_code(&resp), -32600, "id shape {v:?} should be -32600");
270        }
271    }
272
273    #[test]
274    fn params_must_be_object_array_or_null() {
275        let req = serde_json::json!({
276            "jsonrpc": "2.0",
277            "id": 1,
278            "method": "x",
279            "params": "not-an-object",
280        });
281        let resp = validate_request_value(req)
282            .unwrap_err()
283            .into_jsonrpc_response();
284        assert_eq!(err_code(&resp), -32600);
285    }
286
287    #[test]
288    fn parse_error_response_has_id_null_and_32700() {
289        let bad = b"{not valid";
290        let err = serde_json::from_slice::<serde_json::Value>(bad).unwrap_err();
291        let resp = parse_error_response(err);
292        assert_eq!(err_code(&resp), -32700);
293        assert!(resp.id.is_none());
294    }
295}