1use std::time::Duration;
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14use crate::{CRATES_IO_API, DEFAULT_TIMEOUT_SECS, USER_AGENT, sparse_index_path};
15
16#[derive(Debug, Clone)]
21pub struct HttpRegistryClient {
22 base_url: String,
23 timeout: Duration,
24 client: reqwest::blocking::Client,
25 cache_dir: Option<std::path::PathBuf>,
26}
27
28impl HttpRegistryClient {
29 pub fn new(base_url: &str) -> Self {
31 let client = reqwest::blocking::Client::builder()
32 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
33 .user_agent(USER_AGENT)
34 .build()
35 .unwrap_or_else(|_| reqwest::blocking::Client::new());
36
37 Self {
38 base_url: base_url.trim_end_matches('/').to_string(),
39 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
40 client,
41 cache_dir: None,
42 }
43 }
44
45 pub fn with_cache_dir(mut self, cache_dir: std::path::PathBuf) -> Self {
47 self.cache_dir = Some(cache_dir);
48 self
49 }
50
51 pub fn crates_io() -> Self {
53 Self::new(CRATES_IO_API)
54 }
55
56 pub fn with_timeout(mut self, timeout: Duration) -> Self {
58 self.timeout = timeout;
59 self.client = reqwest::blocking::Client::builder()
60 .timeout(timeout)
61 .user_agent(USER_AGENT)
62 .build()
63 .unwrap_or_else(|_| reqwest::blocking::Client::new());
64 self
65 }
66
67 pub fn crate_exists(&self, name: &str) -> Result<bool> {
69 let url = format!("{}/api/v1/crates/{}", self.base_url, name);
70
71 let response = self
72 .client
73 .get(&url)
74 .send()
75 .context("failed to send request to registry")?;
76
77 match response.status() {
78 reqwest::StatusCode::OK => Ok(true),
79 reqwest::StatusCode::NOT_FOUND => Ok(false),
80 status => Err(anyhow::anyhow!("unexpected status code: {}", status)),
81 }
82 }
83
84 pub fn version_exists(&self, name: &str, version: &str) -> Result<bool> {
86 let url = format!("{}/api/v1/crates/{}/{}", self.base_url, name, version);
87
88 let response = self
89 .client
90 .get(&url)
91 .send()
92 .context("failed to send request to registry")?;
93
94 match response.status() {
95 reqwest::StatusCode::OK => Ok(true),
96 reqwest::StatusCode::NOT_FOUND => Ok(false),
97 status => Err(anyhow::anyhow!("unexpected status code: {}", status)),
98 }
99 }
100
101 pub fn get_crate_info(&self, name: &str) -> Result<Option<CrateInfo>> {
103 let url = format!("{}/api/v1/crates/{}", self.base_url, name);
104
105 let response = self
106 .client
107 .get(&url)
108 .send()
109 .context("failed to send request to registry")?;
110
111 if response.status() == reqwest::StatusCode::NOT_FOUND {
112 return Ok(None);
113 }
114
115 if !response.status().is_success() {
116 return Err(anyhow::anyhow!(
117 "unexpected status code: {}",
118 response.status()
119 ));
120 }
121
122 let crate_response: CrateResponse =
123 response.json().context("failed to parse crate response")?;
124
125 Ok(Some(CrateInfo {
126 name: crate_response.crate_data.name,
127 newest_version: crate_response.crate_data.newest_version,
128 created_at: crate_response.crate_data.created_at,
129 updated_at: crate_response.crate_data.updated_at,
130 }))
131 }
132
133 fn fetch_owners_with_token(
134 &self,
135 name: &str,
136 token: Option<&str>,
137 ) -> Result<Option<OwnersResponse>> {
138 let url = format!("{}/api/v1/crates/{}/owners", self.base_url, name);
139 let mut request = self.client.get(&url);
140 if let Some(token) = token {
141 request = request.header("Authorization", token);
142 }
143
144 let response = request.send().context("failed to query owners")?;
145 match response.status() {
146 reqwest::StatusCode::OK => {
147 let owners_response: OwnersResponse =
148 response.json().context("failed to parse owners response")?;
149 Ok(Some(owners_response))
150 }
151 reqwest::StatusCode::NOT_FOUND => Ok(None),
152 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
153 Err(anyhow::anyhow!(
154 "forbidden when querying owners; token may be invalid or missing required scope"
155 ))
156 }
157 status => Err(anyhow::anyhow!(
158 "unexpected status while querying owners: {status}"
159 )),
160 }
161 }
162
163 pub fn get_owners(&self, name: &str) -> Result<Vec<Owner>> {
165 let owners_response = self
166 .fetch_owners_with_token(name, None)?
167 .unwrap_or_default();
168 Ok(owners_response
169 .users
170 .into_iter()
171 .map(|owner| Owner {
172 login: owner.login,
173 name: owner.name,
174 avatar: owner.avatar,
175 })
176 .collect())
177 }
178
179 pub fn list_owners(&self, name: &str, token: &str) -> Result<OwnersResponse> {
181 self.fetch_owners_with_token(name, Some(token))?
182 .ok_or_else(|| anyhow::anyhow!("crate not found when querying owners: {name}"))
183 }
184
185 pub fn is_owner(&self, name: &str, username: &str) -> Result<bool> {
187 let owners = self.get_owners(name)?;
188 Ok(owners.iter().any(|o| o.login == username))
189 }
190
191 pub fn is_version_visible_in_sparse_index(
193 &self,
194 index_base: &str,
195 name: &str,
196 version: &str,
197 ) -> Result<bool> {
198 let content = self.fetch_sparse_index_file(index_base, name)?;
199 Ok(shipper_sparse_index::contains_version(&content, version))
200 }
201
202 pub fn fetch_sparse_index_file(&self, index_base: &str, name: &str) -> Result<String> {
204 let index_base = index_base.trim_end_matches('/');
205 let index_path = sparse_index_path(name);
206 let url = format!("{}/{}", index_base, index_path);
207
208 let cache_file = self.cache_dir.as_ref().map(|d| d.join(&index_path));
209 let etag_file = cache_file.as_ref().map(|f| f.with_extension("etag"));
210
211 let mut request = self.client.get(&url);
212
213 if let Some(ref path) = etag_file
214 && let Ok(etag) = std::fs::read_to_string(path)
215 {
216 request = request.header(reqwest::header::IF_NONE_MATCH, etag.trim());
217 }
218
219 let response = request.send().context("index request failed")?;
220
221 match response.status() {
222 reqwest::StatusCode::OK => {
223 let etag = response
224 .headers()
225 .get(reqwest::header::ETAG)
226 .and_then(|h| h.to_str().ok())
227 .map(|s| s.to_string());
228 let content = response
229 .text()
230 .context("failed to read index response body")?;
231
232 if let Some(ref path) = cache_file {
233 if let Some(parent) = path.parent() {
234 let _ = std::fs::create_dir_all(parent);
235 }
236 let _ = std::fs::write(path, &content);
237 if let (Some(ref etag_val), Some(etag_path)) = (etag, etag_file) {
238 let _ = std::fs::write(etag_path, etag_val);
239 }
240 }
241 Ok(content)
242 }
243 reqwest::StatusCode::NOT_MODIFIED => {
244 if let Some(ref path) = cache_file {
245 std::fs::read_to_string(path).context("failed to read cached index file")
246 } else {
247 Err(anyhow::anyhow!(
248 "received 304 Not Modified but no cache file available"
249 ))
250 }
251 }
252 reqwest::StatusCode::NOT_FOUND => Err(anyhow::anyhow!("index file not found: {url}")),
253 status => Err(anyhow::anyhow!(
254 "unexpected status while fetching index: {status}"
255 )),
256 }
257 }
258
259 pub fn base_url(&self) -> &str {
261 &self.base_url
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct CrateInfo {
268 pub name: String,
270 pub newest_version: String,
272 pub created_at: String,
274 pub updated_at: String,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct OwnersApiUser {
281 pub id: Option<u64>,
283 pub login: String,
285 pub name: Option<String>,
287 pub avatar: Option<String>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Owner {
294 pub login: String,
296 pub name: Option<String>,
298 pub avatar: Option<String>,
300}
301
302#[derive(Debug, Deserialize)]
304struct CrateResponse {
305 #[serde(rename = "crate")]
306 crate_data: CrateData,
307}
308
309#[derive(Debug, Deserialize)]
311struct CrateData {
312 name: String,
313 newest_version: String,
314 created_at: String,
315 updated_at: String,
316}
317
318#[derive(Debug, Default, Serialize, Deserialize)]
320pub struct OwnersResponse {
321 pub users: Vec<OwnersApiUser>,
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::{CRATES_IO_API, is_crate_visible, is_version_visible};
328
329 #[test]
330 fn client_creation() {
331 let client = HttpRegistryClient::crates_io();
332 assert_eq!(client.base_url(), "https://crates.io");
333 }
334
335 #[test]
336 fn client_with_custom_url() {
337 let client = HttpRegistryClient::new("https://custom.registry.io/");
338 assert_eq!(client.base_url(), "https://custom.registry.io");
339 }
340
341 #[test]
342 fn client_with_timeout() {
343 let client = HttpRegistryClient::crates_io().with_timeout(Duration::from_secs(60));
344 assert_eq!(client.timeout, Duration::from_secs(60));
345 }
346
347 #[test]
348 fn crate_info_serialization() {
349 let info = CrateInfo {
350 name: "test-crate".to_string(),
351 newest_version: "1.0.0".to_string(),
352 created_at: "2024-01-01T00:00:00Z".to_string(),
353 updated_at: "2024-01-02T00:00:00Z".to_string(),
354 };
355
356 let json = serde_json::to_string(&info).expect("serialize");
357 assert!(json.contains("\"name\":\"test-crate\""));
358 }
359
360 #[test]
361 fn owner_serialization() {
362 let owner = Owner {
363 login: "testuser".to_string(),
364 name: Some("Test User".to_string()),
365 avatar: Some("https://example.com/avatar.png".to_string()),
366 };
367
368 let json = serde_json::to_string(&owner).expect("serialize");
369 assert!(json.contains("\"login\":\"testuser\""));
370 }
371
372 #[derive(Debug, Deserialize)]
373 struct VersionsResponse {
374 versions: Vec<Version>,
375 }
376
377 #[derive(Debug, Deserialize)]
378 struct Version {
379 num: String,
380 }
381
382 #[test]
383 fn versions_response_parsing() {
384 let json = r#"{"versions":[{"num":"1.0.0"},{"num":"0.9.0"}]}"#;
385 let response: VersionsResponse = serde_json::from_str(json).expect("parse");
386 assert_eq!(response.versions.len(), 2);
387 assert_eq!(response.versions[0].num, "1.0.0");
388 }
389
390 #[test]
391 fn crate_response_parsing() {
392 let json = r#"{
393 "crate": {
394 "name": "serde",
395 "newest_version": "1.0.190",
396 "created_at": "2017-01-01T00:00:00Z",
397 "updated_at": "2024-01-01T00:00:00Z"
398 }
399 }"#;
400 let response: CrateResponse = serde_json::from_str(json).expect("parse");
401 assert_eq!(response.crate_data.name, "serde");
402 assert_eq!(response.crate_data.newest_version, "1.0.190");
403 }
404
405 #[test]
406 fn owners_response_parsing() {
407 let json = r#"{
408 "users": [
409 {"login": "user1", "name": "User One", "avatar": null},
410 {"login": "user2", "name": null, "avatar": "https://example.com/avatar.png"}
411 ]
412 }"#;
413 let response: OwnersResponse = serde_json::from_str(json).expect("parse");
414 assert_eq!(response.users.len(), 2);
415 assert_eq!(response.users[0].login, "user1");
416 assert_eq!(
417 response.users[1].avatar,
418 Some("https://example.com/avatar.png".to_string())
419 );
420 }
421
422 #[test]
423 fn user_agent_includes_version() {
424 assert!(USER_AGENT.starts_with("shipper/"));
425 assert!(USER_AGENT.contains(env!("CARGO_PKG_VERSION")));
426 }
427
428 #[test]
429 fn test_sparse_index_caching() {
430 use tiny_http::{Header, Response, Server, StatusCode};
431
432 let server = Server::http("127.0.0.1:0").expect("server");
433 let base_url = format!("http://{}", server.server_addr());
434
435 let td = tempfile::tempdir().expect("tempdir");
436 let cache_dir = td.path().to_path_buf();
437
438 let handle = std::thread::spawn({
439 let _base_url = base_url.clone();
440 move || {
441 let req = server.recv().expect("request 1");
443 assert_eq!(req.url(), "/de/mo/demo");
444 let resp = Response::from_string("{\"vers\":\"0.1.0\"}")
445 .with_status_code(StatusCode(200))
446 .with_header(Header::from_bytes("ETag", "W/\"123\"").unwrap());
447 req.respond(resp).expect("respond 1");
448
449 let req = server.recv().expect("request 2");
451 assert_eq!(req.url(), "/de/mo/demo");
452 let etag_header = req
453 .headers()
454 .iter()
455 .find(|h| h.field.equiv("If-None-Match"))
456 .expect("missing If-None-Match");
457 assert_eq!(etag_header.value.as_str(), "W/\"123\"");
458
459 let resp = Response::from_string("").with_status_code(StatusCode(304));
460 req.respond(resp).expect("respond 2");
461 }
462 });
463
464 let client = HttpRegistryClient::new(&base_url).with_cache_dir(cache_dir);
465
466 let content1 = client
468 .fetch_sparse_index_file(&base_url, "demo")
469 .expect("fetch 1");
470 assert_eq!(content1, "{\"vers\":\"0.1.0\"}");
471
472 let content2 = client
474 .fetch_sparse_index_file(&base_url, "demo")
475 .expect("fetch 2");
476 assert_eq!(content2, "{\"vers\":\"0.1.0\"}");
477
478 handle.join().expect("join");
479 }
480
481 fn mock_server() -> (tiny_http::Server, String) {
484 let server = tiny_http::Server::http("127.0.0.1:0").expect("mock server");
485 let base = format!("http://{}", server.server_addr());
486 (server, base)
487 }
488
489 fn respond(req: tiny_http::Request, status: u16, body: &str) {
490 let resp =
491 tiny_http::Response::from_string(body).with_status_code(tiny_http::StatusCode(status));
492 req.respond(resp).expect("respond");
493 }
494
495 #[test]
498 fn url_multiple_trailing_slashes_stripped() {
499 let client = HttpRegistryClient::new("https://example.com///");
500 assert_eq!(client.base_url(), "https://example.com");
501 }
502
503 #[test]
504 fn url_no_trailing_slash_unchanged() {
505 let client = HttpRegistryClient::new("https://example.com");
506 assert_eq!(client.base_url(), "https://example.com");
507 }
508
509 #[test]
510 fn default_timeout_is_30s() {
511 let client = HttpRegistryClient::crates_io();
512 assert_eq!(client.timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
513 }
514
515 #[test]
516 fn with_cache_dir_sets_cache() {
517 let td = tempfile::tempdir().expect("tempdir");
518 let client = HttpRegistryClient::crates_io().with_cache_dir(td.path().to_path_buf());
519 assert_eq!(client.cache_dir, Some(td.path().to_path_buf()));
520 }
521
522 #[test]
525 fn crate_exists_returns_true_on_200() {
526 let (server, base) = mock_server();
527 let handle = std::thread::spawn(move || {
528 let req = server.recv().expect("request");
529 assert_eq!(req.url(), "/api/v1/crates/serde");
530 respond(req, 200, r#"{"crate":{}}"#);
531 });
532 let client = HttpRegistryClient::new(&base);
533 assert!(client.crate_exists("serde").expect("ok"));
534 handle.join().expect("join");
535 }
536
537 #[test]
538 fn crate_exists_returns_false_on_404() {
539 let (server, base) = mock_server();
540 let handle = std::thread::spawn(move || {
541 respond(server.recv().expect("req"), 404, "");
542 });
543 let client = HttpRegistryClient::new(&base);
544 assert!(!client.crate_exists("nonexistent").expect("ok"));
545 handle.join().expect("join");
546 }
547
548 #[test]
549 fn crate_exists_returns_error_on_500() {
550 let (server, base) = mock_server();
551 let handle = std::thread::spawn(move || {
552 respond(server.recv().expect("req"), 500, "");
553 });
554 let client = HttpRegistryClient::new(&base);
555 let err = client.crate_exists("bad").unwrap_err();
556 assert!(err.to_string().contains("unexpected status code"));
557 handle.join().expect("join");
558 }
559
560 #[test]
563 fn version_exists_returns_true_on_200() {
564 let (server, base) = mock_server();
565 let handle = std::thread::spawn(move || {
566 let req = server.recv().expect("req");
567 assert_eq!(req.url(), "/api/v1/crates/serde/1.0.0");
568 respond(req, 200, "{}");
569 });
570 let client = HttpRegistryClient::new(&base);
571 assert!(client.version_exists("serde", "1.0.0").expect("ok"));
572 handle.join().expect("join");
573 }
574
575 #[test]
576 fn version_exists_returns_false_on_404() {
577 let (server, base) = mock_server();
578 let handle = std::thread::spawn(move || {
579 respond(server.recv().expect("req"), 404, "");
580 });
581 let client = HttpRegistryClient::new(&base);
582 assert!(!client.version_exists("serde", "99.0.0").expect("ok"));
583 handle.join().expect("join");
584 }
585
586 #[test]
587 fn version_exists_returns_error_on_503() {
588 let (server, base) = mock_server();
589 let handle = std::thread::spawn(move || {
590 respond(server.recv().expect("req"), 503, "");
591 });
592 let client = HttpRegistryClient::new(&base);
593 let err = client.version_exists("x", "0.1.0").unwrap_err();
594 assert!(err.to_string().contains("unexpected status code"));
595 handle.join().expect("join");
596 }
597
598 #[test]
601 fn get_crate_info_returns_some_on_200() {
602 let (server, base) = mock_server();
603 let body = r#"{
604 "crate": {
605 "name": "demo",
606 "newest_version": "2.0.0",
607 "created_at": "2023-01-01T00:00:00Z",
608 "updated_at": "2024-06-01T00:00:00Z"
609 }
610 }"#;
611 let handle = std::thread::spawn(move || {
612 respond(server.recv().expect("req"), 200, body);
613 });
614 let client = HttpRegistryClient::new(&base);
615 let info = client.get_crate_info("demo").expect("ok").expect("Some");
616 assert_eq!(info.name, "demo");
617 assert_eq!(info.newest_version, "2.0.0");
618 assert_eq!(info.created_at, "2023-01-01T00:00:00Z");
619 assert_eq!(info.updated_at, "2024-06-01T00:00:00Z");
620 handle.join().expect("join");
621 }
622
623 #[test]
624 fn get_crate_info_returns_none_on_404() {
625 let (server, base) = mock_server();
626 let handle = std::thread::spawn(move || {
627 respond(server.recv().expect("req"), 404, "");
628 });
629 let client = HttpRegistryClient::new(&base);
630 assert!(client.get_crate_info("nope").expect("ok").is_none());
631 handle.join().expect("join");
632 }
633
634 #[test]
635 fn get_crate_info_returns_error_on_500() {
636 let (server, base) = mock_server();
637 let handle = std::thread::spawn(move || {
638 respond(server.recv().expect("req"), 500, "");
639 });
640 let client = HttpRegistryClient::new(&base);
641 let err = client.get_crate_info("bad").unwrap_err();
642 assert!(err.to_string().contains("unexpected status code"));
643 handle.join().expect("join");
644 }
645
646 #[test]
647 fn get_crate_info_returns_error_on_invalid_json() {
648 let (server, base) = mock_server();
649 let handle = std::thread::spawn(move || {
650 respond(server.recv().expect("req"), 200, "NOT JSON");
651 });
652 let client = HttpRegistryClient::new(&base);
653 let err = client.get_crate_info("bad").unwrap_err();
654 assert!(err.to_string().contains("failed to parse crate response"));
655 handle.join().expect("join");
656 }
657
658 #[test]
661 fn get_owners_returns_owners_on_200() {
662 let (server, base) = mock_server();
663 let body = r#"{"users":[{"login":"alice","name":"Alice","avatar":null}]}"#;
664 let handle = std::thread::spawn(move || {
665 let req = server.recv().expect("req");
666 assert_eq!(req.url(), "/api/v1/crates/demo/owners");
667 respond(req, 200, body);
668 });
669 let client = HttpRegistryClient::new(&base);
670 let owners = client.get_owners("demo").expect("ok");
671 assert_eq!(owners.len(), 1);
672 assert_eq!(owners[0].login, "alice");
673 handle.join().expect("join");
674 }
675
676 #[test]
677 fn get_owners_returns_empty_on_404() {
678 let (server, base) = mock_server();
679 let handle = std::thread::spawn(move || {
680 respond(server.recv().expect("req"), 404, "");
681 });
682 let client = HttpRegistryClient::new(&base);
683 let owners = client.get_owners("nonexistent").expect("ok");
684 assert!(owners.is_empty());
685 handle.join().expect("join");
686 }
687
688 #[test]
689 fn list_owners_sends_auth_header() {
690 let (server, base) = mock_server();
691 let body = r#"{"users":[{"login":"bob","name":null,"avatar":null}]}"#;
692 let handle = std::thread::spawn(move || {
693 let req = server.recv().expect("req");
694 let auth = req
695 .headers()
696 .iter()
697 .find(|h| h.field.equiv("Authorization"))
698 .expect("missing Authorization");
699 assert_eq!(auth.value.as_str(), "my-token");
700 respond(req, 200, body);
701 });
702 let client = HttpRegistryClient::new(&base);
703 let resp = client.list_owners("demo", "my-token").expect("ok");
704 assert_eq!(resp.users.len(), 1);
705 assert_eq!(resp.users[0].login, "bob");
706 handle.join().expect("join");
707 }
708
709 #[test]
710 fn list_owners_returns_error_on_403() {
711 let (server, base) = mock_server();
712 let handle = std::thread::spawn(move || {
713 respond(server.recv().expect("req"), 403, "");
714 });
715 let client = HttpRegistryClient::new(&base);
716 let err = client.list_owners("demo", "bad-token").unwrap_err();
717 assert!(err.to_string().contains("forbidden"));
718 handle.join().expect("join");
719 }
720
721 #[test]
722 fn list_owners_returns_error_on_401() {
723 let (server, base) = mock_server();
724 let handle = std::thread::spawn(move || {
725 respond(server.recv().expect("req"), 401, "");
726 });
727 let client = HttpRegistryClient::new(&base);
728 let err = client.list_owners("demo", "expired").unwrap_err();
729 assert!(err.to_string().contains("forbidden"));
730 handle.join().expect("join");
731 }
732
733 #[test]
734 fn list_owners_returns_error_on_crate_not_found() {
735 let (server, base) = mock_server();
736 let handle = std::thread::spawn(move || {
737 respond(server.recv().expect("req"), 404, "");
738 });
739 let client = HttpRegistryClient::new(&base);
740 let err = client.list_owners("nope", "token").unwrap_err();
741 assert!(err.to_string().contains("crate not found"));
742 handle.join().expect("join");
743 }
744
745 #[test]
746 fn list_owners_returns_error_on_unexpected_status() {
747 let (server, base) = mock_server();
748 let handle = std::thread::spawn(move || {
749 respond(server.recv().expect("req"), 502, "");
750 });
751 let client = HttpRegistryClient::new(&base);
752 let err = client.list_owners("demo", "tok").unwrap_err();
753 assert!(err.to_string().contains("unexpected status"));
754 handle.join().expect("join");
755 }
756
757 #[test]
758 fn is_owner_returns_true_for_matching_user() {
759 let (server, base) = mock_server();
760 let body = r#"{"users":[{"login":"carol","name":null,"avatar":null}]}"#;
761 let handle = std::thread::spawn(move || {
762 respond(server.recv().expect("req"), 200, body);
763 });
764 let client = HttpRegistryClient::new(&base);
765 assert!(client.is_owner("demo", "carol").expect("ok"));
766 handle.join().expect("join");
767 }
768
769 #[test]
770 fn is_owner_returns_false_for_non_matching_user() {
771 let (server, base) = mock_server();
772 let body = r#"{"users":[{"login":"carol","name":null,"avatar":null}]}"#;
773 let handle = std::thread::spawn(move || {
774 respond(server.recv().expect("req"), 200, body);
775 });
776 let client = HttpRegistryClient::new(&base);
777 assert!(!client.is_owner("demo", "dave").expect("ok"));
778 handle.join().expect("join");
779 }
780
781 #[test]
784 fn fetch_sparse_index_not_found() {
785 let (server, base) = mock_server();
786 let handle = std::thread::spawn(move || {
787 respond(server.recv().expect("req"), 404, "");
788 });
789 let client = HttpRegistryClient::new(&base);
790 let err = client.fetch_sparse_index_file(&base, "xy").unwrap_err();
791 assert!(err.to_string().contains("index file not found"));
792 handle.join().expect("join");
793 }
794
795 #[test]
796 fn fetch_sparse_index_unexpected_status() {
797 let (server, base) = mock_server();
798 let handle = std::thread::spawn(move || {
799 respond(server.recv().expect("req"), 502, "");
800 });
801 let client = HttpRegistryClient::new(&base);
802 let err = client.fetch_sparse_index_file(&base, "xy").unwrap_err();
803 assert!(err.to_string().contains("unexpected status"));
804 handle.join().expect("join");
805 }
806
807 #[test]
808 fn fetch_sparse_index_304_without_cache_errors() {
809 let (server, base) = mock_server();
810 let handle = std::thread::spawn(move || {
811 respond(server.recv().expect("req"), 304, "");
812 });
813 let client = HttpRegistryClient::new(&base);
815 let err = client.fetch_sparse_index_file(&base, "ab").unwrap_err();
816 assert!(err.to_string().contains("304 Not Modified"));
817 handle.join().expect("join");
818 }
819
820 #[test]
821 fn is_version_visible_in_sparse_index_with_mock() {
822 let (server, base) = mock_server();
823 let body = "{\"name\":\"demo\",\"vers\":\"0.1.0\",\"deps\":[]}\n\
824 {\"name\":\"demo\",\"vers\":\"0.2.0\",\"deps\":[]}";
825 let handle = std::thread::spawn(move || {
826 respond(server.recv().expect("req"), 200, body);
827 });
828 let client = HttpRegistryClient::new(&base);
829 assert!(
830 client
831 .is_version_visible_in_sparse_index(&base, "demo", "0.1.0")
832 .expect("ok")
833 );
834 handle.join().expect("join");
835 }
836
837 #[test]
838 fn is_version_visible_in_sparse_index_returns_false_for_missing_version() {
839 let (server, base) = mock_server();
840 let body = "{\"name\":\"demo\",\"vers\":\"0.1.0\",\"deps\":[]}";
841 let handle = std::thread::spawn(move || {
842 respond(server.recv().expect("req"), 200, body);
843 });
844 let client = HttpRegistryClient::new(&base);
845 assert!(
846 !client
847 .is_version_visible_in_sparse_index(&base, "demo", "9.9.9")
848 .expect("ok")
849 );
850 handle.join().expect("join");
851 }
852
853 #[test]
856 fn is_version_visible_delegates_to_client() {
857 let (server, base) = mock_server();
858 let handle = std::thread::spawn(move || {
859 respond(server.recv().expect("req"), 200, "{}");
860 });
861 assert!(is_version_visible(&base, "serde", "1.0.0").expect("ok"));
862 handle.join().expect("join");
863 }
864
865 #[test]
866 fn is_crate_visible_delegates_to_client() {
867 let (server, base) = mock_server();
868 let handle = std::thread::spawn(move || {
869 respond(server.recv().expect("req"), 200, "{}");
870 });
871 assert!(is_crate_visible(&base, "serde").expect("ok"));
872 handle.join().expect("join");
873 }
874
875 #[test]
878 fn timeout_triggers_on_slow_server() {
879 let (server, base) = mock_server();
880 let handle = std::thread::spawn(move || {
881 let req = server.recv().expect("req");
882 std::thread::sleep(Duration::from_secs(3));
884 let _ = req.respond(tiny_http::Response::from_string("{}"));
885 });
886 let client = HttpRegistryClient::new(&base).with_timeout(Duration::from_millis(200));
887 let result = client.crate_exists("slow");
888 assert!(result.is_err());
889 handle.join().expect("join");
890 }
891
892 #[test]
895 fn crate_exists_handles_connection_refused() {
896 let client = HttpRegistryClient::new("http://127.0.0.1:1");
898 let result = client.crate_exists("anything");
899 assert!(result.is_err());
900 assert!(
901 result
902 .unwrap_err()
903 .to_string()
904 .contains("failed to send request")
905 );
906 }
907
908 #[test]
911 fn crate_info_roundtrip() {
912 let info = CrateInfo {
913 name: "foo".to_string(),
914 newest_version: "3.2.1".to_string(),
915 created_at: "2020-01-01T00:00:00Z".to_string(),
916 updated_at: "2025-06-01T00:00:00Z".to_string(),
917 };
918 let json = serde_json::to_string(&info).expect("ser");
919 let back: CrateInfo = serde_json::from_str(&json).expect("de");
920 assert_eq!(back.name, "foo");
921 assert_eq!(back.newest_version, "3.2.1");
922 }
923
924 #[test]
925 fn owner_roundtrip_with_optional_fields() {
926 let owner = Owner {
927 login: "user".to_string(),
928 name: None,
929 avatar: None,
930 };
931 let json = serde_json::to_string(&owner).expect("ser");
932 let back: Owner = serde_json::from_str(&json).expect("de");
933 assert_eq!(back.login, "user");
934 assert!(back.name.is_none());
935 assert!(back.avatar.is_none());
936 }
937
938 #[test]
939 fn owners_api_user_optional_id() {
940 let json = r#"{"login":"alice","name":null,"avatar":null}"#;
941 let user: OwnersApiUser = serde_json::from_str(json).expect("de");
942 assert!(user.id.is_none());
943 assert_eq!(user.login, "alice");
944 }
945
946 #[test]
947 fn owners_api_user_with_id() {
948 let json = r#"{"id":42,"login":"bob","name":"Bob","avatar":"http://a.png"}"#;
949 let user: OwnersApiUser = serde_json::from_str(json).expect("de");
950 assert_eq!(user.id, Some(42));
951 assert_eq!(user.login, "bob");
952 }
953
954 #[test]
955 fn owners_response_default_is_empty() {
956 let resp = OwnersResponse::default();
957 assert!(resp.users.is_empty());
958 }
959
960 #[test]
963 fn sparse_index_path_short_crate() {
964 assert_eq!(sparse_index_path("a"), "1/a");
965 assert_eq!(sparse_index_path("ab"), "2/ab");
966 }
967
968 #[test]
969 fn sparse_index_path_three_char() {
970 assert_eq!(sparse_index_path("abc"), "3/a/abc");
971 }
972
973 #[test]
974 fn sparse_index_path_four_plus_char() {
975 assert_eq!(sparse_index_path("demo"), "de/mo/demo");
976 assert_eq!(sparse_index_path("serde"), "se/rd/serde");
977 }
978
979 #[test]
982 fn crates_io_api_constant() {
983 assert_eq!(CRATES_IO_API, "https://crates.io");
984 }
985
986 #[test]
987 fn default_timeout_constant() {
988 assert_eq!(DEFAULT_TIMEOUT_SECS, 30);
989 }
990
991 #[test]
994 fn snapshot_crate_info() {
995 let info = CrateInfo {
996 name: "my-crate".to_string(),
997 newest_version: "1.2.3".to_string(),
998 created_at: "2024-01-15T10:30:00Z".to_string(),
999 updated_at: "2024-06-20T14:00:00Z".to_string(),
1000 };
1001 insta::assert_yaml_snapshot!("crate_info", info);
1002 }
1003
1004 #[test]
1005 fn snapshot_owner_all_fields() {
1006 let owner = Owner {
1007 login: "alice".to_string(),
1008 name: Some("Alice Smith".to_string()),
1009 avatar: Some("https://example.com/alice.png".to_string()),
1010 };
1011 insta::assert_yaml_snapshot!("owner_all_fields", owner);
1012 }
1013
1014 #[test]
1015 fn snapshot_owner_minimal() {
1016 let owner = Owner {
1017 login: "bot-user".to_string(),
1018 name: None,
1019 avatar: None,
1020 };
1021 insta::assert_yaml_snapshot!("owner_minimal", owner);
1022 }
1023
1024 #[test]
1025 fn snapshot_owners_api_user_with_id() {
1026 let user = OwnersApiUser {
1027 id: Some(42),
1028 login: "bob".to_string(),
1029 name: Some("Bob Jones".to_string()),
1030 avatar: Some("https://example.com/bob.png".to_string()),
1031 };
1032 insta::assert_yaml_snapshot!("owners_api_user_with_id", user);
1033 }
1034
1035 #[test]
1036 fn snapshot_owners_api_user_without_id() {
1037 let user = OwnersApiUser {
1038 id: None,
1039 login: "team:core".to_string(),
1040 name: None,
1041 avatar: None,
1042 };
1043 insta::assert_yaml_snapshot!("owners_api_user_without_id", user);
1044 }
1045
1046 #[test]
1047 fn snapshot_owners_response_multiple() {
1048 let resp = OwnersResponse {
1049 users: vec![
1050 OwnersApiUser {
1051 id: Some(1),
1052 login: "alice".to_string(),
1053 name: Some("Alice".to_string()),
1054 avatar: None,
1055 },
1056 OwnersApiUser {
1057 id: Some(2),
1058 login: "bob".to_string(),
1059 name: None,
1060 avatar: Some("https://example.com/bob.png".to_string()),
1061 },
1062 ],
1063 };
1064 insta::assert_yaml_snapshot!("owners_response_multiple", resp);
1065 }
1066
1067 #[test]
1068 fn snapshot_owners_response_empty() {
1069 let resp = OwnersResponse::default();
1070 insta::assert_yaml_snapshot!("owners_response_empty", resp);
1071 }
1072
1073 #[test]
1074 fn snapshot_url_construction_crate() {
1075 let client = HttpRegistryClient::new("https://crates.io");
1076 let url = format!("{}/api/v1/crates/{}", client.base_url(), "my-crate");
1077 insta::assert_snapshot!("url_crate", url);
1078 }
1079
1080 #[test]
1081 fn snapshot_url_construction_version() {
1082 let client = HttpRegistryClient::new("https://crates.io");
1083 let url = format!(
1084 "{}/api/v1/crates/{}/{}",
1085 client.base_url(),
1086 "my-crate",
1087 "1.2.3"
1088 );
1089 insta::assert_snapshot!("url_version", url);
1090 }
1091
1092 #[test]
1093 fn snapshot_url_construction_owners() {
1094 let client = HttpRegistryClient::new("https://crates.io");
1095 let url = format!("{}/api/v1/crates/{}/owners", client.base_url(), "my-crate");
1096 insta::assert_snapshot!("url_owners", url);
1097 }
1098
1099 #[test]
1100 fn snapshot_url_construction_custom_registry() {
1101 let client = HttpRegistryClient::new("https://my-registry.example.com/");
1102 let url = format!("{}/api/v1/crates/{}", client.base_url(), "private-lib");
1103 insta::assert_snapshot!("url_custom_registry", url);
1104 }
1105
1106 #[test]
1107 fn snapshot_sparse_index_paths() {
1108 insta::assert_snapshot!("sparse_path_1char", sparse_index_path("a"));
1109 insta::assert_snapshot!("sparse_path_2char", sparse_index_path("ab"));
1110 insta::assert_snapshot!("sparse_path_3char", sparse_index_path("abc"));
1111 insta::assert_snapshot!("sparse_path_4char", sparse_index_path("demo"));
1112 insta::assert_snapshot!("sparse_path_long", sparse_index_path("serde_json"));
1113 }
1114
1115 #[test]
1116 fn snapshot_error_connection_refused() {
1117 let client = HttpRegistryClient::new("http://127.0.0.1:1");
1118 let err = client.crate_exists("anything").unwrap_err();
1119 insta::assert_snapshot!("error_connection_refused", err.to_string());
1120 }
1121
1122 #[test]
1123 fn snapshot_error_unexpected_status_crate_exists() {
1124 let (server, base) = mock_server();
1125 let handle = std::thread::spawn(move || {
1126 respond(server.recv().expect("req"), 500, "");
1127 });
1128 let client = HttpRegistryClient::new(&base);
1129 let err = client.crate_exists("bad").unwrap_err();
1130 insta::assert_snapshot!("error_unexpected_status", err.to_string());
1131 handle.join().expect("join");
1132 }
1133
1134 #[test]
1135 fn snapshot_error_owners_forbidden() {
1136 let (server, base) = mock_server();
1137 let handle = std::thread::spawn(move || {
1138 respond(server.recv().expect("req"), 403, "");
1139 });
1140 let client = HttpRegistryClient::new(&base);
1141 let err = client.list_owners("demo", "bad-token").unwrap_err();
1142 insta::assert_snapshot!("error_owners_forbidden", err.to_string());
1143 handle.join().expect("join");
1144 }
1145
1146 #[test]
1147 fn snapshot_error_owners_not_found() {
1148 let (server, base) = mock_server();
1149 let handle = std::thread::spawn(move || {
1150 respond(server.recv().expect("req"), 404, "");
1151 });
1152 let client = HttpRegistryClient::new(&base);
1153 let err = client.list_owners("nope", "token").unwrap_err();
1154 insta::assert_snapshot!("error_owners_not_found", err.to_string());
1155 handle.join().expect("join");
1156 }
1157
1158 mod proptests {
1161 use super::*;
1162 use proptest::prelude::*;
1163
1164 fn crate_name_strategy() -> impl Strategy<Value = String> {
1166 "[a-zA-Z][a-zA-Z0-9_-]{0,63}".prop_filter("non-empty", |s| !s.is_empty())
1167 }
1168
1169 fn version_strategy() -> impl Strategy<Value = String> {
1171 (
1172 0u32..100,
1173 0u32..100,
1174 0u32..100,
1175 proptest::option::of("[a-z]{1,8}"),
1176 )
1177 .prop_map(|(major, minor, patch, pre)| match pre {
1178 Some(tag) => format!("{major}.{minor}.{patch}-{tag}"),
1179 None => format!("{major}.{minor}.{patch}"),
1180 })
1181 }
1182
1183 proptest! {
1184 #[test]
1185 fn url_normalization_strips_trailing_slashes(
1186 base in "[a-z]{3,10}://[a-z]{3,12}\\.[a-z]{2,4}",
1187 slashes in "/{0,10}",
1188 ) {
1189 let input = format!("{base}{slashes}");
1190 let client = HttpRegistryClient::new(&input);
1191 let url = client.base_url();
1192 prop_assert!(!url.ends_with('/'), "URL still has trailing slash: {url}");
1193 }
1194
1195 #[test]
1196 fn sparse_index_path_is_deterministic(name in crate_name_strategy()) {
1197 let a = sparse_index_path(&name);
1198 let b = sparse_index_path(&name);
1199 prop_assert_eq!(&a, &b, "sparse_index_path not deterministic for {}", name);
1200 }
1201
1202 #[test]
1203 fn sparse_index_path_is_lowercase(name in crate_name_strategy()) {
1204 let path = sparse_index_path(&name);
1205 let path_lower = path.to_ascii_lowercase();
1206 prop_assert_eq!(path, path_lower,
1207 "sparse_index_path should be all lowercase for {}", name);
1208 }
1209
1210 #[test]
1211 fn crate_info_roundtrip_prop(
1212 name in "[a-z_-]{1,30}",
1213 version in version_strategy(),
1214 created in "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z",
1215 updated in "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z",
1216 ) {
1217 let info = CrateInfo {
1218 name: name.clone(),
1219 newest_version: version.clone(),
1220 created_at: created.clone(),
1221 updated_at: updated.clone(),
1222 };
1223 let json = serde_json::to_string(&info).expect("serialize");
1224 let back: CrateInfo = serde_json::from_str(&json).expect("deserialize");
1225 prop_assert_eq!(&back.name, &name);
1226 prop_assert_eq!(&back.newest_version, &version);
1227 prop_assert_eq!(&back.created_at, &created);
1228 prop_assert_eq!(&back.updated_at, &updated);
1229 }
1230
1231 #[test]
1232 fn version_string_in_url_construction(
1233 version in version_strategy(),
1234 ) {
1235 let client = HttpRegistryClient::new("https://example.com");
1236 let expected = format!("https://example.com/api/v1/crates/test-crate/{version}");
1237 let url = format!("{}/api/v1/crates/{}/{}", client.base_url(), "test-crate", version);
1238 prop_assert_eq!(url, expected);
1239 }
1240
1241 #[test]
1242 fn owners_response_roundtrip_prop(
1243 logins in prop::collection::vec("[a-z]{1,20}", 0..5),
1244 ) {
1245 let resp = OwnersResponse {
1246 users: logins.iter().map(|login| OwnersApiUser {
1247 id: None,
1248 login: login.clone(),
1249 name: None,
1250 avatar: None,
1251 }).collect(),
1252 };
1253 let json = serde_json::to_string(&resp).expect("serialize");
1254 let back: OwnersResponse = serde_json::from_str(&json).expect("deserialize");
1255 prop_assert_eq!(back.users.len(), resp.users.len());
1256 for (a, b) in resp.users.iter().zip(back.users.iter()) {
1257 prop_assert_eq!(&a.login, &b.login);
1258 }
1259 }
1260 }
1261 }
1262}