1use std::collections::HashMap;
4
5use compact_str::CompactString;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9pub const EXTENSION_RESPONSE_LOG_FIELDS: &[&str] = &["status", "rejectedReason", "reason", "code"];
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct Extensions(HashMap<CompactString, ExtensionEntry>);
28
29impl Extensions {
30 #[must_use]
32 pub fn new() -> Self {
33 Self(HashMap::new())
34 }
35
36 #[must_use]
38 pub fn is_empty(&self) -> bool {
39 self.0.is_empty()
40 }
41
42 #[must_use]
44 pub fn len(&self) -> usize {
45 self.0.len()
46 }
47
48 #[must_use]
50 pub fn get(&self, id: &str) -> Option<&ExtensionEntry> {
51 self.0.get(id)
52 }
53
54 pub fn insert(&mut self, id: impl Into<CompactString>, entry: ExtensionEntry) {
56 let _ = self.0.insert(id.into(), entry);
57 }
58
59 #[must_use]
61 pub fn remove(&mut self, id: &str) -> Option<ExtensionEntry> {
62 self.0.remove(id)
63 }
64
65 pub fn iter(&self) -> impl Iterator<Item = (&CompactString, &ExtensionEntry)> {
67 self.0.iter()
68 }
69
70 pub fn extend(&mut self, other: Self) {
72 self.0.extend(other.0);
73 }
74}
75
76impl<K, V> FromIterator<(K, V)> for Extensions
77where
78 K: Into<CompactString>,
79 V: Into<ExtensionEntry>,
80{
81 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
82 Self(
83 iter.into_iter()
84 .map(|(k, v)| (k.into(), v.into()))
85 .collect(),
86 )
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(untagged)]
97pub enum ExtensionEntry {
98 Structured {
100 info: Value,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 schema: Option<Value>,
105 #[serde(default, flatten)]
107 extra: serde_json::Map<String, Value>,
108 },
109 Raw(Value),
111}
112
113impl ExtensionEntry {
114 #[must_use]
116 pub fn info(info: Value) -> Self {
117 Self::Structured {
118 info,
119 schema: None,
120 extra: serde_json::Map::new(),
121 }
122 }
123
124 #[must_use]
126 pub fn with_schema(info: Value, schema: Value) -> Self {
127 Self::Structured {
128 info,
129 schema: Some(schema),
130 extra: serde_json::Map::new(),
131 }
132 }
133
134 #[must_use]
136 pub const fn raw(value: Value) -> Self {
137 Self::Raw(value)
138 }
139
140 #[must_use]
142 pub const fn as_info(&self) -> Option<&Value> {
143 match self {
144 Self::Structured { info, .. } => Some(info),
145 Self::Raw(_) => None,
146 }
147 }
148
149 #[must_use]
151 pub const fn as_schema(&self) -> Option<&Value> {
152 match self {
153 Self::Structured { schema, .. } => schema.as_ref(),
154 Self::Raw(_) => None,
155 }
156 }
157
158 #[must_use]
160 pub fn to_value(&self) -> Value {
161 match self {
162 Self::Structured {
163 info,
164 schema,
165 extra,
166 } => {
167 let mut obj = extra.clone();
168 let _ = obj.insert("info".to_owned(), info.clone());
169 if let Some(schema) = schema {
170 let _ = obj.insert("schema".to_owned(), schema.clone());
171 }
172 Value::Object(obj)
173 }
174 Self::Raw(value) => value.clone(),
175 }
176 }
177}
178
179impl From<Value> for ExtensionEntry {
180 fn from(value: Value) -> Self {
181 Self::Raw(value)
182 }
183}
184
185#[must_use]
187pub fn schema_has_external_ref(value: &Value) -> bool {
188 match value {
189 Value::Array(items) => items.iter().any(schema_has_external_ref),
190 Value::Object(map) => {
191 for (key, child) in map {
192 if (key == "$ref" || key == "$id")
193 && !child.as_str().is_some_and(|s| s.starts_with('#'))
194 {
195 return true;
196 }
197 if schema_has_external_ref(child) {
198 return true;
199 }
200 }
201 false
202 }
203 _ => false,
204 }
205}
206
207#[cfg(test)]
208#[allow(
209 clippy::unwrap_used,
210 clippy::indexing_slicing,
211 reason = "unit tests panic on assertion failure"
212)]
213mod tests {
214 use serde_json::json;
215
216 use super::*;
217
218 #[test]
219 fn extensions_empty_by_default() {
220 let ext = Extensions::new();
221 assert!(ext.is_empty());
222 assert_eq!(serde_json::to_value(&ext).unwrap(), json!({}));
223 }
224
225 #[test]
226 fn structured_entry_roundtrip() {
227 let mut ext = Extensions::new();
228 ext.insert(
229 "bazaar",
230 ExtensionEntry::with_schema(json!({"registered": true}), json!({"type": "object"})),
231 );
232 let encoded = serde_json::to_value(&ext).unwrap();
233 assert_eq!(encoded["bazaar"]["info"]["registered"], true);
234 assert_eq!(encoded["bazaar"]["schema"]["type"], "object");
235 let decoded: Extensions = serde_json::from_value(encoded).unwrap();
236 assert_eq!(decoded, ext);
237 }
238
239 #[test]
240 fn raw_entry_roundtrip() {
241 let mut ext = Extensions::new();
242 ext.insert("custom", ExtensionEntry::raw(json!([1, 2, 3])));
243 let encoded = serde_json::to_value(&ext).unwrap();
244 assert_eq!(encoded["custom"], json!([1, 2, 3]));
245 let decoded: Extensions = serde_json::from_value(encoded).unwrap();
246 assert_eq!(decoded, ext);
247 }
248
249 #[test]
250 fn log_field_allowlist() {
251 assert_eq!(
252 EXTENSION_RESPONSE_LOG_FIELDS,
253 ["status", "rejectedReason", "reason", "code"]
254 );
255 }
256
257 #[test]
258 fn structured_preserves_sibling_fields() {
259 let encoded = json!({
260 "sign-in-with-x": {
261 "info": {"domain": "api.example.com"},
262 "supportedChains": [{"chainId": "eip155:8453", "type": "eip191"}]
263 }
264 });
265 let decoded: Extensions = serde_json::from_value(encoded).unwrap();
266 let value = decoded.get("sign-in-with-x").unwrap().to_value();
267 assert_eq!(value["info"]["domain"], "api.example.com");
268 assert_eq!(value["supportedChains"][0]["chainId"], "eip155:8453");
269 assert_eq!(value["supportedChains"][0]["type"], "eip191");
270 }
271
272 #[test]
273 fn schema_has_external_ref_http_and_file() {
274 assert!(schema_has_external_ref(
275 &json!({"$ref": "http://127.0.0.1/attacker-schema.json"})
276 ));
277 assert!(schema_has_external_ref(
278 &json!({"$ref": "file:///etc/passwd"})
279 ));
280 assert!(schema_has_external_ref(
281 &json!({"$id": "https://evil.example/x.json"})
282 ));
283 assert!(schema_has_external_ref(
284 &json!({"$ref": "../../etc/passwd"})
285 ));
286 }
287
288 #[test]
289 fn schema_has_external_ref_nested_and_non_string() {
290 assert!(schema_has_external_ref(&json!({
291 "properties": { "input": { "$ref": "http://evil.example/schema.json" } }
292 })));
293 assert!(schema_has_external_ref(
294 &json!({"allOf": [{"type": "object"}, {"$ref": "http://evil.example/x.json"}]})
295 ));
296 assert!(schema_has_external_ref(&json!({"$ref": 1})));
297 assert!(schema_has_external_ref(&json!({"$id": true})));
298 }
299
300 #[test]
301 fn schema_has_external_ref_allows_fragments_and_schema_url() {
302 assert!(!schema_has_external_ref(&json!({
303 "$schema": "https://json-schema.org/draft/2020-12/schema"
304 })));
305 assert!(!schema_has_external_ref(
306 &json!({"$ref": "#/definitions/root"})
307 ));
308 assert!(!schema_has_external_ref(&json!({"$id": "#"})));
309 assert!(!schema_has_external_ref(&json!({})));
310 assert!(!schema_has_external_ref(&json!("https://example.com")));
311 assert!(!schema_has_external_ref(&Value::Null));
312 }
313}