Skip to main content

r402_protocol/payment/
extensions.rs

1//! x402 protocol extension envelope.
2
3use std::collections::HashMap;
4
5use compact_str::CompactString;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// Field names from an `EXTENSION-RESPONSES` value that may be logged.
10pub const EXTENSION_RESPONSE_LOG_FIELDS: &[&str] = &["status", "rejectedReason", "reason", "code"];
11
12/// Canonical x402 extension envelope: extension id → [`ExtensionEntry`].
13///
14/// # Examples
15///
16/// ```
17/// use r402_protocol::payment::{ExtensionEntry, Extensions};
18/// use serde_json::json;
19///
20/// let mut ext = Extensions::new();
21/// ext.insert("bazaar", ExtensionEntry::info(json!({"registered": true})));
22/// let rendered = serde_json::to_value(&ext).unwrap();
23/// assert_eq!(rendered["bazaar"]["info"]["registered"], true);
24/// ```
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct Extensions(HashMap<CompactString, ExtensionEntry>);
28
29impl Extensions {
30    /// Empty extension map.
31    #[must_use]
32    pub fn new() -> Self {
33        Self(HashMap::new())
34    }
35
36    /// Whether no extensions are present.
37    #[must_use]
38    pub fn is_empty(&self) -> bool {
39        self.0.is_empty()
40    }
41
42    /// Number of registered extensions.
43    #[must_use]
44    pub fn len(&self) -> usize {
45        self.0.len()
46    }
47
48    /// Looks up an extension by stable ID.
49    #[must_use]
50    pub fn get(&self, id: &str) -> Option<&ExtensionEntry> {
51        self.0.get(id)
52    }
53
54    /// Inserts or replaces an extension payload for the given ID.
55    pub fn insert(&mut self, id: impl Into<CompactString>, entry: ExtensionEntry) {
56        let _ = self.0.insert(id.into(), entry);
57    }
58
59    /// Removes an extension by stable ID.
60    #[must_use]
61    pub fn remove(&mut self, id: &str) -> Option<ExtensionEntry> {
62        self.0.remove(id)
63    }
64
65    /// Iterates over `(id, entry)` pairs.
66    pub fn iter(&self) -> impl Iterator<Item = (&CompactString, &ExtensionEntry)> {
67        self.0.iter()
68    }
69
70    /// Inserts every entry from `other`, overwriting on id collision.
71    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/// Payload attached to a single extension key.
91///
92/// Structured form is `{info, schema?, …}`. Sibling keys such as SIWX
93/// `supportedChains` are preserved on deserialize so buyers can read them.
94/// Raw form is forwarded verbatim.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(untagged)]
97pub enum ExtensionEntry {
98    /// Canonical `{info, schema?}` envelope plus any sibling fields.
99    Structured {
100        /// Extension-specific payload.
101        info: Value,
102        /// Optional JSON Schema for client-submitted fields.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        schema: Option<Value>,
105        /// Sibling keys (`supportedChains`, …). Empty when absent.
106        #[serde(default, flatten)]
107        extra: serde_json::Map<String, Value>,
108    },
109    /// Raw JSON payload, forwarded as-is.
110    Raw(Value),
111}
112
113impl ExtensionEntry {
114    /// Structured entry with only `info`.
115    #[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    /// Structured entry with `info` and `schema`.
125    #[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    /// Raw entry wrapping the supplied JSON value.
135    #[must_use]
136    pub const fn raw(value: Value) -> Self {
137        Self::Raw(value)
138    }
139
140    /// `info` payload if this entry is structured.
141    #[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    /// Schema if present.
150    #[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    /// Raw JSON value regardless of shape.
159    #[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/// `true` when any `$ref` or `$id` is not a same-document `#` fragment.
186#[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}