Skip to main content

sui_store/
binary_cache.rs

1//! Binary cache store — HTTP client for cache.nixos.org, Cachix, Attic.
2//!
3//! Implements the NarInfo + NAR download protocol for substitution.
4
5// TODO(scope): NarInfo lives in sui-compat — add `impl FromStr for NarInfo`
6// there so callers can use `"...".parse::<NarInfo>()` instead of `NarInfo::parse()`.
7use 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/// Typed errors for binary cache operations.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum BinaryCacheError {
17    /// HTTP client returned an error (network, DNS, TLS, etc.).
18    #[error("http client error: {0}")]
19    HttpClient(#[from] HttpError),
20    /// Server returned an unexpected (non-2xx, non-404) HTTP status.
21    #[error("unexpected HTTP status {status} for {url}")]
22    UnexpectedStatus {
23        /// The HTTP status code received.
24        status: u16,
25        /// The URL that was requested.
26        url: String,
27    },
28    /// The NarInfo response body could not be parsed.
29    #[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
44/// A read-only binary cache store accessed over HTTP.
45pub struct BinaryCacheStore {
46    client: Box<dyn HttpClient>,
47    /// Base URL (e.g., `https://cache.nixos.org`).
48    base_url: String,
49    /// Trusted public keys for signature verification (`keyname:base64pubkey`).
50    trusted_keys: Vec<String>,
51    /// Optional authorization header (`("Bearer", "<token>")` or `("Basic", "<creds>")`).
52    auth_header: Option<(String, String)>,
53}
54
55/// Builder for [`BinaryCacheStore`].
56pub 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    /// Set the trusted public keys for signature verification.
65    #[must_use]
66    pub fn trusted_keys(mut self, keys: Vec<String>) -> Self {
67        self.trusted_keys = keys;
68        self
69    }
70
71    /// Use a custom HTTP client implementation (e.g., for testing).
72    #[must_use]
73    pub fn http_client(mut self, client: Box<dyn HttpClient>) -> Self {
74        self.client = Some(client);
75        self
76    }
77
78    /// Set an authorization header (e.g., `("Bearer", "<token>")` for Attic).
79    #[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    /// Build the [`BinaryCacheStore`].
86    #[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    /// Create a builder for a binary cache store with the given base URL.
99    #[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    /// Create a new binary cache client with default HTTP backend.
110    #[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    /// Create a new binary cache client with a custom HTTP backend.
116    #[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    /// Build the request headers, including auth if configured.
129    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    /// Fetch NarInfo for a store path hash.
141    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    /// Return the base URL of this binary cache (without trailing slash).
170    #[must_use]
171    pub fn base_url(&self) -> &str {
172        &self.base_url
173    }
174
175    /// Return the trusted public keys used for signature verification.
176    #[must_use]
177    pub fn trusted_keys(&self) -> &[String] {
178        &self.trusted_keys
179    }
180
181    /// Return the configured authorization header, if any.
182    #[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    /// Download a NAR file from the cache.
188    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    /// Convert a NarInfo to our PathInfo type.
199    ///
200    /// Delegates to the [`From<&NarInfo>`](PathInfo::from) impl.
201    #[cfg(test)]
202    fn narinfo_to_path_info(info: &NarInfo) -> PathInfo {
203        PathInfo::from(info)
204    }
205
206    /// Get the store path hash (first 32 chars of the basename).
207    fn store_path_hash(path: &StorePath) -> String {
208        let basename = path.to_basename();
209        basename[..32.min(basename.len())].to_string()
210    }
211
212    /// Verify that a NarInfo has at least one valid signature from the trusted keys.
213    ///
214    /// The NarInfo fingerprint is: `1;{storePath};{narHash};{narSize};{sortedReferences}`.
215    /// Each signature in the NarInfo is in `keyname:base64sig` format. Each trusted key
216    /// is in `keyname:base64pubkey` format.
217    ///
218    /// Returns `Ok(true)` if at least one signature matches a trusted key,
219    /// `Ok(false)` if no trusted keys are provided or no signatures match.
220    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        // Build the sorted references for the fingerprint.
232        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        // Build a map of key_name -> public_key_bytes from trusted keys.
243        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        // Check each signature against the matching trusted key.
253        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        // NarInfo references are bare basenames; the PathInfo conversion
316        // must prefix them with the store directory.
317        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    // ── MockHttpClient (local to binary_cache tests) ─────────
375
376    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    // Valid NarInfo text for mock responses.
410    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    // ── fetch_narinfo with valid response ────────────────────
431
432    #[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    // ── fetch_narinfo with 404 ──────────────────────────────
461
462    #[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    // ── fetch_narinfo with HTTP error status ────────────────
485
486    #[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    // ── query_path_info through Store trait ──────────────────
508
509    #[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    // ── is_valid_path through Store trait ─────────────────────
537
538    #[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    // ── query_all_valid_paths is unsupported ─────────────────
575
576    #[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    // ── narinfo_to_path_info preserves content_address ───────
590
591    #[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    // ── store_path_hash with short basename ──────────────────
615
616    #[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    // ── base_url trailing slash normalization ─────────────────
628
629    #[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        // Only one trailing slash should be stripped by trim_end_matches
638        // but the URL should not have a trailing slash
639        assert!(!store.base_url.ends_with('/'));
640    }
641
642    // ── fetch_nar with MockHttpClient ───────────────────────
643
644    #[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    // ── fetch_narinfo edge cases ──────────────────────────────
697
698    #[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    // ── Store trait with dyn Store (Arc<dyn Store> pattern) ──
924
925    #[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    // ── BinaryCacheError → StoreError conversion ─────────────
984
985    #[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    // ── Builder pattern ─────────────────────────────────────
1048
1049    #[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    // ── store_path_hash edge cases ──────────────────────────
1091
1092    #[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    // ── narinfo with different compression algorithms ────────
1104
1105    #[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    // ── narinfo with content-address (CA) field ──────────────
1202
1203    #[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        // Ensure conversion to PathInfo carries CA
1238        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    // ── narinfo with many references on a single line ───────
1246
1247    #[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    // ── narinfo without optional Deriver field ───────────────
1282
1283    #[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    // ── narinfo with empty Deriver value ─────────────────────
1316
1317    #[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    // ── HTTP status code variations ──────────────────────────
1351
1352    #[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    // ── fetch_nar 4xx/5xx errors ─────────────────────────────
1438
1439    #[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    // ── Default trait methods on BinaryCacheStore ────────────
1458
1459    #[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    // ── query_references via BinaryCacheStore ────────────────
1524    //
1525    // BinaryCacheStore.query_path_info populates PathInfo.references with
1526    // absolute store paths (bare NarInfo basenames are prefixed with
1527    // /nix/store/ at conversion time). The default query_references in the
1528    // Store trait then parses each entry via StorePath::from_absolute_path,
1529    // so the full reference list flows through end to end.
1530
1531    #[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        // PathInfo.references are absolute store paths after the conversion.
1556        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        // query_references parses those absolute paths back into StorePaths,
1564        // yielding the full reference list.
1565        let refs = store.query_references(&hello_store_path()).await.unwrap();
1566        assert_eq!(refs.len(), 2);
1567    }
1568
1569    // ── Box<dyn Store> dispatch ──────────────────────────────
1570
1571    #[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    // ── Reference-prefix gap fix regression tests ────────────
1590
1591    /// Round-trip a NarInfo with multiple bare-basename references through
1592    /// `BinaryCacheStore::query_path_info` and verify every reference comes
1593    /// out as a `/nix/store/`-prefixed absolute store path.
1594    #[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    /// `Store::query_references` (the default trait method) must return a
1649    /// non-empty Vec when the underlying NarInfo had references — proving
1650    /// the silent-drop bug is fixed end to end.
1651    #[tokio::test]
1652    async fn query_references_via_store_returns_full_prefixed_paths() {
1653        // Tiny in-memory mock store that returns a fixed PathInfo whose
1654        // references already came from a NarInfo round-trip.
1655        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    /// A NarInfo whose `References:` line is empty must produce an empty
1711    /// `PathInfo.references` vec (no spurious entries from prefixing logic).
1712    #[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    // ── verify_narinfo_signatures ──────────────────────────────
1746
1747    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        // Use a different key — should fail.
1808        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        // Trusted key has a different name.
1837        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        // The verify method sorts references, so we must sign with sorted refs.
1901        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    // ── Auth header tests ────────────────────────────────────────
1925
1926    #[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}