1use serde_json::Value;
4
5use crate::error::VerificationError;
6use crate::payment::{ExtensionEntry, Extensions};
7
8const BUILDER_CODE: &str = "builder-code";
10const SERVER_OWNED_A: &str = "a";
11const ADDITIVE_S: &str = "s";
12const ADDITIVE_S_MAX: usize = 10;
13
14pub fn validate_extension_echoes(
25 advertised: Option<&Extensions>,
26 payload: &Extensions,
27 dynamic_info_fields: impl Fn(&str) -> &'static [&'static str],
28) -> Result<(), VerificationError> {
29 if payload.is_empty() {
30 return Ok(());
31 }
32 for (key, echoed_entry) in payload.iter() {
33 let advertised_entry = advertised.and_then(|ext| ext.get(key.as_str()));
34 let advertised_info = advertised_entry.map(extension_info);
35 let echoed_info = extension_info(echoed_entry);
36
37 if advertised_entry.is_some() {
38 let dynamic = dynamic_info_fields(key.as_str());
39 let advertised_cmp = omit_fields(advertised_info, dynamic);
40 let echoed_cmp = omit_fields(Some(echoed_info), dynamic);
41 if !info_matches_advertised(key.as_str(), advertised_cmp.as_ref(), echoed_cmp.as_ref())
42 {
43 return Err(echo_mismatch(key.as_str()));
44 }
45 }
46
47 if !server_owned_fields_match(key.as_str(), advertised_info, echoed_info) {
48 return Err(echo_mismatch(key.as_str()));
49 }
50 }
51 Ok(())
52}
53
54fn echo_mismatch(key: &str) -> VerificationError {
55 VerificationError::ExtensionEchoMismatch {
56 extension_key: key.to_owned(),
57 }
58}
59
60fn extension_info(entry: &ExtensionEntry) -> &Value {
61 match entry {
62 ExtensionEntry::Structured { info, .. } => info,
63 ExtensionEntry::Raw(value) => value.get("info").unwrap_or(value),
64 }
65}
66
67fn omit_fields(info: Option<&Value>, fields: &[&str]) -> Option<Value> {
68 let info = info?;
69 if fields.is_empty() {
70 return Some(info.clone());
71 }
72 let Value::Object(map) = info else {
73 return Some(info.clone());
74 };
75 let mut copy = map.clone();
76 for field in fields {
77 let _ = copy.remove(*field);
78 }
79 Some(Value::Object(copy))
80}
81
82fn server_owned_fields_match(key: &str, advertised: Option<&Value>, echoed: &Value) -> bool {
83 if key != BUILDER_CODE {
84 return true;
85 }
86 let Value::Object(echoed_map) = echoed else {
87 return true;
88 };
89 if !echoed_map.contains_key(SERVER_OWNED_A) {
90 return true;
91 }
92 let Some(echoed_a) = echoed_map.get(SERVER_OWNED_A) else {
93 return true;
94 };
95 if echoed_a.is_null() {
96 return true;
97 }
98 let advertised_a = advertised.and_then(|v| v.get(SERVER_OWNED_A));
99 advertised_a == Some(echoed_a)
100}
101
102fn info_matches_advertised(key: &str, advertised: Option<&Value>, echoed: Option<&Value>) -> bool {
103 object_contains_subset(advertised, echoed, key, None)
104}
105
106fn object_contains_subset(
107 expected: Option<&Value>,
108 actual: Option<&Value>,
109 extension_key: &str,
110 field_key: Option<&str>,
111) -> bool {
112 if let Some(field) = field_key
113 && extension_key == BUILDER_CODE
114 && field == ADDITIVE_S
115 && expected.is_some_and(is_array_or_scalar)
116 && actual.is_some_and(is_array_or_scalar)
117 {
118 return additive_array_contains(expected, actual);
119 }
120
121 let Some(expected) = expected else {
122 return true;
123 };
124 if !expected.is_object() {
125 return actual == Some(expected);
126 }
127 let Some(actual) = actual else {
128 return false;
129 };
130 let Some(expected_map) = expected.as_object() else {
131 return actual == expected;
132 };
133 let Some(actual_map) = actual.as_object() else {
134 return false;
135 };
136 expected_map.iter().all(|(k, value)| {
137 if !actual_map.contains_key(k) {
138 return value.is_null();
139 }
140 object_contains_subset(
141 Some(value),
142 actual_map.get(k),
143 extension_key,
144 Some(k.as_str()),
145 )
146 })
147}
148
149fn is_array_or_scalar(value: &Value) -> bool {
150 comparable_array(value).is_some()
151}
152
153fn comparable_array(value: &Value) -> Option<Vec<&Value>> {
154 match value {
155 Value::Array(items) => Some(items.iter().collect()),
156 Value::Null | Value::Object(_) => None,
157 other => Some(vec![other]),
158 }
159}
160
161fn additive_array_contains(expected: Option<&Value>, actual: Option<&Value>) -> bool {
162 let Some(expected) = expected.and_then(comparable_array) else {
163 return false;
164 };
165 let Some(actual) = actual.and_then(comparable_array) else {
166 return false;
167 };
168 if actual.len() > ADDITIVE_S_MAX {
169 return false;
170 }
171 expected
172 .iter()
173 .all(|exp| actual.iter().any(|act| act == exp))
174}
175
176#[cfg(test)]
177mod tests {
178 use serde_json::json;
179
180 use super::*;
181 use crate::payment::ExtensionEntry;
182
183 fn none_dyn(_: &str) -> &'static [&'static str] {
184 &[]
185 }
186
187 fn advertised(info: Value) -> Extensions {
188 let mut ext = Extensions::new();
189 ext.insert("builder-code", ExtensionEntry::info(info));
190 ext
191 }
192
193 fn payload(info: Value) -> Extensions {
194 advertised(info)
195 }
196
197 #[test]
198 fn empty_client_extensions_pass() {
199 assert!(validate_extension_echoes(None, &Extensions::new(), none_dyn).is_ok());
200 }
201
202 #[test]
203 fn forged_a_without_server_declaration_fails() {
204 let err = validate_extension_echoes(None, &payload(json!({"a": "forged_app"})), none_dyn)
205 .unwrap_err();
206 assert!(matches!(
207 err,
208 VerificationError::ExtensionEchoMismatch { .. }
209 ));
210 }
211
212 #[test]
213 fn client_s_without_declaration_passes() {
214 assert!(
215 validate_extension_echoes(None, &payload(json!({"s": ["bc_client"]})), none_dyn)
216 .is_ok()
217 );
218 }
219
220 #[test]
221 fn mismatched_a_fails() {
222 let err = validate_extension_echoes(
223 Some(&advertised(json!({"a": "bc_app"}))),
224 &payload(json!({"a": "forged_app"})),
225 none_dyn,
226 )
227 .unwrap_err();
228 assert!(matches!(
229 err,
230 VerificationError::ExtensionEchoMismatch { extension_key } if extension_key == "builder-code"
231 ));
232 }
233
234 #[test]
235 fn matching_a_and_superset_s_passes() {
236 assert!(
237 validate_extension_echoes(
238 Some(&advertised(json!({"a": "bc_app", "s": ["bc_server"]}))),
239 &payload(json!({"a": "bc_app", "s": ["bc_server", "bc_client"]})),
240 none_dyn,
241 )
242 .is_ok()
243 );
244 }
245
246 #[test]
247 fn dropped_s_entry_fails() {
248 assert!(
249 validate_extension_echoes(
250 Some(&advertised(json!({"s": ["bc_server"]}))),
251 &payload(json!({"s": ["bc_client"]})),
252 none_dyn,
253 )
254 .is_err()
255 );
256 }
257
258 #[test]
259 fn padded_s_over_budget_fails() {
260 let mut padded = vec!["bc_server".to_owned()];
261 padded.extend((0..10).map(|i| format!("bc_fake_{i}")));
262 assert!(
263 validate_extension_echoes(
264 Some(&advertised(json!({"s": ["bc_server"]}))),
265 &payload(json!({"s": padded})),
266 none_dyn,
267 )
268 .is_err()
269 );
270 }
271
272 #[test]
273 fn s_at_budget_passes() {
274 let mut at_budget = vec!["bc_server".to_owned()];
275 at_budget.extend((0..9).map(|i| format!("bc_client_{i}")));
276 assert!(
277 validate_extension_echoes(
278 Some(&advertised(json!({"s": ["bc_server"]}))),
279 &payload(json!({"s": at_budget})),
280 none_dyn,
281 )
282 .is_ok()
283 );
284 }
285
286 #[test]
287 fn scalar_s_merges_against_array() {
288 assert!(
289 validate_extension_echoes(
290 Some(&advertised(json!({"s": "bc_server"}))),
291 &payload(json!({"s": ["bc_server", "bc_client"]})),
292 none_dyn,
293 )
294 .is_ok()
295 );
296 }
297
298 #[test]
299 fn dynamic_offers_field_is_omitted() {
300 fn offers_dyn(key: &str) -> &'static [&'static str] {
301 if key == "offer-receipt" {
302 &["offers"]
303 } else {
304 &[]
305 }
306 }
307 let mut advertised = Extensions::new();
308 advertised.insert(
309 "offer-receipt",
310 ExtensionEntry::info(json!({"offers": [1], "kid": "x"})),
311 );
312 let mut payload = Extensions::new();
313 payload.insert(
314 "offer-receipt",
315 ExtensionEntry::info(json!({"offers": [2], "kid": "x"})),
316 );
317 assert!(validate_extension_echoes(Some(&advertised), &payload, offers_dyn).is_ok());
318 }
319}