1use std::time::Duration;
2
3use aep_core::{
4 DEFAULT_INSPECT_FRESHNESS, DidWebDocumentUrlOptions, HttpRequest, MEDIA_TYPE, WELL_KNOWN_PATH,
5 did_web_document_url_with_options, parse_inspect_document,
6};
7use http::{HeaderMap, HeaderValue, Method, StatusCode, header};
8use time::OffsetDateTime;
9use url::Url;
10
11use crate::{AgentError, InspectCacheEntry, InspectErrorCode, Inspection, Session, same_origin};
12
13const MAXIMUM_REDIRECTS: usize = 5;
14
15impl Session {
16 pub async fn inspect(&self) -> Result<Inspection, AgentError> {
17 let _guard = self.inspect_lock.lock().await;
18 let inspect_url = self.service_url.join(WELL_KNOWN_PATH)?;
19 let mut cached = self.client.inspect_cache.find(&inspect_url).await?;
20 if let Some(entry) = &cached
21 && cache_fresh(entry, self.client.clock.now())
22 {
23 return inspection_from_cache(
24 &self.service_url,
25 &inspect_url,
26 entry.clone(),
27 self.client.allow_insecure_loopback,
28 );
29 }
30 if cached
31 .as_ref()
32 .is_some_and(|entry| !safe_target(&entry.final_url, &inspect_url))
33 {
34 self.client.inspect_cache.delete(&inspect_url).await?;
35 cached = None;
36 }
37 let mut current = cached
38 .as_ref()
39 .map_or_else(|| inspect_url.clone(), |entry| entry.final_url.clone());
40 for redirects in 0..=MAXIMUM_REDIRECTS {
41 let mut headers = HeaderMap::new();
42 headers.insert(header::ACCEPT, HeaderValue::from_static(MEDIA_TYPE));
43 if let Some(entry) = &cached {
44 if let Some(value) = entry.etag.as_deref().and_then(header_value) {
45 headers.insert(header::IF_NONE_MATCH, value);
46 }
47 if let Some(value) = entry.last_modified.as_deref().and_then(header_value) {
48 headers.insert(header::IF_MODIFIED_SINCE, value);
49 }
50 }
51 let response = self
52 .client
53 .inspect_transport
54 .send(HttpRequest {
55 method: Method::GET,
56 url: current.clone(),
57 headers,
58 body: Vec::new(),
59 })
60 .await
61 .map_err(|error| {
62 inspect_error(InspectErrorCode::HttpError, error.to_string(), None)
63 })?;
64 if response.final_url != current {
65 return Err(inspect_error(
66 InspectErrorCode::InvalidRedirect,
67 "transport followed an Inspect redirect",
68 Some(response.status.as_u16()),
69 ));
70 }
71 if is_redirect(response.status) {
72 if redirects == MAXIMUM_REDIRECTS {
73 return Err(inspect_error(
74 InspectErrorCode::InvalidRedirect,
75 "exceeded five redirects",
76 Some(response.status.as_u16()),
77 ));
78 }
79 let location = response
80 .headers
81 .get(header::LOCATION)
82 .and_then(|value| value.to_str().ok())
83 .ok_or_else(|| {
84 inspect_error(
85 InspectErrorCode::InvalidRedirect,
86 "redirect omitted Location",
87 Some(response.status.as_u16()),
88 )
89 })?;
90 let next = current.join(location).map_err(|_| {
91 inspect_error(
92 InspectErrorCode::InvalidRedirect,
93 "redirect Location is invalid",
94 Some(response.status.as_u16()),
95 )
96 })?;
97 if !safe_target(&next, ¤t) {
98 return Err(inspect_error(
99 InspectErrorCode::InvalidRedirect,
100 "redirect changed origin or scheme",
101 Some(response.status.as_u16()),
102 ));
103 }
104 current = next;
105 continue;
106 }
107 let inspection = if response.status == StatusCode::NOT_MODIFIED {
108 let mut entry = cached.clone().ok_or_else(|| {
109 inspect_error(
110 InspectErrorCode::HttpError,
111 "returned 304 without a cached document",
112 Some(304),
113 )
114 })?;
115 entry.cached_at = self.client.clock.now();
116 entry.final_url = current.clone();
117 merge_cache_headers(&mut entry, &response.headers);
118 inspection_from_cache(
119 &self.service_url,
120 &inspect_url,
121 entry,
122 self.client.allow_insecure_loopback,
123 )?
124 } else {
125 parse_response(self, &inspect_url, ¤t, response)?
126 };
127 let entry = InspectCacheEntry {
128 cache_control: inspection.cache_control.clone(),
129 cached_at: self.client.clock.now(),
130 document: inspection.document.clone(),
131 etag: inspection.etag.clone(),
132 final_url: inspection.final_url.clone(),
133 last_modified: inspection.last_modified.clone(),
134 };
135 if directive(inspection.cache_control.as_deref(), "no-store").is_some() {
136 self.client.inspect_cache.delete(&inspect_url).await?;
137 } else {
138 self.client.inspect_cache.save(&inspect_url, entry).await?;
139 }
140 return Ok(inspection);
141 }
142 unreachable!("redirect loop returns or continues within its bound")
143 }
144}
145
146fn parse_response(
147 session: &Session,
148 inspect_url: &Url,
149 current: &Url,
150 response: aep_core::HttpResponse,
151) -> Result<Inspection, AgentError> {
152 let status = response.status.as_u16();
153 if !response.status.is_success() {
154 return Err(inspect_error(
155 InspectErrorCode::HttpError,
156 format!("HTTP {status}"),
157 Some(status),
158 ));
159 }
160 if !media_type_matches(response.headers.get(header::CONTENT_TYPE), MEDIA_TYPE) {
161 return Err(inspect_error(
162 InspectErrorCode::InvalidMediaType,
163 "response media type is invalid",
164 Some(status),
165 ));
166 }
167 if response.body.len() > session.client.maximum_response_bytes {
168 return Err(inspect_error(
169 InspectErrorCode::ResponseTooLarge,
170 "response exceeds the configured limit",
171 Some(status),
172 ));
173 }
174 let document = parse_inspect_document(&response.body).map_err(|error| {
175 let code = if serde_json::from_slice::<serde_json::Value>(&response.body).is_err() {
176 InspectErrorCode::InvalidJson
177 } else {
178 InspectErrorCode::ValidationFailed
179 };
180 inspect_error(code, error.to_string(), Some(status))
181 })?;
182 let inspection = Inspection {
183 cache_control: header_string(&response.headers, header::CACHE_CONTROL),
184 document,
185 etag: header_string(&response.headers, header::ETAG),
186 final_url: current.clone(),
187 inspect_url: inspect_url.clone(),
188 last_modified: header_string(&response.headers, header::LAST_MODIFIED),
189 service_url: session.service_url.clone(),
190 };
191 validate_service_identity(&inspection, session.client.allow_insecure_loopback)?;
192 Ok(inspection)
193}
194
195fn inspection_from_cache(
196 service_url: &Url,
197 inspect_url: &Url,
198 entry: InspectCacheEntry,
199 allow_insecure_loopback: bool,
200) -> Result<Inspection, AgentError> {
201 let inspection = Inspection {
202 cache_control: entry.cache_control,
203 document: entry.document,
204 etag: entry.etag,
205 final_url: entry.final_url,
206 inspect_url: inspect_url.clone(),
207 last_modified: entry.last_modified,
208 service_url: service_url.clone(),
209 };
210 validate_service_identity(&inspection, allow_insecure_loopback)?;
211 Ok(inspection)
212}
213
214fn validate_service_identity(
215 inspection: &Inspection,
216 allow_insecure_loopback: bool,
217) -> Result<(), AgentError> {
218 let did = &inspection.document.service.did;
219 if !did.starts_with("did:web:") {
220 return Err(inspect_error(
221 InspectErrorCode::ServiceIdentityMismatch,
222 "Service DID has no supported origin binding",
223 None,
224 ));
225 }
226 let document_url = did_web_document_url_with_options(
227 did,
228 DidWebDocumentUrlOptions {
229 allow_insecure_loopback,
230 },
231 )
232 .map_err(|_| {
233 inspect_error(
234 InspectErrorCode::ServiceIdentityMismatch,
235 "Service DID does not match the Inspect origin",
236 None,
237 )
238 })?;
239 if !same_origin(&document_url, &inspection.final_url) {
240 return Err(inspect_error(
241 InspectErrorCode::ServiceIdentityMismatch,
242 "Service DID does not match the Inspect origin",
243 None,
244 ));
245 }
246 Ok(())
247}
248
249fn cache_fresh(entry: &InspectCacheEntry, now: OffsetDateTime) -> bool {
250 if directive(entry.cache_control.as_deref(), "no-cache").is_some()
251 || directive(entry.cache_control.as_deref(), "no-store").is_some()
252 {
253 return false;
254 }
255 let freshness = match directive(entry.cache_control.as_deref(), "max-age") {
256 Some(value) => match value.parse::<u64>() {
257 Ok(value) => Duration::from_secs(value),
258 Err(_) => return false,
259 },
260 None => DEFAULT_INSPECT_FRESHNESS,
261 };
262 let Ok(freshness) = time::Duration::try_from(freshness) else {
263 return false;
264 };
265 entry
266 .cached_at
267 .checked_add(freshness)
268 .is_some_and(|expires| expires > now)
269}
270
271fn directive<'a>(value: Option<&'a str>, name: &str) -> Option<&'a str> {
272 value?.split(',').find_map(|part| {
273 let mut fields = part.trim().splitn(2, '=');
274 let field = fields.next()?;
275 field
276 .eq_ignore_ascii_case(name)
277 .then(|| fields.next().unwrap_or("").trim_matches('"'))
278 })
279}
280
281fn merge_cache_headers(entry: &mut InspectCacheEntry, headers: &HeaderMap) {
282 if let Some(value) = header_string(headers, header::CACHE_CONTROL) {
283 entry.cache_control = Some(value);
284 }
285 if let Some(value) = header_string(headers, header::ETAG) {
286 entry.etag = Some(value);
287 }
288 if let Some(value) = header_string(headers, header::LAST_MODIFIED) {
289 entry.last_modified = Some(value);
290 }
291}
292
293fn media_type_matches(value: Option<&HeaderValue>, expected: &str) -> bool {
294 value
295 .and_then(|value| value.to_str().ok())
296 .and_then(|value| value.split(';').next())
297 .is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
298}
299
300fn safe_target(target: &Url, reference: &Url) -> bool {
301 target.username().is_empty()
302 && target.password().is_none()
303 && target.fragment().is_none()
304 && target.scheme() == reference.scheme()
305 && same_origin(target, reference)
306}
307
308fn is_redirect(status: StatusCode) -> bool {
309 matches!(
310 status,
311 StatusCode::MOVED_PERMANENTLY
312 | StatusCode::FOUND
313 | StatusCode::SEE_OTHER
314 | StatusCode::TEMPORARY_REDIRECT
315 | StatusCode::PERMANENT_REDIRECT
316 )
317}
318
319fn header_string(headers: &HeaderMap, name: header::HeaderName) -> Option<String> {
320 headers
321 .get(name)
322 .and_then(|value| value.to_str().ok())
323 .map(str::to_owned)
324}
325
326fn header_value(value: &str) -> Option<HeaderValue> {
327 HeaderValue::from_str(value).ok()
328}
329
330fn inspect_error(
331 code: InspectErrorCode,
332 message: impl Into<String>,
333 status: Option<u16>,
334) -> AgentError {
335 AgentError::Inspect {
336 code,
337 message: message.into(),
338 status,
339 }
340}