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
19pub 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 pub enabled: bool,
33
34 pub num_threads: usize,
36
37 pub download_on_update: bool,
39
40 #[schema(value_type = String)]
42 pub url: Url,
43
44 #[schema(value_type = String)]
46 pub index: Url,
47
48 #[schema(value_type = String)]
50 pub api: Url,
51
52 #[arg(long = "proxy-connect-timeout")]
54 pub connect_timeout_seconds: u64,
55
56 #[arg(long = "proxy-request-timeout")]
58 pub request_timeout_seconds: u64,
59
60 #[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 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 pub fn download_url(&self) -> Result<BaseUrl, BaseUrlError> {
97 BaseUrl::new(self.url.clone())
98 }
99
100 pub fn index_url(&self) -> Result<BaseUrl, BaseUrlError> {
102 BaseUrl::new(self.index.clone())
103 }
104
105 pub fn api_url(&self) -> Result<BaseUrl, BaseUrlError> {
107 BaseUrl::new(self.api.clone())
108 }
109
110 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 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 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}