Skip to main content

aep_agent/
inspect.rs

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, &current) {
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, &current, 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}