1use serde::{Deserialize, Serialize};
28use tatara_lisp::DeriveTataraDomain;
29
30use crate::SpecError;
31
32#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
36#[tatara(keyword = "defnar-format")]
37pub struct NarFormat {
38 pub name: String,
39 pub magic: String,
41 pub encoding: NarEncoding,
43 #[serde(rename = "entryTypes")]
45 pub entry_types: Vec<NarEntryType>,
46 pub phases: Vec<NarPhase>,
47}
48
49#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
52pub enum NarEncoding {
53 LengthPrefixedString,
55 Cbor,
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum NarEntryType {
63 Regular,
65 Executable,
67 Directory,
69 Symlink,
71}
72
73#[derive(Serialize, Deserialize, Debug, Clone)]
75pub struct NarPhase {
76 pub kind: NarPhaseKind,
77 #[serde(default)]
78 pub bind: Option<String>,
79 #[serde(default)]
80 pub from: Option<String>,
81}
82
83#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85pub enum NarPhaseKind {
86 ReadMagic,
88 ParseRootNode,
90 StreamEntries,
92 ValidateChecksum,
95 EmitToSink,
97 BuildTree,
99}
100
101pub fn validate_magic(format: &NarFormat, input: &[u8]) -> Result<(), SpecError> {
115 let magic_bytes = format.magic.as_bytes();
119 let needed = 8 + ((magic_bytes.len() + 7) & !7);
120 if input.len() < needed {
121 return Err(SpecError::Interp {
122 phase: "nar-too-short".into(),
123 message: format!(
124 "input is {} bytes, expected at least {needed} for framed magic `{}`",
125 input.len(),
126 format.magic,
127 ),
128 });
129 }
130 let mut len_bytes = [0u8; 8];
132 len_bytes.copy_from_slice(&input[0..8]);
133 let declared_len = u64::from_le_bytes(len_bytes) as usize;
134 if declared_len != magic_bytes.len() {
135 return Err(SpecError::Interp {
136 phase: "nar-bad-magic".into(),
137 message: format!(
138 "magic-len header {declared_len} != expected {}",
139 magic_bytes.len(),
140 ),
141 });
142 }
143 let magic_in = &input[8..8 + magic_bytes.len()];
145 if magic_in != magic_bytes {
146 return Err(SpecError::Interp {
147 phase: "nar-bad-magic".into(),
148 message: format!(
149 "magic bytes mismatch: got `{}`, expected `{}`",
150 String::from_utf8_lossy(magic_in),
151 format.magic,
152 ),
153 });
154 }
155 Ok(())
156}
157
158#[must_use]
161pub fn pack_framed(s: &str) -> Vec<u8> {
162 let bytes = s.as_bytes();
163 let pad = (8 - (bytes.len() % 8)) % 8;
164 let mut out = Vec::with_capacity(8 + bytes.len() + pad);
165 out.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
166 out.extend_from_slice(bytes);
167 out.extend(std::iter::repeat_n(0u8, pad));
168 out
169}
170
171pub fn apply(format: &NarFormat, input: &[u8]) -> Result<String, SpecError> {
181 validate_magic(format, input)?;
182 Ok(format!(
183 "magic ok ({} bytes), full parse M3.1 (sui-compat::nar)",
184 input.len(),
185 ))
186}
187
188pub fn encode(root: &std::path::Path) -> Result<Vec<u8>, std::io::Error> {
205 let mut buf = Vec::new();
206 write_framed(&mut buf, b"nix-archive-1");
207 write_node(&mut buf, root)?;
208 Ok(buf)
209}
210
211pub fn hash_path_nar(root: &std::path::Path) -> Result<[u8; 32], std::io::Error> {
219 use sha2::Digest;
220 let nar = encode(root)?;
221 let digest = sha2::Sha256::digest(&nar);
222 Ok(digest.into())
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum NarDecodeError {
235 BadMagic { found: String },
236 BadFraming { at: usize, message: String },
237 UnknownEntryType { name: String },
238 TooShort { at: usize },
239 Io(String),
240}
241
242impl std::fmt::Display for NarDecodeError {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::BadMagic { found } => write!(f, "bad NAR magic: {found:?}"),
246 Self::BadFraming { at, message } => write!(f, "bad framing at {at}: {message}"),
247 Self::UnknownEntryType { name } => write!(f, "unknown entry type: {name:?}"),
248 Self::TooShort { at } => write!(f, "input too short at {at}"),
249 Self::Io(m) => write!(f, "io: {m}"),
250 }
251 }
252}
253
254impl std::error::Error for NarDecodeError {}
255
256pub fn decode(input: &[u8], dest: &std::path::Path) -> Result<(), NarDecodeError> {
264 let mut cur = Cursor::new(input);
265 let magic = cur.read_framed_string()?;
266 if magic != b"nix-archive-1" {
267 return Err(NarDecodeError::BadMagic {
268 found: String::from_utf8_lossy(&magic).to_string(),
269 });
270 }
271 read_node(&mut cur, dest)?;
272 Ok(())
273}
274
275pub fn decode_from<R: std::io::Read>(
283 mut input: R,
284 dest: &std::path::Path,
285) -> Result<(), NarDecodeError> {
286 let mut buf = Vec::new();
287 input.read_to_end(&mut buf).map_err(|e| NarDecodeError::Io(e.to_string()))?;
288 decode(&buf, dest)
289}
290
291struct Cursor<'a> {
292 data: &'a [u8],
293 pos: usize,
294}
295
296impl<'a> Cursor<'a> {
297 fn new(data: &'a [u8]) -> Self { Self { data, pos: 0 } }
298
299 fn read_u64_le(&mut self) -> Result<u64, NarDecodeError> {
300 if self.pos + 8 > self.data.len() {
301 return Err(NarDecodeError::TooShort { at: self.pos });
302 }
303 let mut buf = [0u8; 8];
304 buf.copy_from_slice(&self.data[self.pos..self.pos + 8]);
305 self.pos += 8;
306 Ok(u64::from_le_bytes(buf))
307 }
308
309 fn read_framed_string(&mut self) -> Result<Vec<u8>, NarDecodeError> {
310 let len = self.read_u64_le()? as usize;
311 if self.pos + len > self.data.len() {
312 return Err(NarDecodeError::TooShort { at: self.pos });
313 }
314 let bytes = self.data[self.pos..self.pos + len].to_vec();
315 self.pos += len;
316 let pad = (8 - (len % 8)) % 8;
318 if self.pos + pad > self.data.len() {
319 return Err(NarDecodeError::TooShort { at: self.pos });
320 }
321 self.pos += pad;
322 Ok(bytes)
323 }
324
325 fn read_framed_str(&mut self) -> Result<String, NarDecodeError> {
326 let bytes = self.read_framed_string()?;
327 String::from_utf8(bytes).map_err(|e| NarDecodeError::BadFraming {
328 at: self.pos,
329 message: format!("invalid utf-8: {e}"),
330 })
331 }
332
333 fn expect_string(&mut self, expected: &[u8]) -> Result<(), NarDecodeError> {
334 let got = self.read_framed_string()?;
335 if got != expected {
336 return Err(NarDecodeError::BadFraming {
337 at: self.pos,
338 message: format!(
339 "expected {:?}, got {:?}",
340 String::from_utf8_lossy(expected),
341 String::from_utf8_lossy(&got),
342 ),
343 });
344 }
345 Ok(())
346 }
347}
348
349fn read_node(cur: &mut Cursor, dest: &std::path::Path) -> Result<(), NarDecodeError> {
350 cur.expect_string(b"(")?;
351 cur.expect_string(b"type")?;
352 let entry_type = cur.read_framed_str()?;
353 match entry_type.as_str() {
354 "regular" => read_regular(cur, dest)?,
355 "directory" => read_directory(cur, dest)?,
356 "symlink" => read_symlink(cur, dest)?,
357 other => return Err(NarDecodeError::UnknownEntryType {
358 name: other.to_string(),
359 }),
360 }
361 cur.expect_string(b")")?;
362 Ok(())
363}
364
365fn read_regular(cur: &mut Cursor, dest: &std::path::Path) -> Result<(), NarDecodeError> {
366 let mut executable = false;
367 let tag = cur.read_framed_str()?;
369 let bytes = if tag == "executable" {
370 executable = true;
371 let _ = cur.read_framed_string()?; cur.expect_string(b"contents")?;
373 cur.read_framed_string()?
374 } else if tag == "contents" {
375 cur.read_framed_string()?
376 } else {
377 return Err(NarDecodeError::BadFraming {
378 at: cur.pos,
379 message: format!("regular: expected `executable` or `contents`, got {tag:?}"),
380 });
381 };
382 if let Some(parent) = dest.parent() {
383 if !parent.as_os_str().is_empty() {
384 std::fs::create_dir_all(parent)
385 .map_err(|e| NarDecodeError::Io(e.to_string()))?;
386 }
387 }
388 std::fs::write(dest, &bytes).map_err(|e| NarDecodeError::Io(e.to_string()))?;
389 #[cfg(unix)]
390 if executable {
391 use std::os::unix::fs::PermissionsExt;
392 let mut perms = std::fs::metadata(dest)
393 .map_err(|e| NarDecodeError::Io(e.to_string()))?.permissions();
394 perms.set_mode(perms.mode() | 0o111);
395 std::fs::set_permissions(dest, perms)
396 .map_err(|e| NarDecodeError::Io(e.to_string()))?;
397 }
398 #[cfg(not(unix))]
399 let _ = executable;
400 Ok(())
401}
402
403fn read_directory(cur: &mut Cursor, dest: &std::path::Path) -> Result<(), NarDecodeError> {
404 std::fs::create_dir_all(dest).map_err(|e| NarDecodeError::Io(e.to_string()))?;
405 loop {
406 let save_pos = cur.pos;
409 let tag = cur.read_framed_string()?;
410 match tag.as_slice() {
411 b"entry" => {
412 cur.expect_string(b"(")?;
413 cur.expect_string(b"name")?;
414 let name = cur.read_framed_str()?;
415 cur.expect_string(b"node")?;
416 read_node(cur, &dest.join(&name))?;
417 cur.expect_string(b")")?;
418 }
419 b")" => {
420 cur.pos = save_pos;
422 return Ok(());
423 }
424 other => return Err(NarDecodeError::BadFraming {
425 at: cur.pos,
426 message: format!("directory: unexpected tag {:?}", String::from_utf8_lossy(other)),
427 }),
428 }
429 }
430}
431
432fn read_symlink(cur: &mut Cursor, dest: &std::path::Path) -> Result<(), NarDecodeError> {
433 cur.expect_string(b"target")?;
434 let target = cur.read_framed_str()?;
435 #[cfg(unix)]
436 {
437 use std::os::unix::fs as unix_fs;
438 if let Some(parent) = dest.parent() {
439 if !parent.as_os_str().is_empty() {
440 std::fs::create_dir_all(parent)
441 .map_err(|e| NarDecodeError::Io(e.to_string()))?;
442 }
443 }
444 unix_fs::symlink(target, dest)
445 .map_err(|e| NarDecodeError::Io(e.to_string()))?;
446 }
447 #[cfg(not(unix))]
448 let _ = (target, dest);
449 Ok(())
450}
451
452#[must_use]
456pub fn store_path_for(store_root: &str, digest: &[u8; 32], name: &str) -> String {
457 let hash_b32 = crate::hash::encode_hash("sha256", "nix-base32", digest)
458 .expect("nix-base32 encoding always succeeds for sha256 digests");
459 let bare = hash_b32.strip_prefix("sha256:").unwrap_or(&hash_b32);
460 let store_hash: String = bare.chars().take(32).collect();
461 format!("{store_root}/{store_hash}-{name}")
462}
463
464#[doc(hidden)]
467pub fn write_string_for_test(buf: &mut Vec<u8>, s: &[u8]) {
468 write_framed(buf, s);
469}
470
471fn write_framed(buf: &mut Vec<u8>, s: &[u8]) {
473 buf.extend_from_slice(&(s.len() as u64).to_le_bytes());
474 buf.extend_from_slice(s);
475 let pad = (8 - (s.len() % 8)) % 8;
476 for _ in 0..pad { buf.push(0); }
477}
478
479fn write_node(buf: &mut Vec<u8>, path: &std::path::Path) -> std::io::Result<()> {
480 write_framed(buf, b"(");
481 let meta = std::fs::symlink_metadata(path)?;
482 if meta.is_file() {
483 write_framed(buf, b"type");
484 write_framed(buf, b"regular");
485 #[cfg(unix)]
486 {
487 use std::os::unix::fs::PermissionsExt;
488 if meta.permissions().mode() & 0o111 != 0 {
489 write_framed(buf, b"executable");
490 write_framed(buf, b"");
491 }
492 }
493 write_framed(buf, b"contents");
494 let bytes = std::fs::read(path)?;
495 write_framed(buf, &bytes);
496 } else if meta.is_dir() {
497 write_framed(buf, b"type");
498 write_framed(buf, b"directory");
499 let mut entries: Vec<_> = std::fs::read_dir(path)?
500 .filter_map(Result::ok)
501 .collect();
502 entries.sort_by_key(|e| e.file_name());
503 for entry in entries {
504 write_framed(buf, b"entry");
505 write_framed(buf, b"(");
506 write_framed(buf, b"name");
507 write_framed(buf, entry.file_name().to_string_lossy().as_bytes());
508 write_framed(buf, b"node");
509 write_node(buf, &entry.path())?;
510 write_framed(buf, b")");
511 }
512 } else if meta.file_type().is_symlink() {
513 write_framed(buf, b"type");
514 write_framed(buf, b"symlink");
515 write_framed(buf, b"target");
516 let target = std::fs::read_link(path)?;
517 write_framed(buf, target.to_string_lossy().as_bytes());
518 }
519 write_framed(buf, b")");
520 Ok(())
521}
522
523pub const CANONICAL_NAR_LISP: &str = include_str!("../specs/nar.lisp");
526
527pub fn load_canonical() -> Result<Vec<NarFormat>, SpecError> {
533 crate::loader::load_all::<NarFormat>(CANONICAL_NAR_LISP)
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn canonical_nar_parses() {
542 let formats = load_canonical().expect("canonical NAR must compile");
543 assert!(!formats.is_empty());
544 }
545
546 #[test]
547 fn cppnix_nar_has_correct_magic() {
548 let formats = load_canonical().unwrap();
549 let cppnix = formats
550 .iter()
551 .find(|f| f.name == "cppnix-nar")
552 .expect("cppnix-nar must exist");
553 assert_eq!(cppnix.magic, "nix-archive-1");
554 assert_eq!(cppnix.encoding, NarEncoding::LengthPrefixedString);
555 }
556
557 #[test]
558 fn cppnix_nar_covers_four_entry_types() {
559 let formats = load_canonical().unwrap();
560 let cppnix = formats.iter().find(|f| f.name == "cppnix-nar").unwrap();
561 let types: std::collections::HashSet<NarEntryType> =
562 cppnix.entry_types.iter().copied().collect();
563 for required in [
564 NarEntryType::Regular,
565 NarEntryType::Executable,
566 NarEntryType::Directory,
567 NarEntryType::Symlink,
568 ] {
569 assert!(
570 types.contains(&required),
571 "cppnix-nar missing entry type {required:?}",
572 );
573 }
574 }
575
576 #[test]
577 fn nar_phases_must_read_magic_first() {
578 let formats = load_canonical().unwrap();
579 for f in &formats {
580 let kinds: Vec<NarPhaseKind> = f.phases.iter().map(|p| p.kind).collect();
581 assert_eq!(
582 kinds[0],
583 NarPhaseKind::ReadMagic,
584 "{}: first phase must be ReadMagic",
585 f.name,
586 );
587 }
588 }
589
590 fn cppnix() -> NarFormat {
593 load_canonical().unwrap().into_iter()
594 .find(|f| f.name == "cppnix-nar")
595 .unwrap()
596 }
597
598 #[test]
599 fn pack_framed_roundtrip_shape() {
600 let packed = pack_framed("nix-archive-1");
603 assert_eq!(packed.len(), 8 + 16);
604 let mut len_bytes = [0u8; 8];
606 len_bytes.copy_from_slice(&packed[0..8]);
607 assert_eq!(u64::from_le_bytes(len_bytes), 13);
608 }
609
610 #[test]
611 fn validate_magic_passes_on_correct_bytes() {
612 let format = cppnix();
613 let packed = pack_framed(&format.magic);
614 validate_magic(&format, &packed).unwrap();
615 }
616
617 #[test]
618 fn validate_magic_rejects_short_input() {
619 let format = cppnix();
620 let err = validate_magic(&format, &[0u8; 3]).unwrap_err();
621 match err {
622 SpecError::Interp { phase, .. } => assert_eq!(phase, "nar-too-short"),
623 _ => panic!("expected nar-too-short"),
624 }
625 }
626
627 #[test]
628 fn validate_magic_rejects_wrong_magic() {
629 let format = cppnix();
630 let packed = pack_framed("wrong-archive-id");
631 let err = validate_magic(&format, &packed).unwrap_err();
632 match err {
633 SpecError::Interp { phase, .. } => assert_eq!(phase, "nar-bad-magic"),
634 _ => panic!("expected nar-bad-magic"),
635 }
636 }
637
638 #[test]
639 fn apply_succeeds_on_well_framed_magic() {
640 let format = cppnix();
641 let packed = pack_framed(&format.magic);
642 let msg = apply(&format, &packed).unwrap();
643 assert!(msg.contains("magic ok"));
644 assert!(msg.contains("M3.1"));
645 }
646
647 #[test]
650 fn encode_file_starts_with_magic() {
651 let tmp = std::env::temp_dir().join("sui-spec-nar-file-test");
652 std::fs::write(&tmp, b"hello").unwrap();
653 let nar = encode(&tmp).unwrap();
654 let _ = std::fs::remove_file(&tmp);
655 let len = u64::from_le_bytes(nar[0..8].try_into().unwrap());
657 assert_eq!(len, 13);
658 assert_eq!(&nar[8..21], b"nix-archive-1");
659 }
660
661 #[test]
662 fn encode_directory_is_sorted() {
663 let tmp = std::env::temp_dir().join("sui-spec-nar-dir-test");
667 let _ = std::fs::remove_dir_all(&tmp);
668 std::fs::create_dir_all(&tmp).unwrap();
669 std::fs::write(tmp.join("z"), b"z-content").unwrap();
670 std::fs::write(tmp.join("a"), b"a-content").unwrap();
671 let nar1 = encode(&tmp).unwrap();
672
673 let nar2 = encode(&tmp).unwrap();
676 assert_eq!(nar1, nar2);
677
678 let _ = std::fs::remove_dir_all(&tmp);
679 }
680
681 #[test]
682 fn hash_path_nar_is_32_bytes() {
683 let tmp = std::env::temp_dir().join("sui-spec-nar-hash-test");
684 std::fs::write(&tmp, b"abc").unwrap();
685 let digest = hash_path_nar(&tmp).unwrap();
686 let _ = std::fs::remove_file(&tmp);
687 assert_eq!(digest.len(), 32);
688 }
689
690 #[test]
691 fn store_path_for_uses_first_32_chars_of_base32_hash() {
692 let digest: [u8; 32] = [0u8; 32];
696 let path = store_path_for("/nix/store", &digest, "hello");
697 assert!(path.starts_with("/nix/store/"));
698 let after_root = path.strip_prefix("/nix/store/").unwrap();
699 let (hash, name) = after_root.split_once('-').unwrap();
700 assert_eq!(hash.len(), 32);
701 assert_eq!(name, "hello");
702 }
703
704 #[test]
705 fn store_path_for_is_deterministic() {
706 let digest: [u8; 32] = [42u8; 32];
707 let p1 = store_path_for("/nix/store", &digest, "name");
708 let p2 = store_path_for("/nix/store", &digest, "name");
709 assert_eq!(p1, p2);
710 }
711
712 #[test]
713 fn store_path_for_differs_with_name() {
714 let digest: [u8; 32] = [42u8; 32];
715 let p1 = store_path_for("/nix/store", &digest, "a");
716 let p2 = store_path_for("/nix/store", &digest, "b");
717 let (h1, _) = p1.strip_prefix("/nix/store/").unwrap().split_once('-').unwrap();
719 let (h2, _) = p2.strip_prefix("/nix/store/").unwrap().split_once('-').unwrap();
720 assert_eq!(h1, h2);
721 assert!(p1.ends_with("-a"));
722 assert!(p2.ends_with("-b"));
723 }
724}