Skip to main content

aep_core/
openapi.rs

1use url::Url;
2
3use crate::{CoreError, OpenApiTrailingSlash};
4
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct OpenApiPathMatchOptions {
7    pub method: String,
8    pub path: String,
9    pub trailing_slash: OpenApiTrailingSlash,
10}
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct OpenApiPathMatch {
14    pub method: String,
15    pub template: String,
16}
17
18/// How an Agent must treat the security an operation resolves to.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum OpenApiSecurityDisposition {
21    Public,
22    OptionalAnonymous,
23    Required,
24    Alternatives,
25    AllRequired,
26    FallbackLiveChallenge,
27}
28
29impl OpenApiSecurityDisposition {
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Public => "public",
34            Self::OptionalAnonymous => "optional-anonymous",
35            Self::Required => "required",
36            Self::Alternatives => "alternatives",
37            Self::AllRequired => "all-required",
38            Self::FallbackLiveChallenge => "fallback-live-challenge",
39        }
40    }
41}
42
43/// The resolved security for one operation.
44///
45/// `satisfiable` lists the complete requirement objects the Agent can satisfy, each as the scheme
46/// names it must present together. It is empty unless the disposition selects credentials.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct OpenApiSecurityResolution {
49    pub disposition: OpenApiSecurityDisposition,
50    pub satisfiable: Vec<Vec<String>>,
51}
52
53/// Resolves the effective security for one operation (CORE-OAS-011/013/014/015).
54///
55/// Operation-level security replaces the root value rather than adding to it, an empty list makes
56/// the operation public, an empty requirement object is an anonymous alternative, several
57/// requirement objects are alternatives, and several schemes inside one object are compound. An
58/// Agent that cannot satisfy every member of a compound requirement treats that requirement as
59/// unsupported and never reduces it to the members it does support, so the requirements reported
60/// as satisfiable are always whole requirement objects.
61#[must_use]
62pub fn resolve_openapi_security(
63    root: Option<&[Vec<String>]>,
64    operation: Option<&[Vec<String>]>,
65    supported_schemes: &[String],
66) -> OpenApiSecurityResolution {
67    let effective = operation.or(root).unwrap_or(&[]);
68    if effective.is_empty() {
69        return OpenApiSecurityResolution {
70            disposition: OpenApiSecurityDisposition::Public,
71            satisfiable: Vec::new(),
72        };
73    }
74    if effective.iter().any(Vec::is_empty) {
75        return OpenApiSecurityResolution {
76            disposition: OpenApiSecurityDisposition::OptionalAnonymous,
77            satisfiable: Vec::new(),
78        };
79    }
80    let satisfiable: Vec<Vec<String>> = effective
81        .iter()
82        .filter(|requirement| {
83            requirement
84                .iter()
85                .all(|scheme| supported_schemes.contains(scheme))
86        })
87        .cloned()
88        .collect();
89    let Some(only) = satisfiable.first() else {
90        return OpenApiSecurityResolution {
91            disposition: OpenApiSecurityDisposition::FallbackLiveChallenge,
92            satisfiable: Vec::new(),
93        };
94    };
95    let disposition = if effective.len() > 1 {
96        OpenApiSecurityDisposition::Alternatives
97    } else if only.len() > 1 {
98        OpenApiSecurityDisposition::AllRequired
99    } else {
100        OpenApiSecurityDisposition::Required
101    };
102    OpenApiSecurityResolution {
103        disposition,
104        satisfiable,
105    }
106}
107
108pub fn match_openapi_path(
109    templates: &[String],
110    options: &OpenApiPathMatchOptions,
111) -> Result<OpenApiPathMatch, CoreError> {
112    let method = options.method.to_ascii_uppercase();
113    if method.is_empty() || options.path.is_empty() || options.path.contains('?') {
114        return Err(CoreError::Invalid(
115            "invalid OpenAPI operation target".to_owned(),
116        ));
117    }
118    let request_segments = path_segments(&options.path, options.trailing_slash);
119    let mut best: Option<(&str, Vec<u8>)> = None;
120    let mut ambiguous = false;
121    for template in templates {
122        let template_segments = path_segments(template, options.trailing_slash);
123        if template_segments.len() != request_segments.len() {
124            continue;
125        }
126        let mut specificity = Vec::with_capacity(template_segments.len());
127        let mut matched = true;
128        for (template_segment, request_segment) in
129            template_segments.iter().zip(request_segments.iter())
130        {
131            if template_segment.starts_with('{')
132                && template_segment.ends_with('}')
133                && template_segment.len() > 2
134            {
135                specificity.push(0);
136            } else {
137                specificity.push(1);
138                if template_segment != request_segment {
139                    matched = false;
140                    break;
141                }
142            }
143        }
144        if !matched {
145            continue;
146        }
147        match &best {
148            None => {
149                best = Some((template, specificity));
150                ambiguous = false;
151            }
152            Some((_current, score)) if specificity.as_slice() > score.as_slice() => {
153                best = Some((template, specificity));
154                ambiguous = false;
155            }
156            Some((_current, score)) if specificity == *score => ambiguous = true,
157            Some(_) => {}
158        }
159    }
160    let Some((template, _score)) = best else {
161        return Err(CoreError::Invalid(
162            "OpenAPI operation is not documented".to_owned(),
163        ));
164    };
165    if ambiguous {
166        return Err(CoreError::Invalid(
167            "ambiguous OpenAPI path templates".to_owned(),
168        ));
169    }
170    Ok(OpenApiPathMatch {
171        method,
172        template: template.to_owned(),
173    })
174}
175
176pub fn resolve_openapi_url(
177    final_inspect_url: &Url,
178    reference: &str,
179    allow_insecure_loopback: bool,
180) -> Result<Url, CoreError> {
181    let development_origin = allow_insecure_loopback
182        && final_inspect_url.scheme() == "http"
183        && final_inspect_url.host_str().is_some_and(is_loopback_host);
184    if (final_inspect_url.scheme() != "https" && !development_origin)
185        || final_inspect_url.host_str().is_none()
186        || !final_inspect_url.username().is_empty()
187        || final_inspect_url.password().is_some()
188        || final_inspect_url.fragment().is_some()
189    {
190        return Err(CoreError::Invalid(
191            "invalid final AEP Inspect URL".to_owned(),
192        ));
193    }
194    let resolved = final_inspect_url.join(reference)?;
195    if !resolved.username().is_empty()
196        || resolved.password().is_some()
197        || resolved.fragment().is_some()
198        || resolved.host_str().is_none()
199    {
200        return Err(CoreError::Invalid("invalid AEP OpenAPI URL".to_owned()));
201    }
202    let secure = resolved.scheme() == "https";
203    let allowed_loopback = allow_insecure_loopback
204        && resolved.scheme() == "http"
205        && resolved.host_str().is_some_and(is_loopback_host);
206    if !secure && !allowed_loopback {
207        return Err(CoreError::Invalid(
208            "AEP OpenAPI URL requires HTTPS".to_owned(),
209        ));
210    }
211    Ok(resolved)
212}
213
214fn path_segments(path: &str, trailing_slash: OpenApiTrailingSlash) -> Vec<&str> {
215    let path = if trailing_slash == OpenApiTrailingSlash::Equivalent && path != "/" {
216        path.strip_suffix('/').unwrap_or(path)
217    } else {
218        path
219    };
220    path.strip_prefix('/').unwrap_or(path).split('/').collect()
221}
222
223/// Reports whether a URL host is one of the three loopback names that admit plaintext HTTP.
224///
225/// CORE-OAS-003 spells the exception syntactically: `localhost`, `127.0.0.1`, or `[::1]` and
226/// nothing else. A name that happens to resolve to a loopback address, and any other address in
227/// `127.0.0.0/8`, does not qualify.
228#[must_use]
229pub fn is_loopback_host(host: &str) -> bool {
230    matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn prefers_the_most_specific_template() {
239        let matched = match_openapi_path(
240            &["/items/{id}".to_owned(), "/items/current".to_owned()],
241            &OpenApiPathMatchOptions {
242                method: "get".to_owned(),
243                path: "/items/current".to_owned(),
244                trailing_slash: OpenApiTrailingSlash::Strict,
245            },
246        )
247        .expect("operation match");
248        assert_eq!(matched.template, "/items/current");
249    }
250
251    #[test]
252    fn handles_trailing_slashes_and_ambiguous_templates() {
253        let matched = match_openapi_path(
254            &["/items/{id}".to_owned()],
255            &OpenApiPathMatchOptions {
256                method: "post".to_owned(),
257                path: "/items/one/".to_owned(),
258                trailing_slash: OpenApiTrailingSlash::Equivalent,
259            },
260        )
261        .expect("equivalent trailing slash");
262        assert_eq!(matched.method, "POST");
263        assert!(
264            match_openapi_path(
265                &["/items/{id}".to_owned(), "/items/{name}".to_owned()],
266                &OpenApiPathMatchOptions {
267                    method: "GET".to_owned(),
268                    path: "/items/one".to_owned(),
269                    trailing_slash: OpenApiTrailingSlash::Strict,
270                },
271            )
272            .is_err()
273        );
274        assert!(
275            match_openapi_path(
276                &["/items/{id}".to_owned()],
277                &OpenApiPathMatchOptions {
278                    method: "GET".to_owned(),
279                    path: "/other/one".to_owned(),
280                    trailing_slash: OpenApiTrailingSlash::Strict,
281                },
282            )
283            .is_err()
284        );
285    }
286
287    #[test]
288    fn resolves_only_safe_openapi_urls() {
289        let inspect = Url::parse("https://service.example/.well-known/aep").expect("Inspect URL");
290        assert_eq!(
291            resolve_openapi_url(&inspect, "/openapi.json", false)
292                .expect("resolved OpenAPI URL")
293                .as_str(),
294            "https://service.example/openapi.json"
295        );
296        assert!(
297            resolve_openapi_url(&inspect, "http://service.example/openapi.json", false).is_err()
298        );
299        let loopback = Url::parse("https://127.0.0.1/.well-known/aep").expect("Inspect URL");
300        assert!(resolve_openapi_url(&loopback, "http://127.0.0.1/openapi.json", true).is_ok());
301    }
302}