1use std::sync::OnceLock;
2
3use regex::Regex;
4use url::Url;
5
6use crate::{
7 Authentication, Binding, Command, GrantType, InspectClaims, InspectDocument,
8 MAX_AUTHENTICATION_METHODS, ParseError, SigningAlgorithm, VERSION, ValidationError,
9 validation::{issue, parse_and_validate, require_unique, result},
10};
11
12pub fn parse_inspect_document(data: &[u8]) -> Result<InspectDocument, ParseError> {
13 parse_and_validate(data, "Inspect document", validate_inspect_document)
14}
15
16pub fn validate_inspect_document(document: &InspectDocument) -> Result<(), ValidationError> {
17 let mut issues = Vec::new();
18 if !version_pattern().is_match(&document.aep_version) {
19 issues.push(issue(
20 "$.aep_version",
21 "Expected major.minor version syntax.",
22 ));
23 } else if !is_version_compatible(&document.aep_version, VERSION) {
24 issues.push(issue(
25 "$.aep_version",
26 format!("Unsupported AEP major version: {}.", document.aep_version),
27 ));
28 }
29 validate_authentication(document.authentication.as_ref(), &mut issues);
30 validate_advertisements(
31 document.bindings.supported.iter().map(Binding::as_str),
32 "$.bindings.supported",
33 true,
34 &mut issues,
35 );
36 if !document.bindings.supported.contains(&Binding::Http) {
37 issues.push(issue(
38 "$.bindings.supported",
39 "Expected http to be advertised.",
40 ));
41 }
42 validate_claims(document.claims.as_ref(), &mut issues);
43 validate_advertisements(
44 document.commands.supported.iter().map(Command::as_str),
45 "$.commands.supported",
46 true,
47 &mut issues,
48 );
49 if !document.commands.supported.contains(&Command::Inspect) {
50 issues.push(issue(
51 "$.commands.supported",
52 "Expected inspect to be advertised.",
53 ));
54 }
55 if document
56 .commands
57 .supported
58 .iter()
59 .any(|command| command.as_str() == "authenticate")
60 {
61 issues.push(issue(
62 "$.commands.supported",
63 "authenticate is an assertion operation, not a command.",
64 ));
65 }
66 validate_advertisements(
67 document.commands.grant_types.iter().map(GrantType::as_str),
68 "$.commands.grant_types",
69 false,
70 &mut issues,
71 );
72 for name in document.commands.grant_types_config.keys() {
73 let path = format!("$.commands.grant_types_config.{name}");
74 if !advertisement_pattern().is_match(name) {
75 issues.push(issue(&path, "Expected a lowercase grant-type identifier."));
76 }
77 if !document
78 .commands
79 .grant_types
80 .iter()
81 .any(|grant_type| grant_type.as_str() == name)
82 {
83 issues.push(issue(
84 &path,
85 "Expected configuration for an advertised grant type.",
86 ));
87 }
88 }
89 let advertises_grant_or_revoke = document
90 .commands
91 .supported
92 .iter()
93 .any(|command| matches!(command, Command::Grant | Command::Revoke));
94 if advertises_grant_or_revoke && document.commands.grant_types.is_empty() {
95 issues.push(issue(
96 "$.commands.grant_types",
97 "Expected at least one grant type when Grant or Revoke is advertised.",
98 ));
99 }
100 if document.core.signing_algorithms.is_empty() {
101 issues.push(issue(
102 "$.core.signing_algorithms",
103 "Expected at least one signing algorithm.",
104 ));
105 }
106 if !document
107 .core
108 .signing_algorithms
109 .contains(&SigningAlgorithm::EdDsa)
110 {
111 issues.push(issue(
112 "$.core.signing_algorithms",
113 "Expected EdDSA to be advertised.",
114 ));
115 }
116 if !document
117 .core
118 .signing_algorithms
119 .contains(&SigningAlgorithm::Es256)
120 {
121 issues.push(issue(
122 "$.core.signing_algorithms",
123 "Expected ES256 to be advertised.",
124 ));
125 }
126 if let Some(extensions) = &document.extensions {
127 for (index, extension) in extensions.supported.iter().enumerate() {
128 if Url::parse(extension).is_err() {
129 issues.push(issue(
130 format!("$.extensions.supported[{index}]"),
131 "Expected an absolute URI.",
132 ));
133 }
134 }
135 }
136 if let Some(endpoint_base) = &document.http.endpoint_base
140 && crate::normalize_endpoint_base(Some(endpoint_base)).is_err()
141 {
142 issues.push(issue(
143 "$.http.endpoint_base",
144 "Expected an origin-relative absolute path.",
145 ));
146 }
147 if let Some(openapi) = &document.http.openapi
148 && (openapi.url.is_empty()
149 || openapi.url.chars().any(char::is_whitespace)
150 || Url::parse(&openapi.url).is_err() && !is_relative_reference(&openapi.url))
151 {
152 issues.push(issue("$.http.openapi.url", "Expected a URI reference."));
153 }
154 for (index, method) in document.identity.methods.iter().enumerate() {
155 if !identity_pattern().is_match(method.as_str()) {
156 issues.push(issue(
157 format!("$.identity.methods[{index}]"),
158 "Expected an identity-method identifier.",
159 ));
160 }
161 }
162 let authenticated = document.commands.supported.iter().any(|command| {
163 matches!(
164 command,
165 Command::Enroll | Command::Grant | Command::Revoke | Command::Status
166 )
167 });
168 if authenticated && document.identity.methods.is_empty() {
169 issues.push(issue(
170 "$.identity.methods",
171 "Expected at least one identity method for authenticated commands.",
172 ));
173 }
174 if !document.service.did.starts_with("did:") {
175 issues.push(issue("$.service.did", "Expected a DID."));
176 }
177 result("Inspect document", issues)
178}
179
180pub fn is_version_compatible(received: &str, supported: &str) -> bool {
181 if !version_pattern().is_match(received) || !version_pattern().is_match(supported) {
182 return false;
183 }
184 received.split_once('.').map(|parts| parts.0) == supported.split_once('.').map(|parts| parts.0)
185}
186
187fn validate_authentication(
188 authentication: Option<&Authentication>,
189 issues: &mut Vec<crate::ValidationIssue>,
190) {
191 let Some(authentication) = authentication else {
192 return;
193 };
194 if authentication.methods.is_empty() {
195 issues.push(issue(
196 "$.authentication.methods",
197 "Expected at least one item.",
198 ));
199 }
200 if authentication.methods.len() > MAX_AUTHENTICATION_METHODS {
201 issues.push(issue(
202 "$.authentication.methods",
203 "Expected at most 16 items.",
204 ));
205 }
206 validate_advertisements(
207 authentication.methods.iter().map(|method| method.as_str()),
208 "$.authentication.methods",
209 false,
210 issues,
211 );
212 require_unique(&authentication.methods, "$.authentication.methods", issues);
213}
214
215fn validate_claims(claims: Option<&InspectClaims>, issues: &mut Vec<crate::ValidationIssue>) {
216 let Some(claims) = claims else {
217 return;
218 };
219 for (group, values) in [
220 ("required", &claims.required),
221 ("preferred", &claims.preferred),
222 ("optional", &claims.optional),
223 ] {
224 for (index, value) in values.iter().enumerate() {
225 if !claim_name_pattern().is_match(value.as_str()) {
226 issues.push(issue(
227 format!("$.claims.{group}[{index}]"),
228 "Expected a registered claim-name shape.",
229 ));
230 }
231 }
232 }
233}
234
235fn validate_advertisements<'a>(
236 values: impl Iterator<Item = &'a str>,
237 path: &str,
238 require_item: bool,
239 issues: &mut Vec<crate::ValidationIssue>,
240) {
241 let values = values.collect::<Vec<_>>();
242 if require_item && values.is_empty() {
243 issues.push(issue(path, "Expected at least one item."));
244 }
245 for (index, value) in values.into_iter().enumerate() {
246 if !advertisement_pattern().is_match(value) {
247 issues.push(issue(
248 format!("{path}[{index}]"),
249 "Expected a lowercase advertisement identifier.",
250 ));
251 }
252 }
253}
254
255fn is_relative_reference(value: &str) -> bool {
256 !value.is_empty() && !value.starts_with("//") && !value.contains('#')
257}
258
259fn version_pattern() -> &'static Regex {
260 static PATTERN: OnceLock<Regex> = OnceLock::new();
261 PATTERN.get_or_init(|| {
262 Regex::new(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$").expect("valid version pattern")
263 })
264}
265
266fn advertisement_pattern() -> &'static Regex {
267 static PATTERN: OnceLock<Regex> = OnceLock::new();
268 PATTERN.get_or_init(|| {
269 Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").expect("valid advertisement pattern")
270 })
271}
272
273fn identity_pattern() -> &'static Regex {
274 static PATTERN: OnceLock<Regex> = OnceLock::new();
275 PATTERN.get_or_init(|| {
276 Regex::new(r"^[a-z0-9]+(?::[a-z0-9]+)*(?:-[a-z0-9]+)*$")
277 .expect("valid identity method pattern")
278 })
279}
280
281fn claim_name_pattern() -> &'static Regex {
282 static PATTERN: OnceLock<Regex> = OnceLock::new();
283 PATTERN.get_or_init(|| {
284 Regex::new(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$").expect("valid claim name pattern")
285 })
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn valid_document() -> InspectDocument {
293 parse_inspect_document(
294 br#"{
295 "aep_version":"1.0",
296 "bindings":{"supported":["http"]},
297 "commands":{"supported":["inspect","enroll"]},
298 "core":{"signing_algorithms":["EdDSA","ES256"]},
299 "http":{},
300 "identity":{"methods":["did:web"]},
301 "service":{"did":"did:web:service.example"}
302 }"#,
303 )
304 .expect("valid Inspect document")
305 }
306
307 #[test]
308 fn accepts_unknown_well_formed_advertisements() {
309 let document = parse_inspect_document(
310 br#"{
311 "aep_version":"1.1",
312 "bindings":{"supported":["http","future-binding"]},
313 "commands":{"supported":["inspect","future-command"]},
314 "core":{"signing_algorithms":["EdDSA","ES256","future"]},
315 "http":{},
316 "identity":{"methods":[]},
317 "service":{"did":"did:web:service.example"}
318 }"#,
319 )
320 .expect("same-major additive document");
321 assert_eq!(document.commands.supported[1].as_str(), "future-command");
322 }
323
324 #[test]
325 fn rejects_an_incompatible_major_version() {
326 let error = parse_inspect_document(
327 br#"{"aep_version":"2.0","bindings":{"supported":["http"]},"commands":{"supported":["inspect"]},"core":{"signing_algorithms":["EdDSA","ES256"]},"http":{},"identity":{"methods":[]},"service":{"did":"did:web:service.example"}}"#,
328 )
329 .expect_err("major version must fail");
330 assert!(matches!(error, ParseError::Validation(_)));
331 }
332
333 #[test]
334 fn rejects_invalid_required_advertisements() {
335 let mut document = valid_document();
336 document.bindings.supported.clear();
337 assert!(validate_inspect_document(&document).is_err());
338
339 let mut document = valid_document();
340 document.commands.supported = vec![Command::Enroll];
341 document.commands.grant_types.clear();
342 assert!(validate_inspect_document(&document).is_err());
343
344 let mut document = valid_document();
345 document.core.signing_algorithms = vec![SigningAlgorithm::EdDsa];
346 assert!(validate_inspect_document(&document).is_err());
347
348 let mut document = valid_document();
349 document.identity.methods.clear();
350 assert!(validate_inspect_document(&document).is_err());
351
352 let mut document = valid_document();
353 document
354 .commands
355 .supported
356 .push(Command::Other("authenticate".to_owned()));
357 assert!(validate_inspect_document(&document).is_err());
358 }
359
360 #[test]
361 fn rejects_invalid_optional_advertisements() {
362 let mut document = valid_document();
363 document.authentication = Some(Authentication { methods: vec![] });
364 assert!(validate_inspect_document(&document).is_err());
365
366 let mut document = valid_document();
367 document.http.endpoint_base = Some("//wrong".to_owned());
368 assert!(validate_inspect_document(&document).is_err());
369
370 let mut document = valid_document();
371 document.extensions = Some(crate::Extensions {
372 supported: vec!["relative".to_owned()],
373 additional: Default::default(),
374 });
375 assert!(validate_inspect_document(&document).is_err());
376
377 let mut document = valid_document();
378 document.service.did = "not-a-did".to_owned();
379 assert!(validate_inspect_document(&document).is_err());
380 }
381
382 #[test]
383 fn compares_only_compatible_major_versions() {
384 assert!(is_version_compatible("1.9", "1.0"));
385 assert!(!is_version_compatible("2.0", "1.0"));
386 assert!(!is_version_compatible("invalid", "1.0"));
387 }
388}