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 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 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 pub fn download_url(&self) -> Result<BaseUrl, BaseUrlError> {
103 BaseUrl::new(self.url.clone())
104 }
105
106 pub fn index_url(&self) -> Result<BaseUrl, BaseUrlError> {
108 BaseUrl::new(self.index.clone())
109 }
110
111 pub fn api_url(&self) -> Result<BaseUrl, BaseUrlError> {
113 BaseUrl::new(self.api.clone())
114 }
115
116 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 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 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}