Skip to main content

forest/rpc/
json_validator.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! JSON validation utilities for RPC requests and responses processing.
5//!
6//! - **Duplicate key detection**: `serde_json` automatically deduplicates keys at parse time
7//!   using a "last-write-wins" strategy. This means JSON like `{"/":"cid1", "/":"cid2"}` will
8//!   keep only the last value, which can lead to unexpected behavior in RPC calls.
9//! - **Unknown field detection**: `serde_json` silently ignores unknown fields by default.
10//!   In strict mode, [`from_value_rejecting_unknown_fields`] applies to RPC request and
11//!   responses.
12//!
13//! All of this is gated behind the `FOREST_STRICT_JSON` environment variable.
14
15use ahash::HashSet;
16use serde::de::DeserializeOwned;
17
18pub const STRICT_JSON_ENV: &str = "FOREST_STRICT_JSON";
19
20crate::def_is_env_truthy!(is_strict_mode, STRICT_JSON_ENV);
21
22/// validates JSON for duplicate keys by parsing at the token level.
23pub fn validate_json_for_duplicates(json_str: &str) -> Result<(), String> {
24    if !is_strict_mode() {
25        return Ok(());
26    }
27
28    fn check_value(value: &sonic_rs::Value) -> Result<(), String> {
29        match value.as_ref() {
30            sonic_rs::ValueRef::Object(obj) => {
31                let mut seen = HashSet::default();
32                for (key, value) in obj.iter() {
33                    if !seen.insert(key) {
34                        return Err(format!(
35                            "duplicate key '{key}' in JSON object - this likely indicates malformed input. \
36                            Set {STRICT_JSON_ENV}=0 to disable this check"
37                        ));
38                    }
39                    check_value(value)?;
40                }
41                Ok(())
42            }
43            sonic_rs::ValueRef::Array(arr) => {
44                for item in arr.iter() {
45                    check_value(item)?;
46                }
47                Ok(())
48            }
49            _ => Ok(()),
50        }
51    }
52    // defer to serde_json for invalid JSON
53    let value: sonic_rs::Value = match sonic_rs::from_str(json_str) {
54        Ok(v) => v,
55        Err(_) => return Ok(()),
56    };
57    check_value(&value)
58}
59
60/// De-serializes a [`serde_json::Value`] into `T`, rejecting unknown fields when strict mode is
61/// enabled. When strict mode is off, this is equivalent to [`serde_json::from_value`].
62pub fn from_value_rejecting_unknown_fields<T: DeserializeOwned>(
63    value: serde_json::Value,
64) -> Result<T, serde_json::Error> {
65    if !is_strict_mode() {
66        return serde_json::from_value(value);
67    }
68    let mut unknown = Vec::new();
69    let result: T = serde_ignored::deserialize(value, |path| {
70        unknown.push(path.to_string());
71    })?;
72    if !unknown.is_empty() {
73        return Err(serde::de::Error::custom(format!(
74            "unknown field(s): {}. Set {STRICT_JSON_ENV}=0 to disable this check",
75            unknown.join(", ")
76        )));
77    }
78    Ok(result)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use serde::Deserialize;
85    use serde_json::json;
86    use serial_test::serial;
87
88    fn with_strict_mode<F>(enabled: bool, f: F)
89    where
90        F: FnOnce(),
91    {
92        let original = std::env::var(STRICT_JSON_ENV).ok();
93
94        if enabled {
95            unsafe {
96                std::env::set_var(STRICT_JSON_ENV, "1");
97            }
98        } else {
99            unsafe {
100                std::env::remove_var(STRICT_JSON_ENV);
101            }
102        }
103
104        f();
105
106        unsafe {
107            match original {
108                Some(val) => std::env::set_var(STRICT_JSON_ENV, val),
109                None => std::env::remove_var(STRICT_JSON_ENV),
110            }
111        }
112    }
113
114    #[test]
115    #[serial]
116    fn test_no_duplicates() {
117        with_strict_mode(true, || {
118            let json = r#"{"a": 1, "b": 2, "c": 3}"#;
119            assert!(validate_json_for_duplicates(json).is_ok());
120        });
121    }
122
123    #[test]
124    #[serial]
125    fn test_duplicate_keys_detected() {
126        with_strict_mode(true, || {
127            let json = r#"{"/":"cid1", "/":"cid2"}"#;
128            let result = validate_json_for_duplicates(json);
129            assert!(result.is_err(), "Should have detected duplicate key");
130            assert!(result.unwrap_err().contains("duplicate key"));
131        });
132    }
133
134    #[test]
135    #[serial]
136    fn test_strict_mode_disabled() {
137        with_strict_mode(false, || {
138            // should pass with strict mode disabled
139            let json = r#"{"/":"cid1", "/":"cid2"}"#;
140            assert!(validate_json_for_duplicates(json).is_ok());
141        });
142    }
143
144    #[test]
145    #[serial]
146    fn test_duplicate_cid_keys() {
147        with_strict_mode(true, || {
148            let json = r#"{
149                "jsonrpc": "2.0",
150                "id": 1,
151                "method": "Filecoin.ChainGetMessagesInTipset",
152                "params": [[{
153                    "/":"bafy2bzacea43254b5x6c4l22ynpjfoct5qvabbbk2abcfspfcjkiltivrlyqi",
154                    "/":"bafy2bzacea4viqyaozpfk57lnemwufryb76llxzmebxc7it2rnssqz2ljdl6a",
155                    "/":"bafy2bzaceav6j67epppz5ib55v5ty26dhkq4jinbsizq2olb3azbzxvfmc73o"
156                }]]
157            }"#;
158
159            let result = validate_json_for_duplicates(json);
160            assert!(result.is_err());
161            assert!(result.unwrap_err().contains("duplicate key '/'"));
162        });
163    }
164
165    #[derive(Debug, Deserialize, PartialEq)]
166    struct RpcTestReq {
167        name: String,
168        value: i32,
169    }
170
171    #[test]
172    #[serial]
173    fn test_unknown_fields_known_only() {
174        with_strict_mode(true, || {
175            let val = json!({"name": "alice", "value": 42});
176            let result = from_value_rejecting_unknown_fields::<RpcTestReq>(val);
177            assert_eq!(
178                result.unwrap(),
179                RpcTestReq {
180                    name: "alice".into(),
181                    value: 42
182                }
183            );
184        });
185    }
186
187    #[test]
188    #[serial]
189    fn test_unknown_fields_detected() {
190        with_strict_mode(true, || {
191            let val = json!({"name": "alice", "value": 42, "extra": true});
192            let err = from_value_rejecting_unknown_fields::<RpcTestReq>(val)
193                .expect_err("expected Err when unknown JSON field is present under strict mode");
194            let msg = err.to_string();
195            assert!(
196                msg.contains("unknown field(s)") && msg.contains("extra"),
197                "got: {msg}"
198            );
199        });
200    }
201
202    #[test]
203    #[serial]
204    fn test_unknown_fields_strict_mode_off() {
205        with_strict_mode(false, || {
206            let val = json!({"name": "alice", "value": 42, "extra": true});
207            let result = from_value_rejecting_unknown_fields::<RpcTestReq>(val);
208            assert!(
209                result.is_ok(),
210                "unknown fields should be allowed when strict mode is off"
211            );
212        });
213    }
214}