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    /// Remove crates and versions that were deleted from crates.io from the
67    /// cache, with their cached crate files, when the index of the crate is
68    /// refreshed. Without it, they stay available from Kellnr
69    pub remove_deleted_versions: bool,
70}
71
72impl Default for Proxy {
73    fn default() -> Self {
74        Self {
75            enabled: false,
76            num_threads: 10,
77            download_on_update: false,
78            url: default_proxy_url(),
79            index: default_index_url(),
80            api: default_api_url(),
81            connect_timeout_seconds: 5,
82            request_timeout_seconds: 30,
83            user_agent: default_user_agent(),
84            remove_deleted_versions: false,
85        }
86    }
87}
88
89impl Proxy {
90    /// Lets the upstream URLs end in a slash. They are base URLs for
91    /// `Url::join`, which replaces the last path segment if it is missing.
92    pub fn normalize(&mut self) {
93        for url in [&mut self.url, &mut self.index, &mut self.api] {
94            if !url.path().ends_with('/') {
95                let path = format!("{}/", url.path());
96                url.set_path(&path);
97            }
98        }
99    }
100
101    /// Base URL of the crate downloads, `proxy.url`.
102    pub fn download_url(&self) -> Result<BaseUrl, BaseUrlError> {
103        BaseUrl::new(self.url.clone())
104    }
105
106    /// Base URL of the sparse index, `proxy.index`.
107    pub fn index_url(&self) -> Result<BaseUrl, BaseUrlError> {
108        BaseUrl::new(self.index.clone())
109    }
110
111    /// Base URL of the crates API, `proxy.api`.
112    pub fn api_url(&self) -> Result<BaseUrl, BaseUrlError> {
113        BaseUrl::new(self.api.clone())
114    }
115
116    /// Checks the upstream URLs of an enabled proxy, so the accessors of
117    /// them cannot fail later.
118    pub fn validate(&self) -> Result<(), String> {
119        if !self.enabled {
120            return Ok(());
121        }
122        for (key, url) in [
123            ("proxy.url", self.download_url()),
124            ("proxy.index", self.index_url()),
125            ("proxy.api", self.api_url()),
126        ] {
127            url.map_err(|e| format!("{key} {e}"))?;
128        }
129        Ok(())
130    }
131
132    /// Configuration that is accepted but most likely a mistake.
133    pub fn warnings(&self) -> Vec<String> {
134        if self.enabled && self.num_threads == 0 {
135            vec![
136                "proxy.num_threads is 0, cached crates.io crates are not updated in the background"
137                    .to_string(),
138            ]
139        } else {
140            Vec::new()
141        }
142    }
143
144    /// The upstream sparse-index URL that internal cargo invocations (e.g. the
145    /// docs builder) should resolve crates.io dependencies from, if it differs
146    /// from the built-in crates.io default.
147    ///
148    /// Returns `None` when the proxy is disabled or the default index is in
149    /// use. In both cases cargo's own default (`index.crates.io`) is correct
150    /// and source replacement would be redundant. When `Some`, callers should
151    /// point cargo's `crates-io` source at this URL so dependency resolution
152    /// honors `proxy.index` instead of hitting crates.io directly. See #1185.
153    pub fn cratesio_index_override(&self) -> Option<&Url> {
154        if self.enabled && self.index != default_index_url() {
155            Some(&self.index)
156        } else {
157            None
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn normalize_lets_urls_end_in_a_slash() {
168        let mut proxy: Proxy = toml::from_str(
169            r#"
170            url = "https://mirror.example.com/crates"
171            index = "https://mirror.example.com/index"
172            api = "https://mirror.example.com/api/v1/crates/"
173        "#,
174        )
175        .unwrap();
176
177        proxy.normalize();
178
179        assert_eq!(
180            proxy.url.join("serde/serde-1.0.0.crate").unwrap().as_str(),
181            "https://mirror.example.com/crates/serde/serde-1.0.0.crate"
182        );
183        assert_eq!(proxy.index.as_str(), "https://mirror.example.com/index/");
184        assert_eq!(
185            proxy.api.as_str(),
186            "https://mirror.example.com/api/v1/crates/"
187        );
188    }
189
190    #[test]
191    fn accessors_return_base_urls_that_keep_the_last_segment() {
192        let proxy: Proxy = toml::from_str(
193            r#"
194            url = "https://mirror.example.com/crates"
195            index = "https://mirror.example.com/index"
196            api = "https://mirror.example.com/api/v1/crates"
197        "#,
198        )
199        .unwrap();
200
201        assert_eq!(
202            proxy
203                .download_url()
204                .unwrap()
205                .join("serde/1.0.0/download")
206                .as_str(),
207            "https://mirror.example.com/crates/serde/1.0.0/download"
208        );
209        assert_eq!(
210            proxy.index_url().unwrap().join("se/rd/serde").as_str(),
211            "https://mirror.example.com/index/se/rd/serde"
212        );
213        assert_eq!(
214            proxy.api_url().unwrap().join("serde").as_str(),
215            "https://mirror.example.com/api/v1/crates/serde"
216        );
217    }
218
219    #[test]
220    fn validate_names_the_key_of_an_invalid_url() {
221        let proxy = Proxy {
222            enabled: true,
223            index: Url::parse("sparse+https://index.crates.io/").unwrap(),
224            ..Proxy::default()
225        };
226        assert_eq!(
227            proxy.validate().unwrap_err(),
228            "proxy.index must be an http or https URL, got sparse+https://index.crates.io/"
229        );
230    }
231
232    #[test]
233    fn zero_threads_are_accepted_with_a_warning() {
234        let proxy = Proxy {
235            enabled: true,
236            num_threads: 0,
237            ..Proxy::default()
238        };
239        assert!(proxy.validate().is_ok());
240        assert!(proxy.warnings()[0].contains("num_threads"));
241        assert!(Proxy::default().warnings().is_empty());
242    }
243
244    #[test]
245    fn validate_rejects_urls_that_are_not_http() {
246        let proxy = Proxy {
247            enabled: true,
248            index: Url::parse("sparse+https://index.crates.io/").unwrap(),
249            ..Proxy::default()
250        };
251        assert!(proxy.validate().unwrap_err().contains("proxy.index"));
252    }
253
254    #[test]
255    fn validate_ignores_disabled_proxy() {
256        let proxy = Proxy {
257            num_threads: 0,
258            url: Url::parse("ftp://example.com/").unwrap(),
259            ..Proxy::default()
260        };
261        assert!(proxy.validate().is_ok());
262        let enabled = Proxy {
263            enabled: true,
264            ..Proxy::default()
265        };
266        assert!(enabled.validate().is_ok());
267    }
268
269    #[test]
270    fn default_api_url_is_cratesio() {
271        let proxy = Proxy::default();
272        assert_eq!(
273            proxy.api,
274            Url::parse("https://crates.io/api/v1/crates/").unwrap()
275        );
276    }
277
278    #[test]
279    fn deserialize_custom_api_url_from_toml() {
280        let toml = r#"
281            api = "https://rsproxy.cn/api/v1/crates/"
282        "#;
283        let proxy: Proxy = toml::from_str(toml).unwrap();
284        assert_eq!(
285            proxy.api,
286            Url::parse("https://rsproxy.cn/api/v1/crates/").unwrap()
287        );
288    }
289
290    #[test]
291    fn missing_api_url_uses_default() {
292        let toml = r#"
293            enabled = true
294            url = "https://rsproxy.cn/api/v1/crates/"
295            index = "https://rsproxy.cn/index/"
296        "#;
297        let proxy: Proxy = toml::from_str(toml).unwrap();
298        assert_eq!(
299            proxy.api,
300            Url::parse("https://crates.io/api/v1/crates/").unwrap()
301        );
302    }
303
304    #[test]
305    fn api_url_join_produces_correct_crate_url() {
306        let proxy: Proxy = toml::from_str(r#"api = "https://rsproxy.cn/api/v1/crates/""#).unwrap();
307        let url = proxy.api.join("serde").unwrap();
308        assert_eq!(url.as_str(), "https://rsproxy.cn/api/v1/crates/serde");
309    }
310
311    #[test]
312    fn default_url_join_produces_correct_crate_url() {
313        let proxy = Proxy::default();
314        let url = proxy.api.join("tokio").unwrap();
315        assert_eq!(url.as_str(), "https://crates.io/api/v1/crates/tokio");
316    }
317
318    #[test]
319    fn default_user_agent_is_kellnr() {
320        let proxy = Proxy::default();
321        assert_eq!(proxy.user_agent, "kellnr.io/kellnr");
322    }
323
324    #[test]
325    fn missing_user_agent_uses_default() {
326        let proxy: Proxy = toml::from_str("enabled = true").unwrap();
327        assert_eq!(proxy.user_agent, "kellnr.io/kellnr");
328    }
329
330    #[test]
331    fn deserialize_custom_user_agent_from_toml() {
332        let toml = r#"user_agent = "kellnr.io/kellnr (admin@example.com)""#;
333        let proxy: Proxy = toml::from_str(toml).unwrap();
334        assert_eq!(proxy.user_agent, "kellnr.io/kellnr (admin@example.com)");
335    }
336
337    #[test]
338    fn deleted_versions_are_kept_by_default() {
339        let proxy: Proxy = toml::from_str("enabled = true").unwrap();
340        assert!(!proxy.remove_deleted_versions);
341
342        let proxy: Proxy = toml::from_str("remove_deleted_versions = true").unwrap();
343        assert!(proxy.remove_deleted_versions);
344    }
345
346    #[test]
347    fn index_override_none_when_disabled() {
348        let proxy: Proxy = toml::from_str(r#"index = "https://rsproxy.cn/index/""#).unwrap();
349        assert!(!proxy.enabled);
350        assert_eq!(proxy.cratesio_index_override(), None);
351    }
352
353    #[test]
354    fn index_override_none_when_default_index() {
355        let proxy: Proxy = toml::from_str("enabled = true").unwrap();
356        assert_eq!(proxy.cratesio_index_override(), None);
357    }
358
359    #[test]
360    fn index_override_some_when_enabled_and_custom() {
361        let toml = r#"
362            enabled = true
363            index = "https://rsproxy.cn/index/"
364        "#;
365        let proxy: Proxy = toml::from_str(toml).unwrap();
366        assert_eq!(
367            proxy.cratesio_index_override().map(Url::as_str),
368            Some("https://rsproxy.cn/index/")
369        );
370    }
371}