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
137 && (!endpoint_base.starts_with('/') || endpoint_base.starts_with("//"))
138 {
139 issues.push(issue(
140 "$.http.endpoint_base",
141 "Expected an origin-relative absolute path.",
142 ));
143 }
144 if let Some(openapi) = &document.http.openapi
145 && (openapi.url.is_empty()
146 || openapi.url.chars().any(char::is_whitespace)
147 || Url::parse(&openapi.url).is_err() && !is_relative_reference(&openapi.url))
148 {
149 issues.push(issue("$.http.openapi.url", "Expected a URI reference."));
150 }
151 for (index, method) in document.identity.methods.iter().enumerate() {
152 if !identity_pattern().is_match(method.as_str()) {
153 issues.push(issue(
154 format!("$.identity.methods[{index}]"),
155 "Expected an identity-method identifier.",
156 ));
157 }
158 }
159 let authenticated = document.commands.supported.iter().any(|command| {
160 matches!(
161 command,
162 Command::Enroll | Command::Grant | Command::Revoke | Command::Status
163 )
164 });
165 if authenticated && document.identity.methods.is_empty() {
166 issues.push(issue(
167 "$.identity.methods",
168 "Expected at least one identity method for authenticated commands.",
169 ));
170 }
171 if !document.service.did.starts_with("did:") {
172 issues.push(issue("$.service.did", "Expected a DID."));
173 }
174 result("Inspect document", issues)
175}
176
177pub fn is_version_compatible(received: &str, supported: &str) -> bool {
178 if !version_pattern().is_match(received) || !version_pattern().is_match(supported) {
179 return false;
180 }
181 received.split_once('.').map(|parts| parts.0) == supported.split_once('.').map(|parts| parts.0)
182}
183
184fn validate_authentication(
185 authentication: Option<&Authentication>,
186 issues: &mut Vec<crate::ValidationIssue>,
187) {
188 let Some(authentication) = authentication else {
189 return;
190 };
191 if authentication.methods.is_empty() {
192 issues.push(issue(
193 "$.authentication.methods",
194 "Expected at least one item.",
195 ));
196 }
197 if authentication.methods.len() > MAX_AUTHENTICATION_METHODS {
198 issues.push(issue(
199 "$.authentication.methods",
200 "Expected at most 16 items.",
201 ));
202 }
203 validate_advertisements(
204 authentication.methods.iter().map(|method| method.as_str()),
205 "$.authentication.methods",
206 false,
207 issues,
208 );
209 require_unique(&authentication.methods, "$.authentication.methods", issues);
210}
211
212fn validate_claims(claims: Option<&InspectClaims>, issues: &mut Vec<crate::ValidationIssue>) {
213 let Some(claims) = claims else {
214 return;
215 };
216 for (group, values) in [
217 ("required", &claims.required),
218 ("preferred", &claims.preferred),
219 ("optional", &claims.optional),
220 ] {
221 for (index, value) in values.iter().enumerate() {
222 if !claim_name_pattern().is_match(value.as_str()) {
223 issues.push(issue(
224 format!("$.claims.{group}[{index}]"),
225 "Expected a registered claim-name shape.",
226 ));
227 }
228 }
229 }
230}
231
232fn validate_advertisements<'a>(
233 values: impl Iterator<Item = &'a str>,
234 path: &str,
235 require_item: bool,
236 issues: &mut Vec<crate::ValidationIssue>,
237) {
238 let values = values.collect::<Vec<_>>();
239 if require_item && values.is_empty() {
240 issues.push(issue(path, "Expected at least one item."));
241 }
242 for (index, value) in values.into_iter().enumerate() {
243 if !advertisement_pattern().is_match(value) {
244 issues.push(issue(
245 format!("{path}[{index}]"),
246 "Expected a lowercase advertisement identifier.",
247 ));
248 }
249 }
250}
251
252fn is_relative_reference(value: &str) -> bool {
253 !value.is_empty() && !value.starts_with("//") && !value.contains('#')
254}
255
256fn version_pattern() -> &'static Regex {
257 static PATTERN: OnceLock<Regex> = OnceLock::new();
258 PATTERN.get_or_init(|| {
259 Regex::new(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$").expect("valid version pattern")
260 })
261}
262
263fn advertisement_pattern() -> &'static Regex {
264 static PATTERN: OnceLock<Regex> = OnceLock::new();
265 PATTERN.get_or_init(|| {
266 Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").expect("valid advertisement pattern")
267 })
268}
269
270fn identity_pattern() -> &'static Regex {
271 static PATTERN: OnceLock<Regex> = OnceLock::new();
272 PATTERN.get_or_init(|| {
273 Regex::new(r"^[a-z0-9]+(?::[a-z0-9]+)*(?:-[a-z0-9]+)*$")
274 .expect("valid identity method pattern")
275 })
276}
277
278fn claim_name_pattern() -> &'static Regex {
279 static PATTERN: OnceLock<Regex> = OnceLock::new();
280 PATTERN.get_or_init(|| {
281 Regex::new(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$").expect("valid claim name pattern")
282 })
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn valid_document() -> InspectDocument {
290 parse_inspect_document(
291 br#"{
292 "aep_version":"1.0",
293 "bindings":{"supported":["http"]},
294 "commands":{"supported":["inspect","enroll"]},
295 "core":{"signing_algorithms":["EdDSA","ES256"]},
296 "http":{},
297 "identity":{"methods":["did:web"]},
298 "service":{"did":"did:web:service.example"}
299 }"#,
300 )
301 .expect("valid Inspect document")
302 }
303
304 #[test]
305 fn accepts_unknown_well_formed_advertisements() {
306 let document = parse_inspect_document(
307 br#"{
308 "aep_version":"1.1",
309 "bindings":{"supported":["http","future-binding"]},
310 "commands":{"supported":["inspect","future-command"]},
311 "core":{"signing_algorithms":["EdDSA","ES256","future"]},
312 "http":{},
313 "identity":{"methods":[]},
314 "service":{"did":"did:web:service.example"}
315 }"#,
316 )
317 .expect("same-major additive document");
318 assert_eq!(document.commands.supported[1].as_str(), "future-command");
319 }
320
321 #[test]
322 fn rejects_an_incompatible_major_version() {
323 let error = parse_inspect_document(
324 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"}}"#,
325 )
326 .expect_err("major version must fail");
327 assert!(matches!(error, ParseError::Validation(_)));
328 }
329
330 #[test]
331 fn rejects_invalid_required_advertisements() {
332 let mut document = valid_document();
333 document.bindings.supported.clear();
334 assert!(validate_inspect_document(&document).is_err());
335
336 let mut document = valid_document();
337 document.commands.supported = vec![Command::Enroll];
338 document.commands.grant_types.clear();
339 assert!(validate_inspect_document(&document).is_err());
340
341 let mut document = valid_document();
342 document.core.signing_algorithms = vec![SigningAlgorithm::EdDsa];
343 assert!(validate_inspect_document(&document).is_err());
344
345 let mut document = valid_document();
346 document.identity.methods.clear();
347 assert!(validate_inspect_document(&document).is_err());
348
349 let mut document = valid_document();
350 document
351 .commands
352 .supported
353 .push(Command::Other("authenticate".to_owned()));
354 assert!(validate_inspect_document(&document).is_err());
355 }
356
357 #[test]
358 fn rejects_invalid_optional_advertisements() {
359 let mut document = valid_document();
360 document.authentication = Some(Authentication { methods: vec![] });
361 assert!(validate_inspect_document(&document).is_err());
362
363 let mut document = valid_document();
364 document.http.endpoint_base = Some("//wrong".to_owned());
365 assert!(validate_inspect_document(&document).is_err());
366
367 let mut document = valid_document();
368 document.extensions = Some(crate::Extensions {
369 supported: vec!["relative".to_owned()],
370 additional: Default::default(),
371 });
372 assert!(validate_inspect_document(&document).is_err());
373
374 let mut document = valid_document();
375 document.service.did = "not-a-did".to_owned();
376 assert!(validate_inspect_document(&document).is_err());
377 }
378
379 #[test]
380 fn compares_only_compatible_major_versions() {
381 assert!(is_version_compatible("1.9", "1.0"));
382 assert!(!is_version_compatible("2.0", "1.0"));
383 assert!(!is_version_compatible("invalid", "1.0"));
384 }
385}