Skip to main content

aep_core/
inspect.rs

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}