Skip to main content

sui_compat/
nar.rs

1//! NAR (Nix Archive) format — backed by the `nix-nar` crate.
2//!
3//! This module wraps `nix-nar` for filesystem operations and provides
4//! an in-memory `NarNode` tree for programmatic NAR construction and testing.
5//! The wire-level serialization (8-byte aligned, length-prefixed strings)
6//! is implemented here for cases where we need raw NAR bytes without
7//! touching the filesystem.
8
9use std::io::{self, Read, Write};
10use std::path::Path;
11
12use crate::wire;
13use thiserror::Error;
14
15/// NAR magic header.
16pub const NAR_MAGIC: &str = "nix-archive-1";
17
18/// Hard cap on a single length-prefixed string in a NAR. CppNix
19/// allows arbitrarily large NARs, but a single *string* (filename,
20/// type token, file contents) above this size is almost certainly
21/// either a corrupted file or a fuzz-input attack on the parser.
22/// Allocating multi-exabyte buffers triggers `abort()` from the
23/// system allocator, which `catch_unwind` cannot contain — so we
24/// reject up front instead. 4 GiB is a generous cap for any real
25/// file we'd encounter in `/nix/store`.
26pub const MAX_NAR_STRING: u64 = 4 * 1024 * 1024 * 1024;
27
28#[derive(Debug, Error)]
29pub enum NarError {
30    #[error("io error: {0}")]
31    Io(#[from] io::Error),
32    #[error("invalid NAR: {0}")]
33    Invalid(String),
34    #[error("unexpected token: expected {expected}, got {got}")]
35    UnexpectedToken { expected: String, got: String },
36}
37
38// ── Wire primitives ──────────────────────────────────────────
39//
40// u64 and length-prefixed byte framing is shared with the worker
41// protocol in `crate::wire`. NAR adds a max-string-length cap
42// to defend against allocation bombs.
43
44fn write_str(w: &mut impl Write, s: &[u8]) -> io::Result<()> {
45    wire::write_bytes(w, s)
46}
47
48fn read_str(r: &mut impl Read) -> Result<Vec<u8>, NarError> {
49    let len_u64 = wire::read_u64(r)?;
50    if len_u64 > MAX_NAR_STRING {
51        return Err(NarError::Invalid(format!(
52            "nar string too long: {len_u64} bytes exceeds {MAX_NAR_STRING} cap"
53        )));
54    }
55    let len = len_u64 as usize;
56    let mut buf = vec![0u8; len];
57    r.read_exact(&mut buf)?;
58    let pad = (8 - (len % 8)) % 8;
59    if pad > 0 {
60        let mut pad_buf = vec![0u8; pad];
61        r.read_exact(&mut pad_buf)?;
62    }
63    Ok(buf)
64}
65
66fn read_str_utf8(r: &mut impl Read) -> Result<String, NarError> {
67    let bytes = read_str(r)?;
68    String::from_utf8(bytes).map_err(|e| NarError::Invalid(format!("invalid UTF-8: {e}")))
69}
70
71fn expect_str(r: &mut impl Read, expected: &str) -> Result<(), NarError> {
72    let got = read_str_utf8(r)?;
73    if got != expected {
74        return Err(NarError::UnexpectedToken {
75            expected: expected.to_string(),
76            got,
77        });
78    }
79    Ok(())
80}
81
82// ── In-memory NAR node types ─────────────────────────────────
83
84/// A node in a NAR archive tree.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum NarNode {
87    /// A regular file with optional executable permission.
88    Regular {
89        /// Whether the file has the executable bit set.
90        executable: bool,
91        /// Raw file contents.
92        contents: Vec<u8>,
93    },
94    /// A symbolic link.
95    Symlink {
96        /// The symlink target path.
97        target: String,
98    },
99    /// A directory containing named entries.
100    Directory {
101        /// Sorted list of directory entries.
102        entries: Vec<NarEntry>,
103    },
104}
105
106/// A named entry within a NAR directory node.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct NarEntry {
109    /// Entry filename (relative, no path separators).
110    pub name: String,
111    /// The file, symlink, or subdirectory at this entry.
112    pub node: NarNode,
113}
114
115// ── Writer ───────────────────────────────────────────────────
116
117/// Serialize a NAR node tree to a writer.
118pub struct NarWriter;
119
120impl NarWriter {
121    /// Write a complete NAR archive from an in-memory tree.
122    pub fn write(w: &mut impl Write, node: &NarNode) -> Result<(), NarError> {
123        write_str(w, NAR_MAGIC.as_bytes())?;
124        Self::write_node(w, node)?;
125        Ok(())
126    }
127
128    fn write_node(w: &mut impl Write, node: &NarNode) -> Result<(), NarError> {
129        write_str(w, b"(")?;
130        match node {
131            NarNode::Regular { executable, contents } => {
132                write_str(w, b"type")?;
133                write_str(w, b"regular")?;
134                if *executable {
135                    write_str(w, b"executable")?;
136                    write_str(w, b"")?;
137                }
138                write_str(w, b"contents")?;
139                write_str(w, contents)?;
140            }
141            NarNode::Symlink { target } => {
142                write_str(w, b"type")?;
143                write_str(w, b"symlink")?;
144                write_str(w, b"target")?;
145                write_str(w, target.as_bytes())?;
146            }
147            NarNode::Directory { entries } => {
148                write_str(w, b"type")?;
149                write_str(w, b"directory")?;
150                for entry in entries {
151                    write_str(w, b"entry")?;
152                    write_str(w, b"(")?;
153                    write_str(w, b"name")?;
154                    write_str(w, entry.name.as_bytes())?;
155                    write_str(w, b"node")?;
156                    Self::write_node(w, &entry.node)?;
157                    write_str(w, b")")?;
158                }
159            }
160        }
161        write_str(w, b")")?;
162        Ok(())
163    }
164
165    /// Serialize a filesystem path to NAR format using `nix-nar`.
166    pub fn write_path(w: &mut impl Write, path: &Path) -> Result<(), NarError> {
167        let encoder = nix_nar::Encoder::new(path)
168            .map_err(|e| NarError::Invalid(format!("nix-nar encoder error: {e}")))?;
169        let mut reader = std::io::BufReader::new(encoder);
170        std::io::copy(&mut reader, w)?;
171        Ok(())
172    }
173}
174
175// ── Reader ───────────────────────────────────────────────────
176
177/// Deserialize a NAR archive from a reader.
178pub struct NarReader;
179
180impl NarReader {
181    /// Read a complete NAR archive into an in-memory tree.
182    pub fn read_complete(r: &mut impl Read) -> Result<NarNode, NarError> {
183        expect_str(r, NAR_MAGIC)?;
184        Self::read_node(r)
185    }
186
187    fn read_node(r: &mut impl Read) -> Result<NarNode, NarError> {
188        expect_str(r, "(")?;
189        expect_str(r, "type")?;
190        let node_type = read_str_utf8(r)?;
191
192        match node_type.as_str() {
193            "regular" => {
194                let node = Self::read_regular(r)?;
195                expect_str(r, ")")?;
196                Ok(node)
197            }
198            "symlink" => {
199                let node = Self::read_symlink(r)?;
200                expect_str(r, ")")?;
201                Ok(node)
202            }
203            "directory" => Self::read_directory(r),
204            _ => Err(NarError::Invalid(format!("unknown node type: {node_type}"))),
205        }
206    }
207
208    fn read_regular(r: &mut impl Read) -> Result<NarNode, NarError> {
209        let mut executable = false;
210        let token = read_str_utf8(r)?;
211        if token == "executable" {
212            executable = true;
213            read_str(r)?;
214            expect_str(r, "contents")?;
215        } else if token != "contents" {
216            return Err(NarError::UnexpectedToken {
217                expected: "executable or contents".to_string(),
218                got: token,
219            });
220        }
221        let contents = read_str(r)?;
222        Ok(NarNode::Regular { executable, contents })
223    }
224
225    fn read_symlink(r: &mut impl Read) -> Result<NarNode, NarError> {
226        expect_str(r, "target")?;
227        let target = read_str_utf8(r)?;
228        Ok(NarNode::Symlink { target })
229    }
230
231    fn read_directory(r: &mut impl Read) -> Result<NarNode, NarError> {
232        let mut entries = Vec::new();
233        loop {
234            let token = read_str_utf8(r)?;
235            if token == ")" {
236                return Ok(NarNode::Directory { entries });
237            }
238            if token != "entry" {
239                return Err(NarError::UnexpectedToken {
240                    expected: "entry or )".to_string(),
241                    got: token,
242                });
243            }
244            expect_str(r, "(")?;
245            expect_str(r, "name")?;
246            let name = read_str_utf8(r)?;
247            expect_str(r, "node")?;
248            let node = Self::read_node(r)?;
249            expect_str(r, ")")?;
250            entries.push(NarEntry { name, node });
251        }
252    }
253}
254
255/// Unpack a NAR archive to a filesystem path using `nix-nar`.
256pub fn unpack_nar(nar_data: &[u8], dest: &Path) -> Result<(), NarError> {
257    let decoder = nix_nar::Decoder::new(std::io::Cursor::new(nar_data))
258        .map_err(|e| NarError::Invalid(format!("nix-nar decoder error: {e}")))?;
259    decoder
260        .unpack(dest)
261        .map_err(|e| NarError::Invalid(format!("nix-nar unpack error: {e}")))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use proptest::prelude::*;
268    use std::io::Cursor;
269
270    #[test]
271    fn roundtrip_regular_file() {
272        let node = NarNode::Regular { executable: false, contents: b"hello world".to_vec() };
273        let mut buf = Vec::new();
274        NarWriter::write(&mut buf, &node).unwrap();
275        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
276        assert_eq!(parsed, node);
277    }
278
279    #[test]
280    fn roundtrip_executable() {
281        let node = NarNode::Regular { executable: true, contents: b"#!/bin/sh\necho hi".to_vec() };
282        let mut buf = Vec::new();
283        NarWriter::write(&mut buf, &node).unwrap();
284        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
285        assert_eq!(parsed, node);
286    }
287
288    #[test]
289    fn roundtrip_symlink() {
290        let node = NarNode::Symlink { target: "/nix/store/abc-foo/bin/foo".to_string() };
291        let mut buf = Vec::new();
292        NarWriter::write(&mut buf, &node).unwrap();
293        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
294        assert_eq!(parsed, node);
295    }
296
297    #[test]
298    fn roundtrip_directory() {
299        let node = NarNode::Directory {
300            entries: vec![
301                NarEntry { name: "bar".to_string(), node: NarNode::Regular { executable: false, contents: b"bar".to_vec() } },
302                NarEntry { name: "foo".to_string(), node: NarNode::Symlink { target: "bar".to_string() } },
303            ],
304        };
305        let mut buf = Vec::new();
306        NarWriter::write(&mut buf, &node).unwrap();
307        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
308        assert_eq!(parsed, node);
309    }
310
311    #[test]
312    fn roundtrip_nested_directory() {
313        let node = NarNode::Directory {
314            entries: vec![
315                NarEntry { name: "bin".to_string(), node: NarNode::Directory {
316                    entries: vec![NarEntry { name: "hello".to_string(), node: NarNode::Regular { executable: true, contents: b"ELF".to_vec() } }],
317                }},
318                NarEntry { name: "lib".to_string(), node: NarNode::Directory { entries: vec![] } },
319            ],
320        };
321        let mut buf = Vec::new();
322        NarWriter::write(&mut buf, &node).unwrap();
323        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
324        assert_eq!(parsed, node);
325    }
326
327    #[test]
328    fn nar_magic_header() {
329        let node = NarNode::Regular { executable: false, contents: vec![] };
330        let mut buf = Vec::new();
331        NarWriter::write(&mut buf, &node).unwrap();
332        let len = u64::from_le_bytes(buf[..8].try_into().unwrap());
333        assert_eq!(len, 13);
334        assert_eq!(&buf[8..21], NAR_MAGIC.as_bytes());
335    }
336
337    #[test]
338    fn eight_byte_alignment() {
339        let node = NarNode::Regular { executable: false, contents: b"hello".to_vec() };
340        let mut buf = Vec::new();
341        NarWriter::write(&mut buf, &node).unwrap();
342        assert_eq!(buf.len() % 8, 0);
343    }
344
345    #[test]
346    fn empty_file() {
347        let node = NarNode::Regular { executable: false, contents: vec![] };
348        let mut buf = Vec::new();
349        NarWriter::write(&mut buf, &node).unwrap();
350        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
351        assert_eq!(parsed, node);
352    }
353
354    #[test]
355    fn empty_directory() {
356        let node = NarNode::Directory { entries: vec![] };
357        let mut buf = Vec::new();
358        NarWriter::write(&mut buf, &node).unwrap();
359        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
360        assert_eq!(parsed, node);
361    }
362
363    #[test]
364    fn very_large_file_content() {
365        let contents = vec![0xAB; 1_000_000];
366        let node = NarNode::Regular { executable: false, contents: contents.clone() };
367        let mut buf = Vec::new();
368        NarWriter::write(&mut buf, &node).unwrap();
369        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
370        assert_eq!(parsed, node);
371    }
372
373    #[test]
374    fn deeply_nested_5_levels() {
375        let leaf = NarNode::Regular { executable: false, contents: b"deep".to_vec() };
376        let mut node = leaf;
377        for i in (0..5).rev() {
378            node = NarNode::Directory {
379                entries: vec![NarEntry { name: format!("level{i}"), node }],
380            };
381        }
382        let mut buf = Vec::new();
383        NarWriter::write(&mut buf, &node).unwrap();
384        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
385        assert_eq!(parsed, node);
386    }
387
388    #[test]
389    fn directory_with_many_entries() {
390        let entries: Vec<NarEntry> = (0..60).map(|i| NarEntry {
391            name: format!("file-{i:03}"),
392            node: NarNode::Regular { executable: false, contents: format!("content {i}").into_bytes() },
393        }).collect();
394        let node = NarNode::Directory { entries };
395        let mut buf = Vec::new();
396        NarWriter::write(&mut buf, &node).unwrap();
397        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
398        assert_eq!(parsed, node);
399    }
400
401    #[test]
402    fn write_path_on_real_temp_directory() {
403        let dir = tempfile::tempdir().unwrap();
404        std::fs::write(dir.path().join("hello.txt"), b"Hello!").unwrap();
405        std::fs::create_dir(dir.path().join("sub")).unwrap();
406        std::fs::write(dir.path().join("sub").join("nested.txt"), b"nested").unwrap();
407
408        let mut buf = Vec::new();
409        NarWriter::write_path(&mut buf, dir.path()).unwrap();
410        assert!(buf.len() > 20);
411        assert_eq!(buf.len() % 8, 0);
412    }
413
414    #[test]
415    fn mixed_node_types() {
416        let node = NarNode::Directory {
417            entries: vec![
418                NarEntry { name: "exec".to_string(), node: NarNode::Regular { executable: true, contents: b"#!/bin/sh".to_vec() } },
419                NarEntry { name: "link".to_string(), node: NarNode::Symlink { target: "exec".to_string() } },
420                NarEntry { name: "reg".to_string(), node: NarNode::Regular { executable: false, contents: b"data".to_vec() } },
421                NarEntry { name: "sub".to_string(), node: NarNode::Directory { entries: vec![] } },
422            ],
423        };
424        let mut buf = Vec::new();
425        NarWriter::write(&mut buf, &node).unwrap();
426        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
427        assert_eq!(parsed, node);
428    }
429
430    // ── Executable empty file ────────────────────────────
431
432    #[test]
433    fn roundtrip_executable_empty_file() {
434        let node = NarNode::Regular { executable: true, contents: vec![] };
435        let mut buf = Vec::new();
436        NarWriter::write(&mut buf, &node).unwrap();
437        assert_eq!(buf.len() % 8, 0);
438        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
439        assert_eq!(parsed, node);
440    }
441
442    // ── Large symlink target ─────────────────────────────
443
444    #[test]
445    fn roundtrip_large_symlink_target() {
446        let long_target = "/nix/store/".to_string() + &"a".repeat(500) + "-long-package/lib/libfoo.so.1.2.3";
447        let node = NarNode::Symlink { target: long_target };
448        let mut buf = Vec::new();
449        NarWriter::write(&mut buf, &node).unwrap();
450        assert_eq!(buf.len() % 8, 0);
451        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
452        assert_eq!(parsed, node);
453    }
454
455    // ── Deeply nested directories (10+ levels) ──────────
456
457    #[test]
458    fn deeply_nested_10_levels() {
459        let leaf = NarNode::Regular { executable: false, contents: b"leaf data".to_vec() };
460        let mut node = leaf;
461        for i in (0..10).rev() {
462            node = NarNode::Directory {
463                entries: vec![NarEntry { name: format!("d{i}"), node }],
464            };
465        }
466        let mut buf = Vec::new();
467        NarWriter::write(&mut buf, &node).unwrap();
468        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
469        assert_eq!(parsed, node);
470    }
471
472    // ── Deeply nested with multiple entries at each level ──
473
474    #[test]
475    fn deeply_nested_with_siblings() {
476        let leaf_file = NarNode::Regular { executable: false, contents: b"f".to_vec() };
477        let leaf_link = NarNode::Symlink { target: "f".to_string() };
478
479        let inner = NarNode::Directory {
480            entries: vec![
481                NarEntry { name: "data".to_string(), node: leaf_file.clone() },
482                NarEntry { name: "link".to_string(), node: leaf_link },
483            ],
484        };
485        let mid = NarNode::Directory {
486            entries: vec![
487                NarEntry { name: "inner".to_string(), node: inner },
488                NarEntry { name: "readme".to_string(), node: NarNode::Regular { executable: false, contents: b"README".to_vec() } },
489            ],
490        };
491        let root = NarNode::Directory {
492            entries: vec![
493                NarEntry { name: "bin".to_string(), node: NarNode::Regular { executable: true, contents: b"#!/bin/sh".to_vec() } },
494                NarEntry { name: "lib".to_string(), node: mid },
495            ],
496        };
497
498        let mut buf = Vec::new();
499        NarWriter::write(&mut buf, &root).unwrap();
500        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
501        assert_eq!(parsed, root);
502    }
503
504    // ── Binary file content with all byte values ────────
505
506    #[test]
507    fn roundtrip_binary_content_all_byte_values() {
508        let contents: Vec<u8> = (0..=255).collect();
509        let node = NarNode::Regular { executable: false, contents };
510        let mut buf = Vec::new();
511        NarWriter::write(&mut buf, &node).unwrap();
512        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
513        assert_eq!(parsed, node);
514    }
515
516    // ── Symlink with special characters ─────────────────
517
518    #[test]
519    fn roundtrip_symlink_with_special_chars() {
520        let node = NarNode::Symlink { target: "../foo bar/baz\ttab".to_string() };
521        let mut buf = Vec::new();
522        NarWriter::write(&mut buf, &node).unwrap();
523        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
524        assert_eq!(parsed, node);
525    }
526
527    // ── Error: invalid magic ─────────────────────────────
528
529    #[test]
530    fn reader_rejects_bad_magic() {
531        let mut buf = Vec::new();
532        // Write wrong magic
533        write_str(&mut buf, b"not-nar-magic").unwrap();
534        let result = NarReader::read_complete(&mut Cursor::new(&buf));
535        assert!(result.is_err());
536    }
537
538    #[test]
539    fn reader_rejects_empty_input() {
540        let result = NarReader::read_complete(&mut Cursor::new(&[]));
541        assert!(result.is_err());
542    }
543
544    // ── Property tests ──────────────────────────────────
545
546    proptest! {
547        #[test]
548        fn prop_regular_file_roundtrip(contents in proptest::collection::vec(any::<u8>(), 0..1000), executable in any::<bool>()) {
549            let node = NarNode::Regular { executable, contents };
550            let mut buf = Vec::new();
551            NarWriter::write(&mut buf, &node).unwrap();
552            prop_assert_eq!(buf.len() % 8, 0);
553            let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
554            prop_assert_eq!(parsed, node);
555        }
556
557        #[test]
558        fn prop_symlink_roundtrip(target in "[a-zA-Z0-9/_.-]{1,200}") {
559            let node = NarNode::Symlink { target };
560            let mut buf = Vec::new();
561            NarWriter::write(&mut buf, &node).unwrap();
562            prop_assert_eq!(buf.len() % 8, 0);
563            let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
564            prop_assert_eq!(parsed, node);
565        }
566    }
567
568    // ── MAX_NAR_STRING enforcement ───────────────────────
569
570    #[test]
571    fn read_str_rejects_oversized_length_prefix() {
572        // Hand-craft a NAR magic + a length prefix that exceeds MAX_NAR_STRING.
573        // We can't easily reach this through write_path (gigabytes of data),
574        // so build the bytes by hand.
575        let mut buf = Vec::new();
576        // First the magic
577        write_str(&mut buf, NAR_MAGIC.as_bytes()).unwrap();
578        // Open paren
579        write_str(&mut buf, b"(").unwrap();
580        // type token
581        write_str(&mut buf, b"type").unwrap();
582        // Now write a u64 length prefix that exceeds MAX_NAR_STRING for the
583        // node type string
584        wire::write_u64(&mut buf, MAX_NAR_STRING + 1).unwrap();
585        let result = NarReader::read_complete(&mut Cursor::new(&buf));
586        assert!(result.is_err());
587        match result {
588            Err(NarError::Invalid(s)) => assert!(s.contains("too long")),
589            other => panic!("expected Invalid error about size, got {other:?}"),
590        }
591    }
592
593    // ── Unknown node type ────────────────────────────────
594
595    #[test]
596    fn reader_rejects_unknown_node_type() {
597        let mut buf = Vec::new();
598        write_str(&mut buf, NAR_MAGIC.as_bytes()).unwrap();
599        write_str(&mut buf, b"(").unwrap();
600        write_str(&mut buf, b"type").unwrap();
601        write_str(&mut buf, b"socket").unwrap(); // not regular/symlink/directory
602        let result = NarReader::read_complete(&mut Cursor::new(&buf));
603        match result {
604            Err(NarError::Invalid(s)) => assert!(s.contains("unknown node type")),
605            other => panic!("expected Invalid for unknown type, got {other:?}"),
606        }
607    }
608
609    // ── Regular file with unexpected token after type ────
610
611    #[test]
612    fn reader_rejects_regular_with_wrong_token() {
613        let mut buf = Vec::new();
614        write_str(&mut buf, NAR_MAGIC.as_bytes()).unwrap();
615        write_str(&mut buf, b"(").unwrap();
616        write_str(&mut buf, b"type").unwrap();
617        write_str(&mut buf, b"regular").unwrap();
618        write_str(&mut buf, b"garbage").unwrap(); // expected executable or contents
619        let result = NarReader::read_complete(&mut Cursor::new(&buf));
620        match result {
621            Err(NarError::UnexpectedToken { expected, .. }) => {
622                assert!(expected.contains("executable") || expected.contains("contents"));
623            }
624            other => panic!("expected UnexpectedToken, got {other:?}"),
625        }
626    }
627
628    // ── Directory with token that's not entry or close ──
629
630    #[test]
631    fn reader_rejects_directory_with_wrong_token() {
632        let mut buf = Vec::new();
633        write_str(&mut buf, NAR_MAGIC.as_bytes()).unwrap();
634        write_str(&mut buf, b"(").unwrap();
635        write_str(&mut buf, b"type").unwrap();
636        write_str(&mut buf, b"directory").unwrap();
637        write_str(&mut buf, b"garbage").unwrap(); // expected entry or )
638        let result = NarReader::read_complete(&mut Cursor::new(&buf));
639        match result {
640            Err(NarError::UnexpectedToken { expected, .. }) => {
641                assert!(expected.contains("entry") || expected.contains(")"));
642            }
643            other => panic!("expected UnexpectedToken, got {other:?}"),
644        }
645    }
646
647    // ── 1 KB+ symlink target ─────────────────────────────
648
649    #[test]
650    fn roundtrip_kilobyte_symlink_target() {
651        let target: String = "abcdefgh".repeat(150); // 1200 bytes
652        assert!(target.len() >= 1024);
653        let node = NarNode::Symlink { target };
654        let mut buf = Vec::new();
655        NarWriter::write(&mut buf, &node).unwrap();
656        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
657        assert_eq!(parsed, node);
658    }
659
660    // ── Directory with 100+ entries ──────────────────────
661
662    #[test]
663    fn directory_with_100_entries_roundtrip() {
664        let entries: Vec<NarEntry> = (0..120)
665            .map(|i| NarEntry {
666                name: format!("file-{i:04}"),
667                node: NarNode::Regular {
668                    executable: false,
669                    contents: format!("body-{i}").into_bytes(),
670                },
671            })
672            .collect();
673        let node = NarNode::Directory { entries };
674        let mut buf = Vec::new();
675        NarWriter::write(&mut buf, &node).unwrap();
676        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
677        assert_eq!(parsed, node);
678    }
679
680    // ── File with all 256 byte values ────────────────────
681
682    #[test]
683    fn roundtrip_file_with_all_256_byte_values() {
684        let contents: Vec<u8> = (0..=255).collect();
685        assert_eq!(contents.len(), 256);
686        let node = NarNode::Regular { executable: false, contents };
687        let mut buf = Vec::new();
688        NarWriter::write(&mut buf, &node).unwrap();
689        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
690        assert_eq!(parsed, node);
691    }
692
693    // ── NarError Display ─────────────────────────────────
694
695    #[test]
696    fn nar_error_invalid_display() {
697        let err = NarError::Invalid("custom".to_string());
698        let s = format!("{err}");
699        assert!(s.contains("custom"));
700    }
701
702    #[test]
703    fn nar_error_unexpected_token_display() {
704        let err = NarError::UnexpectedToken {
705            expected: "foo".to_string(),
706            got: "bar".to_string(),
707        };
708        let s = format!("{err}");
709        assert!(s.contains("foo"));
710        assert!(s.contains("bar"));
711    }
712
713    // ── NAR_MAGIC constant value ─────────────────────────
714
715    #[test]
716    fn nar_magic_is_nix_archive_1() {
717        assert_eq!(NAR_MAGIC, "nix-archive-1");
718        assert_eq!(NAR_MAGIC.len(), 13);
719    }
720
721    #[test]
722    fn max_nar_string_is_4gib() {
723        assert_eq!(MAX_NAR_STRING, 4 * 1024 * 1024 * 1024);
724    }
725
726    // ── unpack_nar to filesystem ─────────────────────────
727
728    #[test]
729    fn unpack_nar_roundtrip_single_file() {
730        let dir = tempfile::tempdir().unwrap();
731        let src = dir.path().join("src");
732        std::fs::create_dir(&src).unwrap();
733        std::fs::write(src.join("hello.txt"), b"hello world").unwrap();
734
735        let mut nar_data = Vec::new();
736        NarWriter::write_path(&mut nar_data, &src).unwrap();
737
738        let dest = dir.path().join("dest");
739        unpack_nar(&nar_data, &dest).unwrap();
740
741        let restored = std::fs::read(dest.join("hello.txt")).unwrap();
742        assert_eq!(restored, b"hello world");
743    }
744
745    // ── write_path on a single file (not directory) ─────
746
747    #[test]
748    fn write_path_on_single_file() {
749        let dir = tempfile::tempdir().unwrap();
750        let path = dir.path().join("plain.txt");
751        std::fs::write(&path, b"plain content").unwrap();
752
753        let mut buf = Vec::new();
754        NarWriter::write_path(&mut buf, &path).unwrap();
755        assert!(buf.len() >= 8);
756        assert_eq!(buf.len() % 8, 0);
757    }
758
759    // ── Magic header byte-level layout ───────────────────
760
761    #[test]
762    fn magic_header_layout() {
763        let node = NarNode::Regular { executable: false, contents: vec![] };
764        let mut buf = Vec::new();
765        NarWriter::write(&mut buf, &node).unwrap();
766        // First 8 bytes: u64 length-prefix = 13
767        let len = u64::from_le_bytes(buf[..8].try_into().unwrap());
768        assert_eq!(len, NAR_MAGIC.len() as u64);
769        // Next 13 bytes: magic
770        assert_eq!(&buf[8..21], NAR_MAGIC.as_bytes());
771        // Next 3 bytes: zero padding
772        assert_eq!(&buf[21..24], &[0u8, 0u8, 0u8]);
773    }
774
775    // ── NarNode equality ─────────────────────────────────
776
777    #[test]
778    fn nar_node_equality_and_clone() {
779        let n1 = NarNode::Regular { executable: false, contents: vec![1, 2, 3] };
780        let n2 = n1.clone();
781        assert_eq!(n1, n2);
782        let n3 = NarNode::Regular { executable: true, contents: vec![1, 2, 3] };
783        assert_ne!(n1, n3);
784    }
785
786    #[test]
787    fn nar_entry_equality_and_clone() {
788        let e1 = NarEntry {
789            name: "x".to_string(),
790            node: NarNode::Symlink { target: "y".to_string() },
791        };
792        let e2 = e1.clone();
793        assert_eq!(e1, e2);
794    }
795
796    // ── Empty symlink target ─────────────────────────────
797
798    #[test]
799    fn roundtrip_empty_symlink_target() {
800        let node = NarNode::Symlink { target: String::new() };
801        let mut buf = Vec::new();
802        NarWriter::write(&mut buf, &node).unwrap();
803        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
804        assert_eq!(parsed, node);
805    }
806
807    // ── Nested directory with executable inside ─────────
808
809    #[test]
810    fn nested_directory_with_executable_inside() {
811        let node = NarNode::Directory {
812            entries: vec![
813                NarEntry {
814                    name: "bin".to_string(),
815                    node: NarNode::Directory {
816                        entries: vec![NarEntry {
817                            name: "tool".to_string(),
818                            node: NarNode::Regular {
819                                executable: true,
820                                contents: b"binary content".to_vec(),
821                            },
822                        }],
823                    },
824                },
825            ],
826        };
827        let mut buf = Vec::new();
828        NarWriter::write(&mut buf, &node).unwrap();
829        let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
830        assert_eq!(parsed, node);
831    }
832
833    // ── Property test for directory entries ─────────────
834
835    proptest! {
836        #[test]
837        fn prop_directory_entries_roundtrip(
838            count in 0_usize..=20,
839        ) {
840            let entries: Vec<NarEntry> = (0..count).map(|i| NarEntry {
841                name: format!("e{i:03}"),
842                node: NarNode::Regular {
843                    executable: i % 2 == 0,
844                    contents: vec![i as u8; i],
845                },
846            }).collect();
847            let node = NarNode::Directory { entries };
848            let mut buf = Vec::new();
849            NarWriter::write(&mut buf, &node).unwrap();
850            prop_assert_eq!(buf.len() % 8, 0);
851            let parsed = NarReader::read_complete(&mut Cursor::new(&buf)).unwrap();
852            prop_assert_eq!(parsed, node);
853        }
854    }
855}