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