Skip to main content

kellnr_settings/
proxy.rs

1use kellnr_common::base_url::{BaseUrl, BaseUrlError};
2use provcfg::{ClapArgs, Configurable};
3use serde::{Deserialize, Serialize};
4use url::Url;
5use utoipa::ToSchema;
6
7fn default_proxy_url() -> Url {
8    Url::parse("https://static.crates.io/crates/").unwrap()
9}
10
11fn default_index_url() -> Url {
12    Url::parse("https://index.crates.io/").unwrap()
13}
14
15fn default_api_url() -> Url {
16    Url::parse("https://crates.io/api/v1/crates/").unwrap()
17}
18
19/// Default user-agent sent with requests to crates.io. This is the single source
20/// of truth for the default user-agent across the whole codebase.
21pub const DEFAULT_USER_AGENT: &str = "kellnr.io/kellnr";
22
23fn default_user_agent() -> String {
24    DEFAULT_USER_AGENT.to_string()
25}
26
27#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema)]
28#[serde(default)]
29#[configurable(clap_prefix = "proxy")]
30pub struct Proxy {
31    /// Enable crates.io proxy
32    pub enabled: bool,
33
34    /// Number of proxy threads
35    pub num_threads: usize,
36
37    /// Download crates on index update
38    pub download_on_update: bool,
39
40    /// Crates.io download URL
41    #[schema(value_type = String)]
42    pub url: Url,
43
44    /// Crates.io index URL
45    #[schema(value_type = String)]
46    pub index: Url,
47
48    /// Crates.io API URL
49    #[schema(value_type = String)]
50    pub api: Url,
51
52    /// Connect timeout in seconds for upstream requests
53    #[arg(long = "proxy-connect-timeout")]
54    pub connect_timeout_seconds: u64,
55
56    /// Request timeout in seconds for upstream downloads
57    #[arg(long = "proxy-request-timeout")]
58    pub request_timeout_seconds: u64,
59
60    /// User-agent sent with requests to crates.io. crates.io's data-access
61    /// policy asks for a user-agent that identifies the application and a way
62    /// to get in contact, e.g. "kellnr.io/kellnr (contact@example.com)".
63    #[arg(long = "proxy-user-agent")]
64    pub user_agent: String,
65}
66
67impl Default for Proxy {
68    fn default() -> Self {
69        Self {
70            enabled: false,
71            num_threads: 10,
72            download_on_update: false,
73            url: default_proxy_url(),
74            index: default_index_url(),
75            api: default_api_url(),
76            connect_timeout_seconds: 5,
77            request_timeout_seconds: 30,
78            user_agent: default_user_agent(),
79        }
80    }
81}
82
83impl Proxy {
84    /// Lets the upstream URLs end in a slash. They are base URLs for
85    /// `Url::join`, which replaces the last path segment if it is missing.
86    pub fn normalize(&mut self) {
87        for url in [&mut self.url, &mut self.index, &mut self.api] {
88            if !url.path().ends_with('/') {
89                let path = format!("{}/", url.path());
90                url.set_path(&path);
91            }
92        }
93    }
94
95    /// Base URL of the crate downloads, `proxy.url`.
96    pub fn download_url(&self) -> Result<BaseUrl, BaseUrlError> {
97        BaseUrl::new(self.url.clone())
98    }
99
100    /// Base URL of the sparse index, `proxy.index`.
101    pub fn index_url(&self) -> Result<BaseUrl, BaseUrlError> {
102        BaseUrl::new(self.index.clone())
103    }
104
105    /// Base URL of the crates API, `proxy.api`.
106    pub fn api_url(&self) -> Result<BaseUrl, BaseUrlError> {
107        BaseUrl::new(self.api.clone())
108    }
109
110    /// Checks the upstream URLs of an enabled proxy, so the accessors of
111    /// them cannot fail later.
112    pub fn validate(&self) -> Result<(), String> {
113        if !self.enabled {
114            return Ok(());
115        }
116        for (key, url) in [
117            ("proxy.url", self.download_url()),
118            ("proxy.index", self.index_url()),
119            ("proxy.api", self.api_url()),
120        ] {
121            url.map_err(|e| format!("{key} {e}"))?;
122        }
123        Ok(())
124    }
125
126    /// Configuration that is accepted but most likely a mistake.
127    pub fn warnings(&self) -> Vec<String> {
128        if self.enabled && self.num_threads == 0 {
129            vec![
130                "proxy.num_threads is 0, cached crates.io crates are not updated in the background"
131                    .to_string(),
132            ]
133        } else {
134            Vec::new()
135        }
136    }
137
138    /// The upstream sparse-index URL that internal cargo invocations (e.g. the
139    /// docs builder) should resolve crates.io dependencies from, if it differs
140    /// from the built-in crates.io default.
141    ///
142    /// Returns `None` when the proxy is disabled or the default index is in
143    /// use. In both cases cargo's own default (`index.crates.io`) is correct
144    /// and source replacement would be redundant. When `Some`, callers should
145    /// point cargo's `crates-io` source at this URL so dependency resolution
146    /// honors `proxy.index` instead of hitting crates.io directly. See #1185.
147    pub fn cratesio_index_override(&self) -> Option<&Url> {
148        if self.enabled && self.index != default_index_url() {
149            Some(&self.index)
150        } else {
151            None
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn normalize_lets_urls_end_in_a_slash() {
162        let mut proxy: Proxy = toml::from_str(
163            r#"
164            url = "https://mirror.example.com/crates"
165            index = "https://mirror.example.com/index"
166            api = "https://mirror.example.com/api/v1/crates/"
167        "#,
168        )
169        .unwrap();
170
171        proxy.normalize();
172
173        assert_eq!(
174            proxy.url.join("serde/serde-1.0.0.crate").unwrap().as_str(),
175            "https://mirror.example.com/crates/serde/serde-1.0.0.crate"
176        );
177        assert_eq!(proxy.index.as_str(), "https://mirror.example.com/index/");
178        assert_eq!(
179            proxy.api.as_str(),
180            "https://mirror.example.com/api/v1/crates/"
181        );
182    }
183
184    #[test]
185    fn accessors_return_base_urls_that_keep_the_last_segment() {
186        let proxy: Proxy = toml::from_str(
187            r#"
188            url = "https://mirror.example.com/crates"
189            index = "https://mirror.example.com/index"
190            api = "https://mirror.example.com/api/v1/crates"
191        "#,
192        )
193        .unwrap();
194
195        assert_eq!(
196            proxy
197                .download_url()
198                .unwrap()
199                .join("serde/1.0.0/download")
200                .as_str(),
201            "https://mirror.example.com/crates/serde/1.0.0/download"
202        );
203        assert_eq!(
204            proxy.index_url().unwrap().join("se/rd/serde").as_str(),
205            "https://mirror.example.com/index/se/rd/serde"
206        );
207        assert_eq!(
208            proxy.api_url().unwrap().join("serde").as_str(),
209            "https://mirror.example.com/api/v1/crates/serde"
210        );
211    }
212
213    #[test]
214    fn validate_names_the_key_of_an_invalid_url() {
215        let proxy = Proxy {
216            enabled: true,
217            index: Url::parse("sparse+https://index.crates.io/").unwrap(),
218            ..Proxy::default()
219        };
220        assert_eq!(
221            proxy.validate().unwrap_err(),
222            "proxy.index must be an http or https URL, got sparse+https://index.crates.io/"
223        );
224    }
225
226    #[test]
227    fn zero_threads_are_accepted_with_a_warning() {
228        let proxy = Proxy {
229            enabled: true,
230            num_threads: 0,
231            ..Proxy::default()
232        };
233        assert!(proxy.validate().is_ok());
234        assert!(proxy.warnings()[0].contains("num_threads"));
235        assert!(Proxy::default().warnings().is_empty());
236    }
237
238    #[test]
239    fn validate_rejects_urls_that_are_not_http() {
240        let proxy = Proxy {
241            enabled: true,
242            index: Url::parse("sparse+https://index.crates.io/").unwrap(),
243            ..Proxy::default()
244        };
245        assert!(proxy.validate().unwrap_err().contains("proxy.index"));
246    }
247
248    #[test]
249    fn validate_ignores_disabled_proxy() {
250        let proxy = Proxy {
251            num_threads: 0,
252            url: Url::parse("ftp://example.com/").unwrap(),
253            ..Proxy::default()
254        };
255        assert!(proxy.validate().is_ok());
256        let enabled = Proxy {
257            enabled: true,
258            ..Proxy::default()
259        };
260        assert!(enabled.validate().is_ok());
261    }
262
263    #[test]
264    fn default_api_url_is_cratesio() {
265        let proxy = Proxy::default();
266        assert_eq!(
267            proxy.api,
268            Url::parse("https://crates.io/api/v1/crates/").unwrap()
269        );
270    }
271
272    #[test]
273    fn deserialize_custom_api_url_from_toml() {
274        let toml = r#"
275            api = "https://rsproxy.cn/api/v1/crates/"
276        "#;
277        let proxy: Proxy = toml::from_str(toml).unwrap();
278        assert_eq!(
279            proxy.api,
280            Url::parse("https://rsproxy.cn/api/v1/crates/").unwrap()
281        );
282    }
283
284    #[test]
285    fn missing_api_url_uses_default() {
286        let toml = r#"
287            enabled = true
288            url = "https://rsproxy.cn/api/v1/crates/"
289            index = "https://rsproxy.cn/index/"
290        "#;
291        let proxy: Proxy = toml::from_str(toml).unwrap();
292        assert_eq!(
293            proxy.api,
294            Url::parse("https://crates.io/api/v1/crates/").unwrap()
295        );
296    }
297
298    #[test]
299    fn api_url_join_produces_correct_crate_url() {
300        let proxy: Proxy = toml::from_str(r#"api = "https://rsproxy.cn/api/v1/crates/""#).unwrap();
301        let url = proxy.api.join("serde").unwrap();
302        assert_eq!(url.as_str(), "https://rsproxy.cn/api/v1/crates/serde");
303    }
304
305    #[test]
306    fn default_url_join_produces_correct_crate_url() {
307        let proxy = Proxy::default();
308        let url = proxy.api.join("tokio").unwrap();
309        assert_eq!(url.as_str(), "https://crates.io/api/v1/crates/tokio");
310    }
311
312    #[test]
313    fn default_user_agent_is_kellnr() {
314        let proxy = Proxy::default();
315        assert_eq!(proxy.user_agent, "kellnr.io/kellnr");
316    }
317
318    #[test]
319    fn missing_user_agent_uses_default() {
320        let proxy: Proxy = toml::from_str("enabled = true").unwrap();
321        assert_eq!(proxy.user_agent, "kellnr.io/kellnr");
322    }
323
324    #[test]
325    fn deserialize_custom_user_agent_from_toml() {
326        let toml = r#"user_agent = "kellnr.io/kellnr (admin@example.com)""#;
327        let proxy: Proxy = toml::from_str(toml).unwrap();
328        assert_eq!(proxy.user_agent, "kellnr.io/kellnr (admin@example.com)");
329    }
330
331    #[test]
332    fn index_override_none_when_disabled() {
333        let proxy: Proxy = toml::from_str(r#"index = "https://rsproxy.cn/index/""#).unwrap();
334        assert!(!proxy.enabled);
335        assert_eq!(proxy.cratesio_index_override(), None);
336    }
337
338    #[test]
339    fn index_override_none_when_default_index() {
340        let proxy: Proxy = toml::from_str("enabled = true").unwrap();
341        assert_eq!(proxy.cratesio_index_override(), None);
342    }
343
344    #[test]
345    fn index_override_some_when_enabled_and_custom() {
346        let toml = r#"
347            enabled = true
348            index = "https://rsproxy.cn/index/"
349        "#;
350        let proxy: Proxy = toml::from_str(toml).unwrap();
351        assert_eq!(
352            proxy.cratesio_index_override().map(Url::as_str),
353            Some("https://rsproxy.cn/index/")
354        );
355    }
356}