Skip to main content

sui_compat/
narinfo.rs

1//! NarInfo binary cache metadata format.
2//!
3//! Clean-room implementation from the NarInfo specification.
4//! Key-value format, one field per line. Content-Type: `text/x-nix-narinfo`.
5
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum NarInfoError {
10    #[error("missing required field: {0}")]
11    MissingField(String),
12    #[error("invalid field value for {field}: {value}")]
13    InvalidValue { field: String, value: String },
14    #[error("parse error: {0}")]
15    Parse(String),
16}
17
18/// Parsed NarInfo metadata for a store path.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct NarInfo {
21    /// Full store path (e.g., `/nix/store/abc...-hello-2.12.1`).
22    pub store_path: String,
23    /// Relative URL to the NAR file.
24    pub url: String,
25    /// Compression method (`xz`, `zstd`, `none`).
26    pub compression: String,
27    /// Hash of the compressed NAR file (`sha256:<hex>`).
28    pub file_hash: String,
29    /// Size of the compressed NAR file in bytes.
30    pub file_size: u64,
31    /// Hash of the uncompressed NAR (`sha256:<hex>`).
32    pub nar_hash: String,
33    /// Size of the uncompressed NAR in bytes.
34    pub nar_size: u64,
35    /// Runtime dependency store path basenames (space-separated in wire format).
36    pub references: Vec<String>,
37    /// `.drv` basename that built this path.
38    pub deriver: Option<String>,
39    /// Ed25519 signatures (`keyname:base64sig`).
40    pub signatures: Vec<String>,
41    /// Content-address assertion (if applicable).
42    pub ca: Option<String>,
43}
44
45impl std::fmt::Display for NarInfo {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(&self.serialize())
48    }
49}
50
51impl std::str::FromStr for NarInfo {
52    type Err = NarInfoError;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        Self::parse(s)
56    }
57}
58
59impl NarInfo {
60    /// Parse a NarInfo from its text representation.
61    pub fn parse(input: &str) -> Result<Self, NarInfoError> {
62        let mut store_path = None;
63        let mut url = None;
64        let mut compression = None;
65        let mut file_hash = None;
66        let mut file_size = None;
67        let mut nar_hash = None;
68        let mut nar_size = None;
69        let mut references = Vec::new();
70        let mut deriver = None;
71        let mut signatures = Vec::new();
72        let mut ca = None;
73
74        for line in input.lines() {
75            let line = line.trim();
76            if line.is_empty() {
77                continue;
78            }
79
80            let (key, value) = line
81                .split_once(':')
82                .ok_or_else(|| NarInfoError::Parse(format!("no colon in line: {line}")))?;
83            let key = key.trim();
84            let value = value.trim();
85
86            match key {
87                "StorePath" => store_path = Some(value.to_string()),
88                "URL" => url = Some(value.to_string()),
89                "Compression" => compression = Some(value.to_string()),
90                "FileHash" => file_hash = Some(value.to_string()),
91                "FileSize" => {
92                    file_size = Some(value.parse::<u64>().map_err(|_| NarInfoError::InvalidValue {
93                        field: "FileSize".to_string(),
94                        value: value.to_string(),
95                    })?);
96                }
97                "NarHash" => nar_hash = Some(value.to_string()),
98                "NarSize" => {
99                    nar_size = Some(value.parse::<u64>().map_err(|_| NarInfoError::InvalidValue {
100                        field: "NarSize".to_string(),
101                        value: value.to_string(),
102                    })?);
103                }
104                "References" => {
105                    if !value.is_empty() {
106                        references = value.split_whitespace().map(String::from).collect();
107                    }
108                }
109                "Deriver" => {
110                    if !value.is_empty() {
111                        deriver = Some(value.to_string());
112                    }
113                }
114                "Sig" => signatures.push(value.to_string()),
115                "CA" => ca = Some(value.to_string()),
116                _ => {} // Ignore unknown fields
117            }
118        }
119
120        Ok(NarInfo {
121            store_path: store_path.ok_or_else(|| NarInfoError::MissingField("StorePath".to_string()))?,
122            url: url.ok_or_else(|| NarInfoError::MissingField("URL".to_string()))?,
123            compression: compression.unwrap_or_else(|| "none".to_string()),
124            file_hash: file_hash.ok_or_else(|| NarInfoError::MissingField("FileHash".to_string()))?,
125            file_size: file_size.ok_or_else(|| NarInfoError::MissingField("FileSize".to_string()))?,
126            nar_hash: nar_hash.ok_or_else(|| NarInfoError::MissingField("NarHash".to_string()))?,
127            nar_size: nar_size.ok_or_else(|| NarInfoError::MissingField("NarSize".to_string()))?,
128            references,
129            deriver,
130            signatures,
131            ca,
132        })
133    }
134
135    /// Serialize to the NarInfo text format.
136    #[must_use]
137    pub fn serialize(&self) -> String {
138        use std::fmt::Write;
139        let mut out = String::new();
140        let _ = writeln!(out, "StorePath: {}", self.store_path);
141        let _ = writeln!(out, "URL: {}", self.url);
142        let _ = writeln!(out, "Compression: {}", self.compression);
143        let _ = writeln!(out, "FileHash: {}", self.file_hash);
144        let _ = writeln!(out, "FileSize: {}", self.file_size);
145        let _ = writeln!(out, "NarHash: {}", self.nar_hash);
146        let _ = writeln!(out, "NarSize: {}", self.nar_size);
147        let _ = writeln!(out, "References: {}", self.references.join(" "));
148        if let Some(ref d) = self.deriver {
149            let _ = writeln!(out, "Deriver: {d}");
150        }
151        for sig in &self.signatures {
152            let _ = writeln!(out, "Sig: {sig}");
153        }
154        if let Some(ref ca) = self.ca {
155            let _ = writeln!(out, "CA: {ca}");
156        }
157        out
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    const SAMPLE_NARINFO: &str = "\
166StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
167URL: nar/1nhgq6wcggx0plpy4991h3ginj6hipsdslv4fd5eqbhwc1q8ydsn.nar.xz
168Compression: xz
169FileHash: sha256:0d6cc2d69a89a98d02b21e7b725e3c2a4d3eec166ccaee16f14dc67c3a8c6cd0
170FileSize: 42856
171NarHash: sha256:1b0ri5lsf45dknj8bfxi1syz35kmab77apxxg1yrf33la1qm3kc7
172NarSize: 226552
173References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8 sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
174Deriver: xb4y5iklhya4blk42k1cfkb8k07dpp4n-hello-2.12.1.drv
175Sig: cache.nixos.org-1:8ijECciSFzWHwwGVOIVYdp2fOIOJAfgKhsVlKj/trdKJAjQMEhWNNBAPJRnlBNzA7buqPhLox5NW3S0EKgqICw==
176";
177
178    #[test]
179    fn parse_sample() {
180        let info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
181        assert_eq!(info.store_path, "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1");
182        assert_eq!(info.compression, "xz");
183        assert_eq!(info.file_size, 42856);
184        assert_eq!(info.nar_size, 226552);
185        assert_eq!(info.references.len(), 2);
186        assert!(info.deriver.is_some());
187        assert_eq!(info.signatures.len(), 1);
188    }
189
190    #[test]
191    fn serialize_roundtrip() {
192        let info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
193        let serialized = info.serialize();
194        let reparsed = NarInfo::parse(&serialized).unwrap();
195        assert_eq!(info, reparsed);
196    }
197
198    #[test]
199    fn missing_store_path() {
200        let input = "URL: nar/foo.nar\nNarHash: sha256:abc\nNarSize: 100\nFileHash: sha256:def\nFileSize: 50\n";
201        assert!(NarInfo::parse(input).is_err());
202    }
203
204    #[test]
205    fn no_references() {
206        let input = "\
207StorePath: /nix/store/abc-foo
208URL: nar/foo.nar
209Compression: none
210FileHash: sha256:abc
211FileSize: 100
212NarHash: sha256:def
213NarSize: 200
214References: \n";
215        let info = NarInfo::parse(input).unwrap();
216        assert!(info.references.is_empty());
217    }
218
219    #[test]
220    fn multiple_signatures() {
221        let input = "\
222StorePath: /nix/store/abc-foo
223URL: nar/foo.nar
224Compression: none
225FileHash: sha256:abc
226FileSize: 100
227NarHash: sha256:def
228NarSize: 200
229References: \n\
230Sig: key1:aaa==
231Sig: key2:bbb==\n";
232        let info = NarInfo::parse(input).unwrap();
233        assert_eq!(info.signatures.len(), 2);
234        assert_eq!(info.signatures[0], "key1:aaa==");
235        assert_eq!(info.signatures[1], "key2:bbb==");
236    }
237
238    #[test]
239    fn narinfo_with_content_address() {
240        let input = "\
241StorePath: /nix/store/abc-source.tar.gz
242URL: nar/abc.nar.xz
243Compression: xz
244FileHash: sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
245FileSize: 5000
246NarHash: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
247NarSize: 10000
248References: \n\
249CA: fixed:out:r:sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n";
250        let info = NarInfo::parse(input).unwrap();
251        assert_eq!(info.ca, Some("fixed:out:r:sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()));
252
253        // Roundtrip preserves CA
254        let serialized = info.serialize();
255        let reparsed = NarInfo::parse(&serialized).unwrap();
256        assert_eq!(reparsed.ca, info.ca);
257    }
258
259    #[test]
260    fn narinfo_with_no_deriver() {
261        let input = "\
262StorePath: /nix/store/abc-foo
263URL: nar/foo.nar
264Compression: none
265FileHash: sha256:abc
266FileSize: 100
267NarHash: sha256:def
268NarSize: 200
269References: \n";
270        let info = NarInfo::parse(input).unwrap();
271        assert!(info.deriver.is_none());
272
273        // Serialized output should not contain Deriver line
274        let serialized = info.serialize();
275        assert!(!serialized.contains("Deriver:"));
276    }
277
278    #[test]
279    fn narinfo_with_empty_references() {
280        let input = "\
281StorePath: /nix/store/abc-foo
282URL: nar/foo.nar
283Compression: none
284FileHash: sha256:abc
285FileSize: 100
286NarHash: sha256:def
287NarSize: 200
288References: \n";
289        let info = NarInfo::parse(input).unwrap();
290        assert!(info.references.is_empty());
291    }
292
293    #[test]
294    fn unknown_fields_ignored() {
295        let input = "\
296StorePath: /nix/store/abc-foo
297URL: nar/foo.nar
298Compression: none
299FileHash: sha256:abc
300FileSize: 100
301NarHash: sha256:def
302NarSize: 200
303References: \n\
304FutureField: some-value
305AnotherUnknown: 42\n";
306        let info = NarInfo::parse(input).unwrap();
307        assert_eq!(info.store_path, "/nix/store/abc-foo");
308        // Unknown fields should not cause errors
309    }
310
311    #[test]
312    fn missing_url_field() {
313        let input = "\
314StorePath: /nix/store/abc-foo
315Compression: none
316FileHash: sha256:abc
317FileSize: 100
318NarHash: sha256:def
319NarSize: 200
320References: \n";
321        match NarInfo::parse(input) {
322            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "URL"),
323            other => panic!("expected MissingField(URL), got {other:?}"),
324        }
325    }
326
327    #[test]
328    fn missing_nar_hash_field() {
329        let input = "\
330StorePath: /nix/store/abc-foo
331URL: nar/foo.nar
332Compression: none
333FileHash: sha256:abc
334FileSize: 100
335NarSize: 200
336References: \n";
337        match NarInfo::parse(input) {
338            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "NarHash"),
339            other => panic!("expected MissingField(NarHash), got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn missing_file_hash_field() {
345        let input = "\
346StorePath: /nix/store/abc-foo
347URL: nar/foo.nar
348Compression: none
349FileSize: 100
350NarHash: sha256:def
351NarSize: 200
352References: \n";
353        match NarInfo::parse(input) {
354            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "FileHash"),
355            other => panic!("expected MissingField(FileHash), got {other:?}"),
356        }
357    }
358
359    #[test]
360    fn invalid_file_size_value() {
361        let input = "\
362StorePath: /nix/store/abc-foo
363URL: nar/foo.nar
364FileHash: sha256:abc
365FileSize: not-a-number
366NarHash: sha256:def
367NarSize: 200
368References: \n";
369        match NarInfo::parse(input) {
370            Err(NarInfoError::InvalidValue { field, .. }) => assert_eq!(field, "FileSize"),
371            other => panic!("expected InvalidValue for FileSize, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn default_compression_when_missing() {
377        let input = "\
378StorePath: /nix/store/abc-foo
379URL: nar/foo.nar
380FileHash: sha256:abc
381FileSize: 100
382NarHash: sha256:def
383NarSize: 200
384References: \n";
385        let info = NarInfo::parse(input).unwrap();
386        assert_eq!(info.compression, "none");
387    }
388
389    // ── Construct a fully-populated NarInfo for additional tests ──
390
391    fn make_full_narinfo() -> NarInfo {
392        NarInfo {
393            store_path: "/nix/store/abc-pkg".to_string(),
394            url: "nar/abc.nar.xz".to_string(),
395            compression: "xz".to_string(),
396            file_hash: "sha256:1111".to_string(),
397            file_size: 1234,
398            nar_hash: "sha256:2222".to_string(),
399            nar_size: 5678,
400            references: vec!["dep1".to_string(), "dep2".to_string()],
401            deriver: Some("def-pkg.drv".to_string()),
402            signatures: vec!["k1:s1".to_string(), "k2:s2".to_string()],
403            ca: Some("fixed:out:r:sha256:cafe".to_string()),
404        }
405    }
406
407    // ── Display + FromStr ────────────────────────────────
408
409    #[test]
410    fn display_trait_matches_serialize() {
411        let info = make_full_narinfo();
412        let s = format!("{info}");
413        assert_eq!(s, info.serialize());
414    }
415
416    #[test]
417    fn from_str_trait_matches_parse() {
418        use std::str::FromStr;
419        let info = make_full_narinfo();
420        let s = info.serialize();
421        let parsed = NarInfo::from_str(&s).unwrap();
422        assert_eq!(parsed, info);
423    }
424
425    // ── Roundtrip preserves every field ──────────────────
426
427    #[test]
428    fn full_roundtrip_preserves_every_field() {
429        let info = make_full_narinfo();
430        let s = info.serialize();
431        let parsed = NarInfo::parse(&s).unwrap();
432        assert_eq!(parsed.store_path, info.store_path);
433        assert_eq!(parsed.url, info.url);
434        assert_eq!(parsed.compression, info.compression);
435        assert_eq!(parsed.file_hash, info.file_hash);
436        assert_eq!(parsed.file_size, info.file_size);
437        assert_eq!(parsed.nar_hash, info.nar_hash);
438        assert_eq!(parsed.nar_size, info.nar_size);
439        assert_eq!(parsed.references, info.references);
440        assert_eq!(parsed.deriver, info.deriver);
441        assert_eq!(parsed.signatures, info.signatures);
442        assert_eq!(parsed.ca, info.ca);
443    }
444
445    // ── Missing required field variants ──────────────────
446
447    #[test]
448    fn missing_file_size_field() {
449        let input = "\
450StorePath: /nix/store/abc-foo
451URL: nar/foo.nar
452FileHash: sha256:abc
453NarHash: sha256:def
454NarSize: 200
455References: \n";
456        match NarInfo::parse(input) {
457            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "FileSize"),
458            other => panic!("expected MissingField(FileSize), got {other:?}"),
459        }
460    }
461
462    #[test]
463    fn missing_nar_size_field() {
464        let input = "\
465StorePath: /nix/store/abc-foo
466URL: nar/foo.nar
467FileHash: sha256:abc
468FileSize: 100
469NarHash: sha256:def
470References: \n";
471        match NarInfo::parse(input) {
472            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "NarSize"),
473            other => panic!("expected MissingField(NarSize), got {other:?}"),
474        }
475    }
476
477    #[test]
478    fn invalid_nar_size_value() {
479        let input = "\
480StorePath: /nix/store/abc-foo
481URL: nar/foo.nar
482FileHash: sha256:abc
483FileSize: 100
484NarHash: sha256:def
485NarSize: not-numeric
486References: \n";
487        match NarInfo::parse(input) {
488            Err(NarInfoError::InvalidValue { field, value }) => {
489                assert_eq!(field, "NarSize");
490                assert_eq!(value, "not-numeric");
491            }
492            other => panic!("expected InvalidValue for NarSize, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn parse_error_no_colon_in_line() {
498        let input = "this-is-not-a-key-value-line\n";
499        match NarInfo::parse(input) {
500            Err(NarInfoError::Parse(_)) => {}
501            other => panic!("expected Parse error, got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn empty_input_returns_missing_field() {
507        // Empty input → first missing required field is StorePath
508        match NarInfo::parse("") {
509            Err(NarInfoError::MissingField(f)) => assert_eq!(f, "StorePath"),
510            other => panic!("expected MissingField, got {other:?}"),
511        }
512    }
513
514    #[test]
515    fn blank_lines_ignored() {
516        let input = "\
517\n\
518\n\
519StorePath: /nix/store/abc-foo\n\
520\n\
521URL: nar/foo.nar\n\
522\n\
523Compression: none\n\
524FileHash: sha256:abc\n\
525FileSize: 100\n\
526NarHash: sha256:def\n\
527NarSize: 200\n\
528References: \n";
529        let info = NarInfo::parse(input).unwrap();
530        assert_eq!(info.store_path, "/nix/store/abc-foo");
531    }
532
533    #[test]
534    fn references_split_on_whitespace() {
535        let input = "\
536StorePath: /nix/store/abc-foo
537URL: nar/foo.nar
538Compression: none
539FileHash: sha256:abc
540FileSize: 100
541NarHash: sha256:def
542NarSize: 200
543References: a-1 b-2 c-3 d-4 e-5\n";
544        let info = NarInfo::parse(input).unwrap();
545        assert_eq!(info.references.len(), 5);
546        assert_eq!(info.references[0], "a-1");
547        assert_eq!(info.references[4], "e-5");
548    }
549
550    #[test]
551    fn three_signatures_preserved_in_order() {
552        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
553        info.signatures = vec![
554            "k1:s1".to_string(),
555            "k2:s2".to_string(),
556            "k3:s3".to_string(),
557        ];
558        let s = info.serialize();
559        let parsed = NarInfo::parse(&s).unwrap();
560        assert_eq!(parsed.signatures, info.signatures);
561    }
562
563    #[test]
564    fn deriver_field_serializes_when_some() {
565        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
566        info.deriver = Some("xyz.drv".to_string());
567        let s = info.serialize();
568        assert!(s.contains("Deriver: xyz.drv"));
569    }
570
571    #[test]
572    fn ca_field_serializes_when_some() {
573        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
574        info.ca = Some("text:sha256:1234".to_string());
575        let s = info.serialize();
576        assert!(s.contains("CA: text:sha256:1234"));
577    }
578
579    #[test]
580    fn ca_field_omitted_when_none() {
581        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
582        info.ca = None;
583        let s = info.serialize();
584        assert!(!s.contains("CA:"));
585    }
586
587    #[test]
588    fn deriver_field_omitted_when_none() {
589        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
590        info.deriver = None;
591        let s = info.serialize();
592        assert!(!s.contains("Deriver:"));
593    }
594
595    #[test]
596    fn maximum_file_size_value() {
597        let input = format!(
598            "StorePath: /nix/store/abc-foo\n\
599URL: nar/foo.nar\n\
600Compression: none\n\
601FileHash: sha256:abc\n\
602FileSize: {}\n\
603NarHash: sha256:def\n\
604NarSize: {}\n\
605References: \n",
606            u64::MAX,
607            u64::MAX,
608        );
609        let info = NarInfo::parse(&input).unwrap();
610        assert_eq!(info.file_size, u64::MAX);
611        assert_eq!(info.nar_size, u64::MAX);
612    }
613
614    #[test]
615    fn references_dedup_preserved_order() {
616        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
617        info.references = vec!["a".to_string(), "b".to_string(), "a".to_string()];
618        // serialize doesn't dedup; round-trip preserves duplicates
619        let s = info.serialize();
620        let parsed = NarInfo::parse(&s).unwrap();
621        assert_eq!(parsed.references, vec!["a".to_string(), "b".to_string(), "a".to_string()]);
622    }
623
624    #[test]
625    fn whitespace_in_field_values_trimmed() {
626        let input = "\
627StorePath:    /nix/store/abc-foo
628URL:    nar/foo.nar
629Compression:    none
630FileHash:    sha256:abc
631FileSize:    100
632NarHash:    sha256:def
633NarSize:    200
634References:    \n";
635        let info = NarInfo::parse(input).unwrap();
636        assert_eq!(info.store_path, "/nix/store/abc-foo");
637        assert_eq!(info.url, "nar/foo.nar");
638    }
639
640    #[test]
641    fn signature_with_complex_base64_preserved() {
642        let sig = "cache.example.org-1:ABCDEFG+/=Hijklmn==";
643        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
644        info.signatures = vec![sig.to_string()];
645        let parsed = NarInfo::parse(&info.serialize()).unwrap();
646        assert_eq!(parsed.signatures, vec![sig.to_string()]);
647    }
648
649    #[test]
650    fn url_with_query_string_preserved() {
651        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
652        info.url = "nar/abc.nar.xz?token=xyz".to_string();
653        let parsed = NarInfo::parse(&info.serialize()).unwrap();
654        assert_eq!(parsed.url, "nar/abc.nar.xz?token=xyz");
655    }
656
657    #[test]
658    fn store_path_with_long_name_preserved() {
659        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
660        let long = format!("/nix/store/{}-{}", "a".repeat(32), "x".repeat(200));
661        info.store_path = long.clone();
662        let parsed = NarInfo::parse(&info.serialize()).unwrap();
663        assert_eq!(parsed.store_path, long);
664    }
665
666    #[test]
667    fn many_references_preserved() {
668        let mut info = NarInfo::parse(SAMPLE_NARINFO).unwrap();
669        info.references = (0..50).map(|i| format!("ref-{i:03}-name")).collect();
670        let parsed = NarInfo::parse(&info.serialize()).unwrap();
671        assert_eq!(parsed.references.len(), 50);
672        assert_eq!(parsed.references[0], "ref-000-name");
673        assert_eq!(parsed.references[49], "ref-049-name");
674    }
675}