1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct NarInfo {
21 pub store_path: String,
23 pub url: String,
25 pub compression: String,
27 pub file_hash: String,
29 pub file_size: u64,
31 pub nar_hash: String,
33 pub nar_size: u64,
35 pub references: Vec<String>,
37 pub deriver: Option<String>,
39 pub signatures: Vec<String>,
41 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 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 _ => {} }
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 #[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 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 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 }
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 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 #[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 #[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 #[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 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 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}