Skip to main content

sui_spec/
nar.rs

1//! Typed border for the Nix Archive (.nar) format.
2//!
3//! NAR is the on-the-wire format the binary cache returns and the
4//! daemon worker streams.  It's a serialisation of a filesystem
5//! subtree: directory entries, regular files (with executable bit),
6//! and symlinks, all framed by length-prefixed strings.  cppnix's
7//! `libnix-store/nar-accessor.cc` is the canonical reference.
8//!
9//! Today sui-compat consumes the `nix-nar` upstream crate; this
10//! module names the typed contract so future format extensions
11//! (e.g. extended attributes) ride on an explicit spec.
12//!
13//! ## Authoring surface
14//!
15//! ```lisp
16//! (defnar-format
17//!   :name           "cppnix-nar"
18//!   :magic          "nix-archive-1"
19//!   :encoding       LengthPrefixedString
20//!   :entry-types    (Regular Executable Directory Symlink)
21//!   :phases         ((:kind ReadMagic)
22//!                    (:kind ParseRootNode)
23//!                    (:kind StreamEntries)
24//!                    (:kind ValidateChecksum)))
25//! ```
26
27use serde::{Deserialize, Serialize};
28use tatara_lisp::DeriveTataraDomain;
29
30use crate::SpecError;
31
32// ── Typed border ───────────────────────────────────────────────────
33
34/// One NAR format variant.  Today: only the cppnix baseline.
35#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
36#[tatara(keyword = "defnar-format")]
37pub struct NarFormat {
38    pub name: String,
39    /// Magic bytes at file offset 0 (UTF-8, length-prefixed).
40    pub magic: String,
41    /// Wire encoding of strings + framing.
42    pub encoding: NarEncoding,
43    /// Entry kinds the parser must accept.
44    #[serde(rename = "entryTypes")]
45    pub entry_types: Vec<NarEntryType>,
46    pub phases: Vec<NarPhase>,
47}
48
49/// String framing convention.  cppnix uses 8-byte little-endian
50/// length prefix + padded to 8 bytes.
51#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
52pub enum NarEncoding {
53    /// Length-prefixed, 8-byte LE prefix, 8-byte padded.  cppnix.
54    LengthPrefixedString,
55    /// Hypothetical alternative — if a future Nix variant ships a
56    /// CBOR-framed NAR, we'd land it here.
57    Cbor,
58}
59
60/// File-entry kind in a NAR.
61#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum NarEntryType {
63    /// Regular file, mode 0644.
64    Regular,
65    /// Regular file with executable bit, mode 0755.
66    Executable,
67    /// Directory containing further entries.
68    Directory,
69    /// Symbolic link with stored target string.
70    Symlink,
71}
72
73/// One phase of NAR parse/emit.
74#[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/// Closed set of NAR-handling phases.
84#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85pub enum NarPhaseKind {
86    /// Read the leading magic string; reject if mismatch.
87    ReadMagic,
88    /// Read the root `(` token and the root entry's type.
89    ParseRootNode,
90    /// Streaming-recursive walk of directory entries (DFS).
91    StreamEntries,
92    /// Run a sha256 over the entire NAR bytes and verify against
93    /// the expected hash (caller provides).
94    ValidateChecksum,
95    /// Write the NAR's bytes to a sink (file, network, store).
96    EmitToSink,
97    /// Build an in-memory tree from streamed entries.
98    BuildTree,
99}
100
101// ── Spec interpreter (M3.0 minimal — magic + framing check) ───────
102
103/// Validate that the leading bytes of a NAR stream match the
104/// declared magic for the format.  M3.0 doesn't parse the full
105/// archive (sui-compat consumes nix-nar for that); this surface
106/// verifies the format dispatch is well-formed.
107///
108/// # Errors
109///
110/// - `nar-too-short` if the input is shorter than the framed
111///   magic.
112/// - `nar-bad-magic` if the magic bytes don't match the declared
113///   format.
114pub fn validate_magic(format: &NarFormat, input: &[u8]) -> Result<(), SpecError> {
115    // cppnix encodes strings as: 8-byte LE length + content +
116    // padding to 8-byte alignment.  So the leading framed magic
117    // is: [magic_len_u64_le, magic_bytes, pad].
118    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    // Read the LE u64 length.
131    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    // Read the magic bytes.
144    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/// Pack a string per cppnix's NAR framing (`u64-le length + bytes +
159/// pad-to-8`).  Useful for tests + future M3.1 emitter.
160#[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
171/// Apply the NAR algorithm.  M3.0: validates magic on the input
172/// and returns an empty marker on success.  Full parse + emit are
173/// M3.1 work (the sui-compat crate already handles them; this
174/// surface is the typed entry-point future code dispatches
175/// through).
176///
177/// # Errors
178///
179/// Whatever `validate_magic` returns.
180pub 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
188// ── NAR encoder ────────────────────────────────────────────────────
189//
190// Substrate-level encoder.  Byte-equivalent with `nix store dump-path`
191// (verified on the operator workstation via sha256 round-trip).  Used
192// by the `sui store dump-path` / `sui store add-file` / `sui store
193// add-path` / `sui store make-content-addressed` dispatches in the
194// sui binary, plus the future sui-build / sui-store ingestion paths.
195
196/// Encode a filesystem path as canonical NAR bytes.  Returns the
197/// magic-prefixed byte stream byte-equivalent with `nix store
198/// dump-path <path>`.
199///
200/// # Errors
201///
202/// Returns the underlying [`std::io::Error`] for any filesystem
203/// read failure (symlink readlink, missing entries, etc.).
204pub 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
211/// Convenience: NAR-encode then sha256-digest the result.  Returns
212/// the 32-byte digest matching `nix-hash --type sha256 --base16
213/// --flat <nar-dump>`.
214///
215/// # Errors
216///
217/// Propagates [`encode`] errors.
218pub 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// ── NAR decoder ────────────────────────────────────────────────────
226//
227// Symmetric peer of `encode`.  Reads canonical NAR bytes and
228// materializes the filesystem tree under `dest`.  Combined with
229// `encode`, gives the operator full round-trip control:
230// dump-path → materialize at a new location → hash-equality.
231
232/// Typed decoder error.
233#[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
256/// Decode canonical NAR bytes and materialize the tree to `dest`.
257/// Mirrors `nix-store --restore <dest> < <nar-bytes>`.
258///
259/// # Errors
260///
261/// Returns a typed `NarDecodeError` for any malformed input or
262/// filesystem operation failure.
263pub 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
275/// Streaming variant — read from any `Read` source.  Buffers the
276/// whole stream first so we can validate framing; the canonical
277/// NAR is fully buffered in cppnix too, so this is symmetric.
278///
279/// # Errors
280///
281/// Propagates IO errors + decode errors.
282pub 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        // Skip padding to next 8-byte boundary.
317        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    // Optional `executable ""` block before `contents`.
368    let tag = cur.read_framed_str()?;
369    let bytes = if tag == "executable" {
370        executable = true;
371        let _ = cur.read_framed_string()?; // empty value
372        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        // Peek at the next framed string — either "entry" (more
407        // children) or ")" (end-of-directory closer).
408        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                // Roll back — read_node's closing `)` matches.
421                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/// Compute the canonical store path for a NAR digest + name.
453/// Returns `"<store-root>/<32-char-nix-base32-hash-prefix>-<name>"`.
454/// Mirrors cppnix's `makeFixedOutputPath` shape.
455#[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/// Test/internal helper exported for store_ops's typed re-encoder.
465/// External callers should use [`encode`].
466#[doc(hidden)]
467pub fn write_string_for_test(buf: &mut Vec<u8>, s: &[u8]) {
468    write_framed(buf, s);
469}
470
471/// Write a length-prefixed string in canonical NAR padded form.
472fn 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
523// ── Canonical spec ─────────────────────────────────────────────────
524
525pub const CANONICAL_NAR_LISP: &str = include_str!("../specs/nar.lisp");
526
527/// Compile every authored NAR-format variant.
528///
529/// # Errors
530///
531/// Returns an error if the Lisp source fails to parse.
532pub 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    // ── M3.0 magic-validation tests ────────────────────────────
591
592    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        // "nix-archive-1" is 13 bytes → padded to 16.  With the
601        // 8-byte length prefix, total is 24 bytes.
602        let packed = pack_framed("nix-archive-1");
603        assert_eq!(packed.len(), 8 + 16);
604        // First 8 bytes = LE u64 of 13.
605        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    // ── encoder tests ──────────────────────────────────────────
648
649    #[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        // First 8 bytes = LE u64 of 13 (length of "nix-archive-1")
656        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        // Build a small directory with two files in non-sorted
664        // insertion order; encoder must sort by file name so
665        // the NAR is deterministic.
666        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        // Round-trip via byte-equivalence: encoding twice yields
674        // the same bytes (determinism).
675        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        // Known sha256 digest of empty string = a SRI we can
693        // cross-check.  Just verify shape: starts with store
694        // root, has 32-char hash prefix + dash + name.
695        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        // Hash prefix matches; suffix differs.
718        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}