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#[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#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct OpenApiSecurityResolution {
49 pub disposition: OpenApiSecurityDisposition,
50 pub satisfiable: Vec<Vec<String>>,
51}
52
53#[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#[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}