Skip to main content

systemprompt_loader/bundle/source/
https.rs

1//! Plain HTTPS bundle transport.
2//!
3//! The URL is re-validated through the shared SSRF guard on every call rather
4//! than only at profile-parse time, so a profile reloaded from a mutable
5//! source cannot smuggle a link-local address past the boot path. Redirects
6//! are not followed: a 3xx would let the origin move the download to a host
7//! the guard never saw.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::path::Path;
13
14use systemprompt_models::net::{trusted_http_hosts_from_env, validate_outbound_url_with_trust};
15
16use super::stream::stream_to_file;
17use super::{BundleFetcher, FetchedBundle, MAX_BUNDLE_BYTES, RemoteRef};
18use crate::bundle::error::{BundleError, BundleResult};
19
20#[derive(Debug)]
21pub struct HttpsFetcher {
22    name: String,
23    url: String,
24    auth: Option<String>,
25    client: reqwest::Client,
26    max_bytes: u64,
27}
28
29impl HttpsFetcher {
30    #[must_use]
31    pub fn new(name: &str, url: &str, auth: Option<String>, client: reqwest::Client) -> Self {
32        Self {
33            name: name.to_owned(),
34            url: url.to_owned(),
35            auth,
36            client,
37            max_bytes: MAX_BUNDLE_BYTES,
38        }
39    }
40
41    #[must_use]
42    pub const fn with_max_bytes(mut self, max_bytes: u64) -> Self {
43        self.max_bytes = max_bytes;
44        self
45    }
46
47    fn checked_url(&self) -> BundleResult<url::Url> {
48        let trusted = trusted_http_hosts_from_env();
49        validate_outbound_url_with_trust(&self.url, &trusted)
50            .map_err(|e| BundleError::fetch(&self.name, e))
51    }
52}
53
54impl BundleFetcher for HttpsFetcher {
55    async fn head(&self) -> BundleResult<RemoteRef> {
56        let url = self.checked_url()?;
57        let mut request = self.client.head(url);
58        if let Some(token) = self.auth.as_ref() {
59            request = request.bearer_auth(token);
60        }
61        let response = request
62            .send()
63            .await
64            .map_err(|e| BundleError::fetch(&self.name, e))?;
65
66        if response.status() == reqwest::StatusCode::UNAUTHORIZED
67            || response.status() == reqwest::StatusCode::FORBIDDEN
68        {
69            return Err(BundleError::Auth {
70                source_name: self.name.clone(),
71            });
72        }
73        if !response.status().is_success() {
74            return Ok(RemoteRef {
75                digest: String::new(),
76            });
77        }
78
79        let etag = response
80            .headers()
81            .get(reqwest::header::ETAG)
82            .and_then(|v| v.to_str().ok())
83            .map(|v| v.trim_matches('"').to_owned())
84            .unwrap_or_default();
85        Ok(RemoteRef { digest: etag })
86    }
87
88    async fn fetch(&self, into: &Path) -> BundleResult<FetchedBundle> {
89        let url = self.checked_url()?;
90        let mut request = self.client.get(url);
91        if let Some(token) = self.auth.as_ref() {
92            request = request.bearer_auth(token);
93        }
94        let response = request
95            .send()
96            .await
97            .map_err(|e| BundleError::fetch(&self.name, e))?;
98
99        let status = response.status();
100        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
101            return Err(BundleError::Auth {
102                source_name: self.name.clone(),
103            });
104        }
105        if status.is_redirection() {
106            return Err(BundleError::fetch(
107                &self.name,
108                format!("redirect ({status}) is not followed"),
109            ));
110        }
111        if !status.is_success() {
112            return Err(BundleError::fetch(&self.name, format!("status {status}")));
113        }
114
115        let digest = stream_to_file(response, into, &self.name, self.max_bytes).await?;
116        Ok(FetchedBundle {
117            archive: into.to_path_buf(),
118            digest,
119        })
120    }
121}