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    // CORE-HTTP-004 appends the command path to this value with one `/` separator, so a base
137    // carrying a query, a fragment, or a dot segment would steer every command URL away from the
138    // advertised base. The document is refused rather than left to fail at path construction.
139    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}