Skip to main content

aep_agent/
inspect.rs

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        // Custom caches cannot make an unsafe redirect or a no-store representation reusable.
22        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, &current) {
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, &current, 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            // CORE-CACHE-007 keys the entry by the requested advertised URL and updates that key
152            // to the final URL once a redirect is accepted, so the document is reachable under
153            // either spelling. `no-store` removes both.
154            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}