1use sui_compat::narinfo::{NarInfo, NarInfoError};
8use sui_compat::store_path::StorePath;
9
10use crate::http::{HttpClient, HttpError, ReqwestHttpClient};
11use crate::traits::{PathInfo, Store, StoreError, StoreResult};
12
13#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum BinaryCacheError {
17 #[error("http client error: {0}")]
19 HttpClient(#[from] HttpError),
20 #[error("unexpected HTTP status {status} for {url}")]
22 UnexpectedStatus {
23 status: u16,
25 url: String,
27 },
28 #[error("narinfo parse error: {0}")]
30 NarInfoParse(#[from] NarInfoError),
31}
32
33impl From<BinaryCacheError> for StoreError {
34 fn from(e: BinaryCacheError) -> Self {
35 match &e {
36 BinaryCacheError::HttpClient(_) | BinaryCacheError::UnexpectedStatus { .. } => {
37 StoreError::Http(e.to_string())
38 }
39 BinaryCacheError::NarInfoParse(_) => StoreError::NarInfo(e.to_string()),
40 }
41 }
42}
43
44pub struct BinaryCacheStore {
46 client: Box<dyn HttpClient>,
47 base_url: String,
49 trusted_keys: Vec<String>,
51 auth_header: Option<(String, String)>,
53}
54
55pub struct BinaryCacheStoreBuilder {
57 base_url: String,
58 trusted_keys: Vec<String>,
59 client: Option<Box<dyn HttpClient>>,
60 auth_header: Option<(String, String)>,
61}
62
63impl BinaryCacheStoreBuilder {
64 #[must_use]
66 pub fn trusted_keys(mut self, keys: Vec<String>) -> Self {
67 self.trusted_keys = keys;
68 self
69 }
70
71 #[must_use]
73 pub fn http_client(mut self, client: Box<dyn HttpClient>) -> Self {
74 self.client = Some(client);
75 self
76 }
77
78 #[must_use]
80 pub fn auth_header(mut self, scheme: &str, credentials: &str) -> Self {
81 self.auth_header = Some((scheme.to_string(), credentials.to_string()));
82 self
83 }
84
85 #[must_use]
87 pub fn build(self) -> BinaryCacheStore {
88 BinaryCacheStore {
89 client: self.client.unwrap_or_else(|| Box::new(ReqwestHttpClient::new())),
90 base_url: self.base_url,
91 trusted_keys: self.trusted_keys,
92 auth_header: self.auth_header,
93 }
94 }
95}
96
97impl BinaryCacheStore {
98 #[must_use]
100 pub fn builder(base_url: &str) -> BinaryCacheStoreBuilder {
101 BinaryCacheStoreBuilder {
102 base_url: base_url.trim_end_matches('/').to_string(),
103 trusted_keys: Vec::new(),
104 client: None,
105 auth_header: None,
106 }
107 }
108
109 #[must_use]
111 pub fn new(base_url: &str, trusted_keys: Vec<String>) -> Self {
112 Self::builder(base_url).trusted_keys(trusted_keys).build()
113 }
114
115 #[must_use]
117 pub fn with_http_client(
118 base_url: &str,
119 trusted_keys: Vec<String>,
120 client: Box<dyn HttpClient>,
121 ) -> Self {
122 Self::builder(base_url)
123 .trusted_keys(trusted_keys)
124 .http_client(client)
125 .build()
126 }
127
128 fn request_headers(&self, extra: &[(&str, &str)]) -> Vec<(String, String)> {
130 let mut headers: Vec<(String, String)> = extra
131 .iter()
132 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
133 .collect();
134 if let Some((scheme, creds)) = &self.auth_header {
135 headers.push(("Authorization".to_string(), format!("{scheme} {creds}")));
136 }
137 headers
138 }
139
140 pub async fn fetch_narinfo(&self, hash: &str) -> StoreResult<Option<NarInfo>> {
142 let url = format!("{}/{hash}.narinfo", self.base_url);
143 let headers = self.request_headers(&[("Accept", "text/x-nix-narinfo")]);
144 let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
145
146 let response = self
147 .client
148 .get(&url, &header_refs)
149 .await
150 .map_err(BinaryCacheError::from)?;
151
152 if response.status == 404 {
153 return Ok(None);
154 }
155
156 if !response.is_success() {
157 return Err(BinaryCacheError::UnexpectedStatus {
158 status: response.status,
159 url,
160 }
161 .into());
162 }
163
164 let info = NarInfo::parse(&response.body).map_err(BinaryCacheError::from)?;
165
166 Ok(Some(info))
167 }
168
169 #[must_use]
171 pub fn base_url(&self) -> &str {
172 &self.base_url
173 }
174
175 #[must_use]
177 pub fn trusted_keys(&self) -> &[String] {
178 &self.trusted_keys
179 }
180
181 #[must_use]
183 pub fn auth_header(&self) -> Option<(&str, &str)> {
184 self.auth_header.as_ref().map(|(s, c)| (s.as_str(), c.as_str()))
185 }
186
187 pub async fn fetch_nar(&self, url_path: &str) -> StoreResult<Vec<u8>> {
189 let url = format!("{}/{url_path}", self.base_url);
190
191 self.client
192 .get_bytes(&url)
193 .await
194 .map_err(BinaryCacheError::from)
195 .map_err(StoreError::from)
196 }
197
198 #[cfg(test)]
202 fn narinfo_to_path_info(info: &NarInfo) -> PathInfo {
203 PathInfo::from(info)
204 }
205
206 fn store_path_hash(path: &StorePath) -> String {
208 let basename = path.to_basename();
209 basename[..32.min(basename.len())].to_string()
210 }
211
212 pub fn verify_narinfo_signatures(
221 narinfo: &NarInfo,
222 trusted_keys: &[String],
223 ) -> StoreResult<bool> {
224 use sui_compat::signature::{StorePathSignature, compute_fingerprint};
225 use sui_compat::hash::base64_decode;
226
227 if trusted_keys.is_empty() {
228 return Ok(false);
229 }
230
231 let mut sorted_refs: Vec<String> = narinfo.references.clone();
233 sorted_refs.sort();
234
235 let fingerprint = compute_fingerprint(
236 &narinfo.store_path,
237 &narinfo.nar_hash,
238 narinfo.nar_size,
239 &sorted_refs,
240 );
241
242 let mut key_map: std::collections::HashMap<String, Vec<u8>> =
244 std::collections::HashMap::new();
245 for key_str in trusted_keys {
246 if let Some((name, b64_pubkey)) = key_str.split_once(':')
247 && let Ok(pubkey_bytes) = base64_decode(b64_pubkey) {
248 key_map.insert(name.to_string(), pubkey_bytes);
249 }
250 }
251
252 for sig_str in &narinfo.signatures {
254 let parsed = match StorePathSignature::parse(sig_str) {
255 Ok(s) => s,
256 Err(_) => continue,
257 };
258
259 if let Some(pubkey_bytes) = key_map.get(&parsed.key_name)
260 && pubkey_bytes.len() == 32 {
261 let pubkey: [u8; 32] = pubkey_bytes
262 .as_slice()
263 .try_into()
264 .expect("length checked");
265 if parsed.verify(&fingerprint, &pubkey).is_ok() {
266 return Ok(true);
267 }
268 }
269 }
270
271 Ok(false)
272 }
273}
274
275#[async_trait::async_trait]
276impl Store for BinaryCacheStore {
277 async fn query_path_info(&self, path: &StorePath) -> StoreResult<Option<PathInfo>> {
278 let hash = Self::store_path_hash(path);
279 Ok(self
280 .fetch_narinfo(&hash)
281 .await?
282 .as_ref()
283 .map(PathInfo::from))
284 }
285
286 async fn is_valid_path(&self, path: &StorePath) -> StoreResult<bool> {
287 let hash = Self::store_path_hash(path);
288 Ok(self.fetch_narinfo(&hash).await?.is_some())
289 }
290
291 async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
292 Err(StoreError::NotSupported(
293 "binary cache does not support listing all paths".to_string(),
294 ))
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::http::{HttpError, HttpResponse};
302
303 #[test]
304 fn store_path_hash_extraction() {
305 let path = StorePath::from_absolute_path(
306 "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
307 )
308 .unwrap();
309 let hash = BinaryCacheStore::store_path_hash(&path);
310 assert_eq!(hash, "sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6");
311 }
312
313 #[test]
314 fn narinfo_to_path_info_conversion() {
315 let narinfo = sui_compat::narinfo::NarInfo {
318 store_path: "/nix/store/abc-hello".to_string(),
319 url: "nar/abc.nar.xz".to_string(),
320 compression: "xz".to_string(),
321 file_hash: "sha256:aaa".to_string(),
322 file_size: 1000,
323 nar_hash: "sha256:bbb".to_string(),
324 nar_size: 5000,
325 references: vec![
326 "3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string(),
327 ],
328 deriver: Some("abc.drv".to_string()),
329 signatures: vec!["key:sig".to_string()],
330 ca: None,
331 };
332 let info = BinaryCacheStore::narinfo_to_path_info(&narinfo);
333 assert_eq!(info.path, "/nix/store/abc-hello");
334 assert_eq!(info.nar_size, 5000);
335 assert_eq!(info.references.len(), 1);
336 assert_eq!(
337 info.references[0],
338 "/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8"
339 );
340 }
341
342 #[test]
343 fn with_http_client_constructor() {
344 let client = Box::new(ReqwestHttpClient::new());
345 let store = BinaryCacheStore::with_http_client(
346 "https://cache.nixos.org/",
347 vec![],
348 client,
349 );
350 assert_eq!(store.base_url, "https://cache.nixos.org");
351 }
352
353 #[test]
354 fn base_url_accessor() {
355 let store = BinaryCacheStore::new("https://cache.nixos.org/", vec![]);
356 assert_eq!(store.base_url(), "https://cache.nixos.org");
357 }
358
359 #[test]
360 fn trusted_keys_accessor_returns_keys() {
361 let keys = vec![
362 "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=".to_string(),
363 ];
364 let store = BinaryCacheStore::new("https://cache.nixos.org", keys.clone());
365 assert_eq!(store.trusted_keys(), &keys[..]);
366 }
367
368 #[test]
369 fn trusted_keys_accessor_empty() {
370 let store = BinaryCacheStore::new("https://cache.nixos.org", vec![]);
371 assert!(store.trusted_keys().is_empty());
372 }
373
374 struct MockHttpClient {
377 responses: std::collections::HashMap<String, HttpResponse>,
378 }
379
380 impl MockHttpClient {
381 fn new() -> Self {
382 Self {
383 responses: std::collections::HashMap::new(),
384 }
385 }
386 fn with_response(mut self, url: &str, resp: HttpResponse) -> Self {
387 self.responses.insert(url.to_string(), resp);
388 self
389 }
390 }
391
392 #[async_trait::async_trait]
393 impl HttpClient for MockHttpClient {
394 async fn get(
395 &self,
396 url: &str,
397 _h: &[(&str, &str)],
398 ) -> Result<HttpResponse, HttpError> {
399 self.responses
400 .get(url)
401 .cloned()
402 .ok_or_else(|| HttpError::Request(format!("no mock: {url}")))
403 }
404 async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError> {
405 Ok(self.get(url, &[]).await?.body.into_bytes())
406 }
407 }
408
409 const MOCK_NARINFO: &str = "\
411StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
412URL: nar/abc.nar.xz
413Compression: xz
414FileHash: sha256:aaa
415FileSize: 1000
416NarHash: sha256:bbb
417NarSize: 5000
418References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8
419Deriver: abc.drv
420Sig: cache.nixos.org-1:sig==
421";
422
423 fn hello_store_path() -> StorePath {
424 StorePath::from_absolute_path(
425 "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
426 )
427 .unwrap()
428 }
429
430 #[tokio::test]
433 async fn fetch_narinfo_valid_response() {
434 let client = MockHttpClient::new().with_response(
435 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
436 HttpResponse {
437 status: 200,
438 body: MOCK_NARINFO.to_string(),
439 },
440 );
441 let store = BinaryCacheStore::with_http_client(
442 "https://cache.nixos.org",
443 vec![],
444 Box::new(client),
445 );
446
447 let narinfo = store
448 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
449 .await
450 .unwrap();
451 assert!(narinfo.is_some());
452 let info = narinfo.unwrap();
453 assert_eq!(info.nar_size, 5000);
454 assert_eq!(info.references.len(), 1);
455 assert!(info
456 .store_path
457 .contains("hello-2.12.1"));
458 }
459
460 #[tokio::test]
463 async fn fetch_narinfo_404_returns_none() {
464 let client = MockHttpClient::new().with_response(
465 "https://cache.nixos.org/nonexistenthash000000000000000000.narinfo",
466 HttpResponse {
467 status: 404,
468 body: "not found".to_string(),
469 },
470 );
471 let store = BinaryCacheStore::with_http_client(
472 "https://cache.nixos.org",
473 vec![],
474 Box::new(client),
475 );
476
477 let narinfo = store
478 .fetch_narinfo("nonexistenthash000000000000000000")
479 .await
480 .unwrap();
481 assert!(narinfo.is_none());
482 }
483
484 #[tokio::test]
487 async fn fetch_narinfo_500_returns_error() {
488 let client = MockHttpClient::new().with_response(
489 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
490 HttpResponse {
491 status: 500,
492 body: "server error".to_string(),
493 },
494 );
495 let store = BinaryCacheStore::with_http_client(
496 "https://cache.nixos.org",
497 vec![],
498 Box::new(client),
499 );
500
501 let result = store
502 .fetch_narinfo("abc00000000000000000000000000000")
503 .await;
504 assert!(result.is_err());
505 }
506
507 #[tokio::test]
510 async fn query_path_info_via_store_trait() {
511 let client = MockHttpClient::new().with_response(
512 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
513 HttpResponse {
514 status: 200,
515 body: MOCK_NARINFO.to_string(),
516 },
517 );
518 let store = BinaryCacheStore::with_http_client(
519 "https://cache.nixos.org",
520 vec![],
521 Box::new(client),
522 );
523
524 let path_info = store
525 .query_path_info(&hello_store_path())
526 .await
527 .unwrap();
528 assert!(path_info.is_some());
529 let info = path_info.unwrap();
530 assert_eq!(info.path, "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1");
531 assert_eq!(info.nar_hash, "sha256:bbb");
532 assert_eq!(info.nar_size, 5000);
533 assert_eq!(info.signatures, vec!["cache.nixos.org-1:sig=="]);
534 }
535
536 #[tokio::test]
539 async fn is_valid_path_true_when_exists() {
540 let client = MockHttpClient::new().with_response(
541 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
542 HttpResponse {
543 status: 200,
544 body: MOCK_NARINFO.to_string(),
545 },
546 );
547 let store = BinaryCacheStore::with_http_client(
548 "https://cache.nixos.org",
549 vec![],
550 Box::new(client),
551 );
552
553 assert!(store.is_valid_path(&hello_store_path()).await.unwrap());
554 }
555
556 #[tokio::test]
557 async fn is_valid_path_false_when_missing() {
558 let client = MockHttpClient::new().with_response(
559 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
560 HttpResponse {
561 status: 404,
562 body: String::new(),
563 },
564 );
565 let store = BinaryCacheStore::with_http_client(
566 "https://cache.nixos.org",
567 vec![],
568 Box::new(client),
569 );
570
571 assert!(!store.is_valid_path(&hello_store_path()).await.unwrap());
572 }
573
574 #[tokio::test]
577 async fn query_all_valid_paths_unsupported() {
578 let client = MockHttpClient::new();
579 let store = BinaryCacheStore::with_http_client(
580 "https://cache.nixos.org",
581 vec![],
582 Box::new(client),
583 );
584
585 let result = store.query_all_valid_paths().await;
586 assert!(result.is_err());
587 }
588
589 #[test]
592 fn narinfo_to_path_info_preserves_ca() {
593 let narinfo = NarInfo {
594 store_path: "/nix/store/abc-src.tar.gz".to_string(),
595 url: "nar/abc.nar".to_string(),
596 compression: "none".to_string(),
597 file_hash: "sha256:fff".to_string(),
598 file_size: 500,
599 nar_hash: "sha256:eee".to_string(),
600 nar_size: 1000,
601 references: vec![],
602 deriver: None,
603 signatures: vec![],
604 ca: Some("fixed:out:r:sha256:deadbeef".to_string()),
605 };
606 let info = BinaryCacheStore::narinfo_to_path_info(&narinfo);
607 assert_eq!(
608 info.content_address,
609 Some("fixed:out:r:sha256:deadbeef".to_string())
610 );
611 assert_eq!(info.registration_time, 0);
612 }
613
614 #[test]
617 fn store_path_hash_extracts_exactly_32_chars() {
618 let path = StorePath::from_absolute_path(
619 "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1",
620 )
621 .unwrap();
622 let hash = BinaryCacheStore::store_path_hash(&path);
623 assert_eq!(hash.len(), 32);
624 assert_eq!(hash, "00bgd045z0d4icpbc2yyz4gx48ak44la");
625 }
626
627 #[test]
630 fn base_url_trailing_slashes_stripped() {
631 let client = MockHttpClient::new();
632 let store = BinaryCacheStore::with_http_client(
633 "https://cache.nixos.org///",
634 vec![],
635 Box::new(client),
636 );
637 assert!(!store.base_url.ends_with('/'));
640 }
641
642 #[tokio::test]
645 async fn fetch_nar_returns_bytes() {
646 let nar_content = b"fake-nar-content-with-binary-data\x00\xff\xfe";
647 let client = MockHttpClient::new().with_response(
648 "https://cache.nixos.org/nar/abc.nar.xz",
649 HttpResponse {
650 status: 200,
651 body: String::from_utf8_lossy(nar_content).to_string(),
652 },
653 );
654 let store = BinaryCacheStore::with_http_client(
655 "https://cache.nixos.org",
656 vec![],
657 Box::new(client),
658 );
659
660 let data = store.fetch_nar("nar/abc.nar.xz").await.unwrap();
661 assert!(!data.is_empty());
662 }
663
664 #[tokio::test]
665 async fn fetch_nar_http_error() {
666 let client = MockHttpClient::new();
667 let store = BinaryCacheStore::with_http_client(
668 "https://cache.nixos.org",
669 vec![],
670 Box::new(client),
671 );
672
673 let result = store.fetch_nar("nar/missing.nar.xz").await;
674 assert!(result.is_err());
675 }
676
677 #[tokio::test]
678 async fn fetch_nar_empty_body() {
679 let client = MockHttpClient::new().with_response(
680 "https://cache.nixos.org/nar/empty.nar",
681 HttpResponse {
682 status: 200,
683 body: String::new(),
684 },
685 );
686 let store = BinaryCacheStore::with_http_client(
687 "https://cache.nixos.org",
688 vec![],
689 Box::new(client),
690 );
691
692 let data = store.fetch_nar("nar/empty.nar").await.unwrap();
693 assert!(data.is_empty());
694 }
695
696 #[tokio::test]
699 async fn fetch_narinfo_unknown_fields_ignored() {
700 let narinfo_with_extra = "\
701StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
702URL: nar/abc.nar.xz
703Compression: xz
704FileHash: sha256:aaa
705FileSize: 1000
706NarHash: sha256:bbb
707NarSize: 5000
708References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8
709Deriver: abc.drv
710Sig: cache.nixos.org-1:sig==
711FutureField: should-be-ignored
712AnotherUnknown: 42
713";
714 let client = MockHttpClient::new().with_response(
715 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
716 HttpResponse {
717 status: 200,
718 body: narinfo_with_extra.to_string(),
719 },
720 );
721 let store = BinaryCacheStore::with_http_client(
722 "https://cache.nixos.org",
723 vec![],
724 Box::new(client),
725 );
726
727 let narinfo = store
728 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
729 .await
730 .unwrap();
731 assert!(narinfo.is_some());
732 assert_eq!(narinfo.unwrap().nar_size, 5000);
733 }
734
735 #[tokio::test]
736 async fn fetch_narinfo_malformed_body_returns_error() {
737 let client = MockHttpClient::new().with_response(
738 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
739 HttpResponse {
740 status: 200,
741 body: "this is not valid narinfo content at all".to_string(),
742 },
743 );
744 let store = BinaryCacheStore::with_http_client(
745 "https://cache.nixos.org",
746 vec![],
747 Box::new(client),
748 );
749
750 let result = store
751 .fetch_narinfo("abc00000000000000000000000000000")
752 .await;
753 assert!(result.is_err());
754 }
755
756 #[tokio::test]
757 async fn fetch_narinfo_missing_required_field() {
758 let incomplete_narinfo = "\
759StorePath: /nix/store/abc-hello
760Compression: xz
761NarHash: sha256:bbb
762NarSize: 5000
763";
764 let client = MockHttpClient::new().with_response(
765 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
766 HttpResponse {
767 status: 200,
768 body: incomplete_narinfo.to_string(),
769 },
770 );
771 let store = BinaryCacheStore::with_http_client(
772 "https://cache.nixos.org",
773 vec![],
774 Box::new(client),
775 );
776
777 let result = store
778 .fetch_narinfo("abc00000000000000000000000000000")
779 .await;
780 assert!(result.is_err());
781 }
782
783 #[tokio::test]
784 async fn fetch_narinfo_whitespace_in_body() {
785 let narinfo_with_whitespace = "\
786 StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
787 URL: nar/abc.nar.xz
788 Compression: xz
789 FileHash: sha256:aaa
790 FileSize: 1000
791 NarHash: sha256:bbb
792 NarSize: 5000
793 References:
794";
795 let client = MockHttpClient::new().with_response(
796 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
797 HttpResponse {
798 status: 200,
799 body: narinfo_with_whitespace.to_string(),
800 },
801 );
802 let store = BinaryCacheStore::with_http_client(
803 "https://cache.nixos.org",
804 vec![],
805 Box::new(client),
806 );
807
808 let narinfo = store
809 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
810 .await
811 .unwrap();
812 assert!(narinfo.is_some());
813 }
814
815 #[tokio::test]
816 async fn fetch_narinfo_http_client_error() {
817 let client = MockHttpClient::new();
818 let store = BinaryCacheStore::with_http_client(
819 "https://cache.nixos.org",
820 vec![],
821 Box::new(client),
822 );
823
824 let result = store
825 .fetch_narinfo("nonexistent0000000000000000000000")
826 .await;
827 assert!(result.is_err());
828 }
829
830 #[tokio::test]
831 async fn fetch_narinfo_302_redirect_returns_error() {
832 let client = MockHttpClient::new().with_response(
833 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
834 HttpResponse {
835 status: 302,
836 body: String::new(),
837 },
838 );
839 let store = BinaryCacheStore::with_http_client(
840 "https://cache.nixos.org",
841 vec![],
842 Box::new(client),
843 );
844
845 let result = store
846 .fetch_narinfo("abc00000000000000000000000000000")
847 .await;
848 assert!(result.is_err());
849 }
850
851 #[tokio::test]
852 async fn fetch_narinfo_no_signatures() {
853 let narinfo_no_sigs = "\
854StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
855URL: nar/abc.nar.xz
856Compression: xz
857FileHash: sha256:aaa
858FileSize: 1000
859NarHash: sha256:bbb
860NarSize: 5000
861References:
862";
863 let client = MockHttpClient::new().with_response(
864 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
865 HttpResponse {
866 status: 200,
867 body: narinfo_no_sigs.to_string(),
868 },
869 );
870 let store = BinaryCacheStore::with_http_client(
871 "https://cache.nixos.org",
872 vec![],
873 Box::new(client),
874 );
875
876 let narinfo = store
877 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
878 .await
879 .unwrap()
880 .unwrap();
881 assert!(narinfo.signatures.is_empty());
882 assert!(narinfo.references.is_empty());
883 }
884
885 #[tokio::test]
886 async fn fetch_narinfo_multiple_signatures() {
887 let narinfo_multi_sigs = "\
888StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
889URL: nar/abc.nar.xz
890Compression: xz
891FileHash: sha256:aaa
892FileSize: 1000
893NarHash: sha256:bbb
894NarSize: 5000
895References:
896Sig: key1:aaa==
897Sig: key2:bbb==
898Sig: key3:ccc==
899";
900 let client = MockHttpClient::new().with_response(
901 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
902 HttpResponse {
903 status: 200,
904 body: narinfo_multi_sigs.to_string(),
905 },
906 );
907 let store = BinaryCacheStore::with_http_client(
908 "https://cache.nixos.org",
909 vec![],
910 Box::new(client),
911 );
912
913 let narinfo = store
914 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
915 .await
916 .unwrap()
917 .unwrap();
918 assert_eq!(narinfo.signatures.len(), 3);
919 assert_eq!(narinfo.signatures[0], "key1:aaa==");
920 assert_eq!(narinfo.signatures[2], "key3:ccc==");
921 }
922
923 #[tokio::test]
926 async fn dyn_store_query_path_info() {
927 let client = MockHttpClient::new().with_response(
928 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
929 HttpResponse {
930 status: 200,
931 body: MOCK_NARINFO.to_string(),
932 },
933 );
934 let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
935 BinaryCacheStore::with_http_client(
936 "https://cache.nixos.org",
937 vec![],
938 Box::new(client),
939 ),
940 );
941
942 let info = store.query_path_info(&hello_store_path()).await.unwrap();
943 assert!(info.is_some());
944 assert_eq!(info.unwrap().nar_size, 5000);
945 }
946
947 #[tokio::test]
948 async fn dyn_store_is_valid_path() {
949 let client = MockHttpClient::new().with_response(
950 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
951 HttpResponse {
952 status: 200,
953 body: MOCK_NARINFO.to_string(),
954 },
955 );
956 let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
957 BinaryCacheStore::with_http_client(
958 "https://cache.nixos.org",
959 vec![],
960 Box::new(client),
961 ),
962 );
963
964 assert!(store.is_valid_path(&hello_store_path()).await.unwrap());
965 }
966
967 #[tokio::test]
968 async fn dyn_store_query_all_valid_paths_unsupported() {
969 let client = MockHttpClient::new();
970 let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
971 BinaryCacheStore::with_http_client(
972 "https://cache.nixos.org",
973 vec![],
974 Box::new(client),
975 ),
976 );
977
978 let result = store.query_all_valid_paths().await;
979 assert!(result.is_err());
980 }
981
982
983 #[test]
986 fn binary_cache_error_http_client_converts_to_store_http() {
987 let http_err = HttpError::Request("dns failure".to_string());
988 let bc_err: BinaryCacheError = http_err.into();
989 let store_err: StoreError = bc_err.into();
990 match store_err {
991 StoreError::Http(msg) => assert!(msg.contains("dns failure")),
992 other => panic!("expected Http, got {other:?}"),
993 }
994 }
995
996 #[test]
997 fn binary_cache_error_unexpected_status_converts_to_store_http() {
998 let bc_err = BinaryCacheError::UnexpectedStatus {
999 status: 503,
1000 url: "https://cache.test/abc.narinfo".to_string(),
1001 };
1002 let store_err: StoreError = bc_err.into();
1003 match store_err {
1004 StoreError::Http(msg) => {
1005 assert!(msg.contains("503"));
1006 assert!(msg.contains("cache.test"));
1007 }
1008 other => panic!("expected Http, got {other:?}"),
1009 }
1010 }
1011
1012 #[test]
1013 fn binary_cache_error_narinfo_parse_converts_to_store_narinfo() {
1014 let parse_err = sui_compat::narinfo::NarInfoError::MissingField("StorePath".to_string());
1015 let bc_err: BinaryCacheError = parse_err.into();
1016 let store_err: StoreError = bc_err.into();
1017 match store_err {
1018 StoreError::NarInfo(msg) => {
1019 assert!(msg.contains("StorePath") || msg.contains("missing"));
1020 }
1021 other => panic!("expected NarInfo, got {other:?}"),
1022 }
1023 }
1024
1025 #[test]
1026 fn binary_cache_error_display_unexpected_status() {
1027 let err = BinaryCacheError::UnexpectedStatus {
1028 status: 418,
1029 url: "https://teapot.test/x.narinfo".to_string(),
1030 };
1031 let s = err.to_string();
1032 assert!(s.contains("418"));
1033 assert!(s.contains("teapot.test"));
1034 }
1035
1036 #[test]
1037 fn binary_cache_error_debug_format() {
1038 let err = BinaryCacheError::UnexpectedStatus {
1039 status: 500,
1040 url: "x".to_string(),
1041 };
1042 let debug = format!("{err:?}");
1043 assert!(debug.contains("UnexpectedStatus"));
1044 assert!(debug.contains("500"));
1045 }
1046
1047 #[test]
1050 fn builder_default_is_reqwest_client() {
1051 let store = BinaryCacheStore::builder("https://cache.nixos.org").build();
1052 assert_eq!(store.base_url(), "https://cache.nixos.org");
1053 assert!(store.trusted_keys().is_empty());
1054 }
1055
1056 #[test]
1057 fn builder_with_trusted_keys() {
1058 let keys = vec!["k1:abc==".to_string(), "k2:def==".to_string()];
1059 let store = BinaryCacheStore::builder("https://cache.nixos.org")
1060 .trusted_keys(keys.clone())
1061 .build();
1062 assert_eq!(store.trusted_keys().len(), 2);
1063 assert_eq!(store.trusted_keys()[0], "k1:abc==");
1064 }
1065
1066 #[test]
1067 fn builder_chaining_order_independent() {
1068 let client = Box::new(MockHttpClient::new());
1069 let keys = vec!["k:s".to_string()];
1070 let store = BinaryCacheStore::builder("https://cache.nixos.org")
1071 .http_client(client)
1072 .trusted_keys(keys.clone())
1073 .build();
1074 assert_eq!(store.trusted_keys(), &keys[..]);
1075 assert_eq!(store.base_url(), "https://cache.nixos.org");
1076 }
1077
1078 #[test]
1079 fn builder_strips_trailing_slash() {
1080 let store = BinaryCacheStore::builder("https://cache.nixos.org/").build();
1081 assert_eq!(store.base_url(), "https://cache.nixos.org");
1082 }
1083
1084 #[test]
1085 fn builder_strips_multiple_trailing_slashes() {
1086 let store = BinaryCacheStore::builder("https://cache.nixos.org////").build();
1087 assert!(!store.base_url().ends_with('/'));
1088 }
1089
1090 #[test]
1093 fn store_path_hash_for_drv_path() {
1094 let path = StorePath::from_absolute_path(
1095 "/nix/store/xb4y5iklhya4blk42k1cfkb8k07dpp4n-hello-2.12.1.drv",
1096 )
1097 .unwrap();
1098 let hash = BinaryCacheStore::store_path_hash(&path);
1099 assert_eq!(hash, "xb4y5iklhya4blk42k1cfkb8k07dpp4n");
1100 assert_eq!(hash.len(), 32);
1101 }
1102
1103 #[tokio::test]
1106 async fn fetch_narinfo_zstd_compression() {
1107 let body = "\
1108StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1109URL: nar/abc.nar.zst
1110Compression: zstd
1111FileHash: sha256:aaa
1112FileSize: 1000
1113NarHash: sha256:bbb
1114NarSize: 5000
1115References:
1116";
1117 let client = MockHttpClient::new().with_response(
1118 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1119 HttpResponse {
1120 status: 200,
1121 body: body.to_string(),
1122 },
1123 );
1124 let store = BinaryCacheStore::with_http_client(
1125 "https://cache.nixos.org",
1126 vec![],
1127 Box::new(client),
1128 );
1129 let info = store
1130 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1131 .await
1132 .unwrap()
1133 .unwrap();
1134 assert_eq!(info.compression, "zstd");
1135 }
1136
1137 #[tokio::test]
1138 async fn fetch_narinfo_no_compression() {
1139 let body = "\
1140StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1141URL: nar/abc.nar
1142Compression: none
1143FileHash: sha256:aaa
1144FileSize: 1000
1145NarHash: sha256:bbb
1146NarSize: 5000
1147References:
1148";
1149 let client = MockHttpClient::new().with_response(
1150 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1151 HttpResponse {
1152 status: 200,
1153 body: body.to_string(),
1154 },
1155 );
1156 let store = BinaryCacheStore::with_http_client(
1157 "https://cache.nixos.org",
1158 vec![],
1159 Box::new(client),
1160 );
1161 let info = store
1162 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1163 .await
1164 .unwrap()
1165 .unwrap();
1166 assert_eq!(info.compression, "none");
1167 }
1168
1169 #[tokio::test]
1170 async fn fetch_narinfo_bzip2_compression() {
1171 let body = "\
1172StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1173URL: nar/abc.nar.bz2
1174Compression: bzip2
1175FileHash: sha256:aaa
1176FileSize: 1000
1177NarHash: sha256:bbb
1178NarSize: 5000
1179References:
1180";
1181 let client = MockHttpClient::new().with_response(
1182 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1183 HttpResponse {
1184 status: 200,
1185 body: body.to_string(),
1186 },
1187 );
1188 let store = BinaryCacheStore::with_http_client(
1189 "https://cache.nixos.org",
1190 vec![],
1191 Box::new(client),
1192 );
1193 let info = store
1194 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1195 .await
1196 .unwrap()
1197 .unwrap();
1198 assert_eq!(info.compression, "bzip2");
1199 }
1200
1201 #[tokio::test]
1204 async fn fetch_narinfo_with_ca_field() {
1205 let body = "\
1206StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-source.tar.gz
1207URL: nar/abc.nar.xz
1208Compression: xz
1209FileHash: sha256:aaa
1210FileSize: 1000
1211NarHash: sha256:bbb
1212NarSize: 5000
1213References:
1214CA: fixed:out:r:sha256:cafebabedeadbeef
1215";
1216 let client = MockHttpClient::new().with_response(
1217 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1218 HttpResponse {
1219 status: 200,
1220 body: body.to_string(),
1221 },
1222 );
1223 let store = BinaryCacheStore::with_http_client(
1224 "https://cache.nixos.org",
1225 vec![],
1226 Box::new(client),
1227 );
1228 let info = store
1229 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1230 .await
1231 .unwrap()
1232 .unwrap();
1233 assert_eq!(
1234 info.ca,
1235 Some("fixed:out:r:sha256:cafebabedeadbeef".to_string())
1236 );
1237 let path_info = PathInfo::from(&info);
1239 assert_eq!(
1240 path_info.content_address,
1241 Some("fixed:out:r:sha256:cafebabedeadbeef".to_string())
1242 );
1243 }
1244
1245 #[tokio::test]
1248 async fn fetch_narinfo_many_references_on_one_line() {
1249 let body = "\
1250StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1251URL: nar/abc.nar.xz
1252Compression: xz
1253FileHash: sha256:aaa
1254FileSize: 1000
1255NarHash: sha256:bbb
1256NarSize: 5000
1257References: dep1 dep2 dep3 dep4 dep5 dep6 dep7 dep8 dep9 dep10
1258";
1259 let client = MockHttpClient::new().with_response(
1260 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1261 HttpResponse {
1262 status: 200,
1263 body: body.to_string(),
1264 },
1265 );
1266 let store = BinaryCacheStore::with_http_client(
1267 "https://cache.nixos.org",
1268 vec![],
1269 Box::new(client),
1270 );
1271 let info = store
1272 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1273 .await
1274 .unwrap()
1275 .unwrap();
1276 assert_eq!(info.references.len(), 10);
1277 assert_eq!(info.references[0], "dep1");
1278 assert_eq!(info.references[9], "dep10");
1279 }
1280
1281 #[tokio::test]
1284 async fn fetch_narinfo_no_deriver() {
1285 let body = "\
1286StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1287URL: nar/abc.nar.xz
1288Compression: xz
1289FileHash: sha256:aaa
1290FileSize: 1000
1291NarHash: sha256:bbb
1292NarSize: 5000
1293References:
1294";
1295 let client = MockHttpClient::new().with_response(
1296 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1297 HttpResponse {
1298 status: 200,
1299 body: body.to_string(),
1300 },
1301 );
1302 let store = BinaryCacheStore::with_http_client(
1303 "https://cache.nixos.org",
1304 vec![],
1305 Box::new(client),
1306 );
1307 let info = store
1308 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1309 .await
1310 .unwrap()
1311 .unwrap();
1312 assert!(info.deriver.is_none());
1313 }
1314
1315 #[tokio::test]
1318 async fn fetch_narinfo_empty_deriver_treated_as_none() {
1319 let body = "\
1320StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1321URL: nar/abc.nar.xz
1322Compression: xz
1323FileHash: sha256:aaa
1324FileSize: 1000
1325NarHash: sha256:bbb
1326NarSize: 5000
1327References:
1328Deriver:
1329";
1330 let client = MockHttpClient::new().with_response(
1331 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1332 HttpResponse {
1333 status: 200,
1334 body: body.to_string(),
1335 },
1336 );
1337 let store = BinaryCacheStore::with_http_client(
1338 "https://cache.nixos.org",
1339 vec![],
1340 Box::new(client),
1341 );
1342 let info = store
1343 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1344 .await
1345 .unwrap()
1346 .unwrap();
1347 assert!(info.deriver.is_none());
1348 }
1349
1350 #[tokio::test]
1353 async fn fetch_narinfo_503_returns_error() {
1354 let client = MockHttpClient::new().with_response(
1355 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
1356 HttpResponse {
1357 status: 503,
1358 body: "service unavailable".to_string(),
1359 },
1360 );
1361 let store = BinaryCacheStore::with_http_client(
1362 "https://cache.nixos.org",
1363 vec![],
1364 Box::new(client),
1365 );
1366 let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
1367 assert!(result.is_err());
1368 }
1369
1370 #[tokio::test]
1371 async fn fetch_narinfo_403_returns_error() {
1372 let client = MockHttpClient::new().with_response(
1373 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
1374 HttpResponse {
1375 status: 403,
1376 body: "forbidden".to_string(),
1377 },
1378 );
1379 let store = BinaryCacheStore::with_http_client(
1380 "https://cache.nixos.org",
1381 vec![],
1382 Box::new(client),
1383 );
1384 let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
1385 assert!(result.is_err());
1386 }
1387
1388 #[tokio::test]
1389 async fn fetch_narinfo_301_redirect_returns_error() {
1390 let client = MockHttpClient::new().with_response(
1391 "https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
1392 HttpResponse {
1393 status: 301,
1394 body: String::new(),
1395 },
1396 );
1397 let store = BinaryCacheStore::with_http_client(
1398 "https://cache.nixos.org",
1399 vec![],
1400 Box::new(client),
1401 );
1402 let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
1403 assert!(result.is_err());
1404 }
1405
1406 #[tokio::test]
1407 async fn fetch_narinfo_201_created_treated_as_success() {
1408 let body = "\
1409StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1410URL: nar/abc.nar.xz
1411Compression: xz
1412FileHash: sha256:aaa
1413FileSize: 1000
1414NarHash: sha256:bbb
1415NarSize: 5000
1416References:
1417";
1418 let client = MockHttpClient::new().with_response(
1419 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1420 HttpResponse {
1421 status: 201,
1422 body: body.to_string(),
1423 },
1424 );
1425 let store = BinaryCacheStore::with_http_client(
1426 "https://cache.nixos.org",
1427 vec![],
1428 Box::new(client),
1429 );
1430 let info = store
1431 .fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
1432 .await
1433 .unwrap();
1434 assert!(info.is_some());
1435 }
1436
1437 #[tokio::test]
1440 async fn fetch_nar_returns_correct_url_path() {
1441 let client = MockHttpClient::new().with_response(
1442 "https://cache.nixos.org/nar/some/nested/path.nar.xz",
1443 HttpResponse {
1444 status: 200,
1445 body: "data".to_string(),
1446 },
1447 );
1448 let store = BinaryCacheStore::with_http_client(
1449 "https://cache.nixos.org",
1450 vec![],
1451 Box::new(client),
1452 );
1453 let bytes = store.fetch_nar("nar/some/nested/path.nar.xz").await.unwrap();
1454 assert_eq!(bytes, b"data");
1455 }
1456
1457 #[tokio::test]
1460 async fn binary_cache_collect_garbage_unsupported() {
1461 use crate::traits::GcOptions;
1462 let client = MockHttpClient::new();
1463 let store = BinaryCacheStore::with_http_client(
1464 "https://cache.nixos.org",
1465 vec![],
1466 Box::new(client),
1467 );
1468 let result = store.collect_garbage(&GcOptions::default()).await;
1469 assert!(result.is_err());
1470 }
1471
1472 #[tokio::test]
1473 async fn binary_cache_add_to_store_unsupported() {
1474 let client = MockHttpClient::new();
1475 let store = BinaryCacheStore::with_http_client(
1476 "https://cache.nixos.org",
1477 vec![],
1478 Box::new(client),
1479 );
1480 let result = store.add_to_store("hello", b"data", &[]).await;
1481 assert!(result.is_err());
1482 }
1483
1484 #[tokio::test]
1485 async fn binary_cache_register_path_unsupported() {
1486 let client = MockHttpClient::new();
1487 let store = BinaryCacheStore::with_http_client(
1488 "https://cache.nixos.org",
1489 vec![],
1490 Box::new(client),
1491 );
1492 let info = PathInfo::new("/nix/store/abc-x", "sha256:aaa");
1493 let result = store.register_path(&info).await;
1494 assert!(result.is_err());
1495 }
1496
1497 #[tokio::test]
1498 async fn binary_cache_query_referrers_unsupported() {
1499 let client = MockHttpClient::new();
1500 let store = BinaryCacheStore::with_http_client(
1501 "https://cache.nixos.org",
1502 vec![],
1503 Box::new(client),
1504 );
1505 let result = store.query_referrers(&hello_store_path()).await;
1506 assert!(result.is_err());
1507 }
1508
1509 #[tokio::test]
1510 async fn binary_cache_add_signatures_unsupported() {
1511 let client = MockHttpClient::new();
1512 let store = BinaryCacheStore::with_http_client(
1513 "https://cache.nixos.org",
1514 vec![],
1515 Box::new(client),
1516 );
1517 let result = store
1518 .add_signatures(&hello_store_path(), &["sig".to_string()])
1519 .await;
1520 assert!(result.is_err());
1521 }
1522
1523 #[tokio::test]
1532 async fn binary_cache_query_references_round_trip() {
1533 let body = "\
1534StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1535URL: nar/abc.nar.xz
1536Compression: xz
1537FileHash: sha256:aaa
1538FileSize: 1000
1539NarHash: sha256:bbb
1540NarSize: 5000
1541References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37 00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2
1542";
1543 let client = MockHttpClient::new().with_response(
1544 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1545 HttpResponse {
1546 status: 200,
1547 body: body.to_string(),
1548 },
1549 );
1550 let store = BinaryCacheStore::with_http_client(
1551 "https://cache.nixos.org",
1552 vec![],
1553 Box::new(client),
1554 );
1555 let info = store.query_path_info(&hello_store_path()).await.unwrap().unwrap();
1557 assert_eq!(info.references.len(), 2);
1558 assert_eq!(
1559 info.references[0],
1560 "/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37"
1561 );
1562
1563 let refs = store.query_references(&hello_store_path()).await.unwrap();
1566 assert_eq!(refs.len(), 2);
1567 }
1568
1569 #[tokio::test]
1572 async fn box_dyn_binary_cache_store_query_path_info() {
1573 let client = MockHttpClient::new().with_response(
1574 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1575 HttpResponse {
1576 status: 200,
1577 body: MOCK_NARINFO.to_string(),
1578 },
1579 );
1580 let store: Box<dyn Store> = Box::new(BinaryCacheStore::with_http_client(
1581 "https://cache.nixos.org",
1582 vec![],
1583 Box::new(client),
1584 ));
1585 let info = store.query_path_info(&hello_store_path()).await.unwrap();
1586 assert!(info.is_some());
1587 }
1588
1589 #[tokio::test]
1595 async fn query_path_info_references_are_absolute_store_paths() {
1596 let narinfo_multi_refs = "\
1597StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1598URL: nar/abc.nar.xz
1599Compression: xz
1600FileHash: sha256:aaa
1601FileSize: 1000
1602NarHash: sha256:bbb
1603NarSize: 5000
1604References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8 00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2 sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1605Deriver: abc.drv
1606Sig: cache.nixos.org-1:sig==
1607";
1608 let client = MockHttpClient::new().with_response(
1609 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1610 HttpResponse {
1611 status: 200,
1612 body: narinfo_multi_refs.to_string(),
1613 },
1614 );
1615 let store = BinaryCacheStore::with_http_client(
1616 "https://cache.nixos.org",
1617 vec![],
1618 Box::new(client),
1619 );
1620
1621 let info = store
1622 .query_path_info(&hello_store_path())
1623 .await
1624 .unwrap()
1625 .expect("path info should be present");
1626
1627 assert_eq!(info.references.len(), 3);
1628 for r in &info.references {
1629 assert!(
1630 r.starts_with("/nix/store/"),
1631 "reference should be absolute store path, got {r:?}"
1632 );
1633 }
1634 assert_eq!(
1635 info.references[0],
1636 "/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8"
1637 );
1638 assert_eq!(
1639 info.references[1],
1640 "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2"
1641 );
1642 assert_eq!(
1643 info.references[2],
1644 "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1"
1645 );
1646 }
1647
1648 #[tokio::test]
1652 async fn query_references_via_store_returns_full_prefixed_paths() {
1653 struct MockStore {
1656 info: PathInfo,
1657 }
1658
1659 #[async_trait::async_trait]
1660 impl Store for MockStore {
1661 async fn query_path_info(
1662 &self,
1663 _path: &StorePath,
1664 ) -> StoreResult<Option<PathInfo>> {
1665 Ok(Some(self.info.clone()))
1666 }
1667 async fn is_valid_path(&self, _path: &StorePath) -> StoreResult<bool> {
1668 Ok(true)
1669 }
1670 async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
1671 Ok(vec![])
1672 }
1673 }
1674
1675 let narinfo = NarInfo {
1676 store_path: "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1".to_string(),
1677 url: "nar/abc.nar.xz".to_string(),
1678 compression: "xz".to_string(),
1679 file_hash: "sha256:aaa".to_string(),
1680 file_size: 1000,
1681 nar_hash: "sha256:bbb".to_string(),
1682 nar_size: 5000,
1683 references: vec![
1684 "3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string(),
1685 "00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2".to_string(),
1686 ],
1687 deriver: None,
1688 signatures: vec![],
1689 ca: None,
1690 };
1691 let mock = MockStore {
1692 info: PathInfo::from(&narinfo),
1693 };
1694
1695 let refs = mock.query_references(&hello_store_path()).await.unwrap();
1696 assert_eq!(
1697 refs.len(),
1698 2,
1699 "default query_references must yield both NarInfo references"
1700 );
1701 let absolute: Vec<String> = refs.iter().map(StorePath::to_absolute_path).collect();
1702 assert!(absolute.contains(
1703 &"/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string()
1704 ));
1705 assert!(absolute.contains(
1706 &"/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2".to_string()
1707 ));
1708 }
1709
1710 #[tokio::test]
1713 async fn query_path_info_empty_references_yields_empty_vec() {
1714 let narinfo_no_refs = "\
1715StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
1716URL: nar/abc.nar.xz
1717Compression: xz
1718FileHash: sha256:aaa
1719FileSize: 1000
1720NarHash: sha256:bbb
1721NarSize: 5000
1722References:
1723";
1724 let client = MockHttpClient::new().with_response(
1725 "https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
1726 HttpResponse {
1727 status: 200,
1728 body: narinfo_no_refs.to_string(),
1729 },
1730 );
1731 let store = BinaryCacheStore::with_http_client(
1732 "https://cache.nixos.org",
1733 vec![],
1734 Box::new(client),
1735 );
1736
1737 let info = store
1738 .query_path_info(&hello_store_path())
1739 .await
1740 .unwrap()
1741 .expect("path info should be present");
1742 assert!(info.references.is_empty());
1743 }
1744
1745 fn make_signed_narinfo() -> (NarInfo, String) {
1748 use ed25519_dalek::{Signer, SigningKey};
1749 use sui_compat::hash::base64_encode;
1750 use sui_compat::signature::compute_fingerprint;
1751
1752 let signing_key = SigningKey::from_bytes(&[42u8; 32]);
1753 let verifying_key = signing_key.verifying_key();
1754
1755 let narinfo = NarInfo {
1756 store_path: "/nix/store/abc-hello".to_string(),
1757 url: "nar/abc.nar.xz".to_string(),
1758 compression: "xz".to_string(),
1759 file_hash: "sha256:aaa".to_string(),
1760 file_size: 1000,
1761 nar_hash: "sha256:bbb".to_string(),
1762 nar_size: 5000,
1763 references: vec![],
1764 deriver: None,
1765 signatures: vec![],
1766 ca: None,
1767 };
1768
1769 let fingerprint = compute_fingerprint(
1770 &narinfo.store_path,
1771 &narinfo.nar_hash,
1772 narinfo.nar_size,
1773 &narinfo.references,
1774 );
1775 let sig = signing_key.sign(fingerprint.as_bytes());
1776 let sig_str = format!(
1777 "test-key:{}",
1778 base64_encode(&sig.to_bytes())
1779 );
1780 let trusted_key = format!(
1781 "test-key:{}",
1782 base64_encode(verifying_key.as_bytes())
1783 );
1784
1785 let mut signed = narinfo;
1786 signed.signatures = vec![sig_str];
1787
1788 (signed, trusted_key)
1789 }
1790
1791 #[test]
1792 fn verify_narinfo_signatures_valid() {
1793 let (narinfo, trusted_key) = make_signed_narinfo();
1794 let result = BinaryCacheStore::verify_narinfo_signatures(
1795 &narinfo,
1796 &[trusted_key],
1797 )
1798 .unwrap();
1799 assert!(result);
1800 }
1801
1802 #[test]
1803 fn verify_narinfo_signatures_invalid_key() {
1804 use sui_compat::hash::base64_encode;
1805
1806 let (narinfo, _) = make_signed_narinfo();
1807 let wrong_key = format!(
1809 "test-key:{}",
1810 base64_encode(&[99u8; 32])
1811 );
1812 let result = BinaryCacheStore::verify_narinfo_signatures(
1813 &narinfo,
1814 &[wrong_key],
1815 )
1816 .unwrap();
1817 assert!(!result);
1818 }
1819
1820 #[test]
1821 fn verify_narinfo_signatures_empty_trusted_keys_returns_false() {
1822 let (narinfo, _) = make_signed_narinfo();
1823 let result = BinaryCacheStore::verify_narinfo_signatures(
1824 &narinfo,
1825 &[],
1826 )
1827 .unwrap();
1828 assert!(!result);
1829 }
1830
1831 #[test]
1832 fn verify_narinfo_signatures_no_matching_key_name() {
1833 use sui_compat::hash::base64_encode;
1834
1835 let (narinfo, _) = make_signed_narinfo();
1836 let wrong_name_key = format!(
1838 "other-key:{}",
1839 base64_encode(&[42u8; 32])
1840 );
1841 let result = BinaryCacheStore::verify_narinfo_signatures(
1842 &narinfo,
1843 &[wrong_name_key],
1844 )
1845 .unwrap();
1846 assert!(!result);
1847 }
1848
1849 #[test]
1850 fn verify_narinfo_signatures_unsigned_narinfo() {
1851 let narinfo = NarInfo {
1852 store_path: "/nix/store/abc-hello".to_string(),
1853 url: "nar/abc.nar.xz".to_string(),
1854 compression: "xz".to_string(),
1855 file_hash: "sha256:aaa".to_string(),
1856 file_size: 1000,
1857 nar_hash: "sha256:bbb".to_string(),
1858 nar_size: 5000,
1859 references: vec![],
1860 deriver: None,
1861 signatures: vec![],
1862 ca: None,
1863 };
1864 let result = BinaryCacheStore::verify_narinfo_signatures(
1865 &narinfo,
1866 &["key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string()],
1867 )
1868 .unwrap();
1869 assert!(!result);
1870 }
1871
1872 #[test]
1873 fn verify_narinfo_signatures_with_references() {
1874 use ed25519_dalek::{Signer, SigningKey};
1875 use sui_compat::hash::base64_encode;
1876 use sui_compat::signature::compute_fingerprint;
1877
1878 let signing_key = SigningKey::from_bytes(&[10u8; 32]);
1879 let verifying_key = signing_key.verifying_key();
1880
1881 let refs = vec![
1882 "dep-b".to_string(),
1883 "dep-a".to_string(),
1884 ];
1885
1886 let narinfo = NarInfo {
1887 store_path: "/nix/store/xyz-pkg".to_string(),
1888 url: "nar/xyz.nar".to_string(),
1889 compression: "none".to_string(),
1890 file_hash: "sha256:fff".to_string(),
1891 file_size: 2000,
1892 nar_hash: "sha256:eee".to_string(),
1893 nar_size: 3000,
1894 references: refs.clone(),
1895 deriver: None,
1896 signatures: vec![],
1897 ca: None,
1898 };
1899
1900 let mut sorted_refs = refs;
1902 sorted_refs.sort();
1903 let fingerprint = compute_fingerprint(
1904 &narinfo.store_path,
1905 &narinfo.nar_hash,
1906 narinfo.nar_size,
1907 &sorted_refs,
1908 );
1909 let sig = signing_key.sign(fingerprint.as_bytes());
1910 let sig_str = format!("k:{}", base64_encode(&sig.to_bytes()));
1911 let trusted_key = format!("k:{}", base64_encode(verifying_key.as_bytes()));
1912
1913 let mut signed = narinfo;
1914 signed.signatures = vec![sig_str];
1915
1916 let result = BinaryCacheStore::verify_narinfo_signatures(
1917 &signed,
1918 &[trusted_key],
1919 )
1920 .unwrap();
1921 assert!(result);
1922 }
1923
1924 #[test]
1927 fn builder_auth_header_none_by_default() {
1928 let store = BinaryCacheStore::builder("https://cache.example.com").build();
1929 assert!(store.auth_header().is_none());
1930 }
1931
1932 #[test]
1933 fn builder_auth_header_set() {
1934 let store = BinaryCacheStore::builder("https://cache.example.com")
1935 .auth_header("Bearer", "my-token-123")
1936 .build();
1937 let (scheme, creds) = store.auth_header().unwrap();
1938 assert_eq!(scheme, "Bearer");
1939 assert_eq!(creds, "my-token-123");
1940 }
1941
1942 #[test]
1943 fn request_headers_without_auth() {
1944 let store = BinaryCacheStore::builder("https://cache.example.com").build();
1945 let headers = store.request_headers(&[("Accept", "text/plain")]);
1946 assert_eq!(headers.len(), 1);
1947 assert_eq!(headers[0], ("Accept".to_string(), "text/plain".to_string()));
1948 }
1949
1950 #[test]
1951 fn request_headers_with_auth() {
1952 let store = BinaryCacheStore::builder("https://cache.example.com")
1953 .auth_header("Bearer", "token123")
1954 .build();
1955 let headers = store.request_headers(&[("Accept", "text/plain")]);
1956 assert_eq!(headers.len(), 2);
1957 assert_eq!(headers[1], ("Authorization".to_string(), "Bearer token123".to_string()));
1958 }
1959
1960 #[test]
1961 fn new_constructor_has_no_auth() {
1962 let store = BinaryCacheStore::new("https://cache.example.com", vec![]);
1963 assert!(store.auth_header().is_none());
1964 }
1965}