Skip to main content

sui_spec/
store_ops.rs

1//! Typed store-operation primitives — parse, transform, query.
2//!
3//! This module is the substrate's first-class surface for
4//! operating on Nix stores beyond what `nix-store` natively
5//! supports.  Built on top of [`crate::nar`]'s encoder/decoder,
6//! it provides:
7//!
8//! 1. [`ParsedNar`] — typed in-memory representation of a NAR
9//!    archive.  Read once, walk arbitrarily, transform freely.
10//! 2. [`StoreSlice`] — typed declaration of "a subset of /nix/store
11//!    to operate on" with predicate-based selection.
12//! 3. [`MaterializationPlan`] — typed plan for rematerializing a
13//!    slice at a new store root (different prefix, drift detection,
14//!    etc.).
15//! 4. [`StoreTransform`] — typed AST for store-path-aware byte
16//!    transforms (graft, rewrite, redact).
17//!
18//! Tatara-lisp authoring surface (`(defstore-operation …)` etc.)
19//! lives in `specs/store_ops.lisp`; the typed border below is
20//! consumed by both the Lisp interpreter and the Rust API.
21
22use serde::{Deserialize, Serialize};
23use tatara_lisp::DeriveTataraDomain;
24
25use crate::SpecError;
26
27// ── Typed NAR tree ─────────────────────────────────────────────────
28
29/// Parsed NAR archive — a typed tree of nodes that operators can
30/// walk, query, and transform without re-decoding on each access.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ParsedNar {
33    pub root: NarNode,
34}
35
36/// One node in the parsed NAR tree.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum NarNode {
39    File {
40        executable: bool,
41        contents: Vec<u8>,
42    },
43    Directory {
44        /// Sorted by name — matches NAR's canonical ordering.
45        entries: Vec<(String, NarNode)>,
46    },
47    Symlink {
48        target: String,
49    },
50}
51
52impl NarNode {
53    /// Walk the tree top-down, calling `visitor` on every node
54    /// with its relative path (`""` for the root).
55    pub fn walk<F: FnMut(&str, &NarNode)>(&self, visitor: &mut F) {
56        self.walk_with_prefix("", visitor);
57    }
58
59    fn walk_with_prefix<F: FnMut(&str, &NarNode)>(&self, prefix: &str, visitor: &mut F) {
60        visitor(prefix, self);
61        if let NarNode::Directory { entries } = self {
62            for (name, child) in entries {
63                let child_path = if prefix.is_empty() {
64                    name.clone()
65                } else {
66                    format!("{prefix}/{name}")
67                };
68                child.walk_with_prefix(&child_path, visitor);
69            }
70        }
71    }
72
73    /// Count files in the tree (excluding directories + symlinks).
74    #[must_use]
75    pub fn file_count(&self) -> usize {
76        let mut count = 0;
77        self.walk(&mut |_, n| {
78            if matches!(n, NarNode::File { .. }) {
79                count += 1;
80            }
81        });
82        count
83    }
84
85    /// Total bytes of file contents in the tree.
86    #[must_use]
87    pub fn total_bytes(&self) -> u64 {
88        let mut total = 0u64;
89        self.walk(&mut |_, n| {
90            if let NarNode::File { contents, .. } = n {
91                total += contents.len() as u64;
92            }
93        });
94        total
95    }
96
97    /// Find a node by path (relative, `/`-separated).  Returns
98    /// `None` if the path doesn't exist in the tree.
99    #[must_use]
100    pub fn at_path(&self, path: &str) -> Option<&NarNode> {
101        if path.is_empty() {
102            return Some(self);
103        }
104        let (head, rest) = match path.split_once('/') {
105            Some((h, r)) => (h, r),
106            None => (path, ""),
107        };
108        match self {
109            NarNode::Directory { entries } => {
110                let (_, child) = entries.iter().find(|(n, _)| n == head)?;
111                child.at_path(rest)
112            }
113            _ => None,
114        }
115    }
116}
117
118impl ParsedNar {
119    /// Parse canonical NAR bytes into the typed tree.  Equivalent
120    /// to [`crate::nar::decode`] but builds an in-memory tree
121    /// instead of materializing to disk.
122    ///
123    /// # Errors
124    ///
125    /// Same typed errors as [`crate::nar::decode`].
126    pub fn parse(bytes: &[u8]) -> Result<Self, crate::nar::NarDecodeError> {
127        // Reuse the decoder framework by going through a temp dir
128        // for now — symmetric materialise-then-walk.  A pure
129        // streaming parser is a follow-up optimization; the
130        // current shape is correct + uses the same wire format.
131        //
132        // The materialization path MUST be unique per call. The prior
133        // `{pid}-{nanos}` scheme was NOT: two `parse` calls on different
134        // threads at the same nanosecond resolved to the SAME `tmp`, so
135        // one call's `decode` wrote its tree into a dir the other call's
136        // `read_node` then walked — reading BOTH trees and returning a
137        // corrupted node with the neighbor's entries mixed in (a real,
138        // shipped concurrency bug, not merely a test artifact). A
139        // process-wide atomic counter appended to the path removes the
140        // shared cell entirely — no two calls can ever collide. (No
141        // `tempfile` dep: it is dev-only for this crate; the counter is
142        // zero-dependency and equally collision-proof.)
143        use std::sync::atomic::{AtomicU64, Ordering};
144        static PARSE_SEQ: AtomicU64 = AtomicU64::new(0);
145        let seq = PARSE_SEQ.fetch_add(1, Ordering::Relaxed);
146        let tmp = std::env::temp_dir().join(format!(
147            "sui-parsed-nar-{}-{}-{seq}",
148            std::process::id(),
149            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
150                .map(|d| d.as_nanos()).unwrap_or(0),
151        ));
152        let _ = std::fs::remove_dir_all(&tmp);
153        crate::nar::decode(bytes, &tmp)?;
154        let root = read_node(&tmp)
155            .map_err(|e| crate::nar::NarDecodeError::Io(e.to_string()))?;
156        let _ = std::fs::remove_dir_all(&tmp);
157        Ok(ParsedNar { root })
158    }
159
160    /// Re-encode the typed tree back to NAR bytes.  Round-trip
161    /// equivalent: `ParsedNar::parse(encode(parse(b)))?.serialize() == b`.
162    #[must_use]
163    pub fn serialize(&self) -> Vec<u8> {
164        let mut buf = Vec::new();
165        crate::nar::write_string_for_test(&mut buf, b"nix-archive-1");
166        write_node(&mut buf, &self.root);
167        buf
168    }
169}
170
171fn read_node(path: &std::path::Path) -> std::io::Result<NarNode> {
172    let meta = std::fs::symlink_metadata(path)?;
173    if meta.file_type().is_symlink() {
174        let target = std::fs::read_link(path)?;
175        return Ok(NarNode::Symlink {
176            target: target.to_string_lossy().to_string(),
177        });
178    }
179    if meta.is_file() {
180        let contents = std::fs::read(path)?;
181        #[cfg(unix)]
182        let executable = {
183            use std::os::unix::fs::PermissionsExt;
184            meta.permissions().mode() & 0o111 != 0
185        };
186        #[cfg(not(unix))]
187        let executable = false;
188        return Ok(NarNode::File { executable, contents });
189    }
190    // Directory
191    let mut entries: Vec<_> = std::fs::read_dir(path)?
192        .filter_map(Result::ok)
193        .collect();
194    entries.sort_by_key(|e| e.file_name());
195    let mut children = Vec::with_capacity(entries.len());
196    for entry in entries {
197        let name = entry.file_name().to_string_lossy().to_string();
198        let child = read_node(&entry.path())?;
199        children.push((name, child));
200    }
201    Ok(NarNode::Directory { entries: children })
202}
203
204fn write_node(buf: &mut Vec<u8>, node: &NarNode) {
205    crate::nar::write_string_for_test(buf, b"(");
206    match node {
207        NarNode::File { executable, contents } => {
208            crate::nar::write_string_for_test(buf, b"type");
209            crate::nar::write_string_for_test(buf, b"regular");
210            if *executable {
211                crate::nar::write_string_for_test(buf, b"executable");
212                crate::nar::write_string_for_test(buf, b"");
213            }
214            crate::nar::write_string_for_test(buf, b"contents");
215            crate::nar::write_string_for_test(buf, contents);
216        }
217        NarNode::Directory { entries } => {
218            crate::nar::write_string_for_test(buf, b"type");
219            crate::nar::write_string_for_test(buf, b"directory");
220            for (name, child) in entries {
221                crate::nar::write_string_for_test(buf, b"entry");
222                crate::nar::write_string_for_test(buf, b"(");
223                crate::nar::write_string_for_test(buf, b"name");
224                crate::nar::write_string_for_test(buf, name.as_bytes());
225                crate::nar::write_string_for_test(buf, b"node");
226                write_node(buf, child);
227                crate::nar::write_string_for_test(buf, b")");
228            }
229        }
230        NarNode::Symlink { target } => {
231            crate::nar::write_string_for_test(buf, b"type");
232            crate::nar::write_string_for_test(buf, b"symlink");
233            crate::nar::write_string_for_test(buf, b"target");
234            crate::nar::write_string_for_test(buf, target.as_bytes());
235        }
236    }
237    crate::nar::write_string_for_test(buf, b")");
238}
239
240// ── Typed StoreSlice + MaterializationPlan ─────────────────────────
241
242/// Typed declaration of "a subset of /nix/store" for operations.
243/// Lisp surface: `(defstore-slice …)`.
244#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
245#[tatara(keyword = "defstore-slice")]
246pub struct StoreSlice {
247    /// Stable name (used in reports + tests).
248    pub name: String,
249    /// Source store root (typically `/nix/store`).
250    #[serde(rename = "sourceRoot")]
251    pub source_root: String,
252    /// Selection predicate — a regex over the basename.  Matches
253    /// every path whose name (e.g. `0mrdxm84...-source`) matches.
254    #[serde(rename = "namePattern")]
255    pub name_pattern: String,
256    /// Maximum entries to include from the source.  Prevents
257    /// disk-blowup when probing large stores.
258    #[serde(rename = "maxEntries")]
259    pub max_entries: usize,
260    /// Skip entries larger than this size (bytes) — avoids
261    /// rematerializing massive sources.  0 = unlimited.
262    #[serde(rename = "maxSizeBytes", default)]
263    pub max_size_bytes: u64,
264}
265
266/// Plan for rematerializing a slice at an alternate root.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct MaterializationPlan {
269    /// Source path under `source_root`.
270    pub source: std::path::PathBuf,
271    /// Destination path under the operator's chosen root.
272    pub dest: std::path::PathBuf,
273}
274
275/// Outcome of running a [`MaterializationPlan`].
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct MaterializationOutcome {
278    pub source: std::path::PathBuf,
279    pub dest: std::path::PathBuf,
280    pub source_nar_sha256: String,
281    pub dest_nar_sha256: String,
282    pub byte_equivalent: bool,
283    pub source_size: u64,
284    pub file_count: usize,
285}
286
287/// Build the materialization plan from a typed slice.  Scans
288/// `slice.source_root`, applies the name pattern + max entries +
289/// size cap, returns a list of (source, dest) pairs.
290///
291/// # Errors
292///
293/// Returns `SpecError::Interp { phase: "slice-scan" }` if the
294/// source dir can't be read.
295pub fn build_materialization_plan(
296    slice: &StoreSlice,
297    dest_root: &std::path::Path,
298) -> Result<Vec<MaterializationPlan>, SpecError> {
299    let pattern = regex::Regex::new(&slice.name_pattern).map_err(|e| SpecError::Interp {
300        phase: "slice-pattern".into(),
301        message: format!("invalid name regex: {e}"),
302    })?;
303    let source_root = std::path::Path::new(&slice.source_root);
304    let entries = std::fs::read_dir(source_root).map_err(|e| SpecError::Interp {
305        phase: "slice-scan".into(),
306        message: format!("read_dir {}: {e}", source_root.display()),
307    })?;
308
309    let mut plans: Vec<MaterializationPlan> = Vec::new();
310    for entry in entries.flatten() {
311        if plans.len() >= slice.max_entries {
312            break;
313        }
314        let name = entry.file_name().to_string_lossy().to_string();
315        if !pattern.is_match(&name) {
316            continue;
317        }
318        // Size cap: estimate via metadata.len() for files,
319        // skip dirs > max_size (we don't du each dir to keep it cheap).
320        if slice.max_size_bytes > 0 {
321            if let Ok(meta) = entry.metadata() {
322                if meta.is_file() && meta.len() > slice.max_size_bytes {
323                    continue;
324                }
325            }
326        }
327        plans.push(MaterializationPlan {
328            source: entry.path(),
329            dest: dest_root.join(&name),
330        });
331    }
332    Ok(plans)
333}
334
335/// Run a materialization plan: NAR-encode the source, decode to
336/// the destination, then compare NAR sha256.  Byte-equivalence
337/// of the rematerialized tree against the source is the proof.
338///
339/// # Errors
340///
341/// Propagates encoder / decoder / hash errors.
342pub fn run_materialization(
343    plan: &MaterializationPlan,
344) -> Result<MaterializationOutcome, SpecError> {
345    use sha2::Digest;
346
347    let source_nar = crate::nar::encode(&plan.source).map_err(|e| SpecError::Interp {
348        phase: "materialize-encode".into(),
349        message: format!("encode {}: {e}", plan.source.display()),
350    })?;
351    let source_hash = sha2::Sha256::digest(&source_nar);
352    let source_hash_hex = hex_encode(&source_hash);
353
354    if plan.dest.exists() {
355        std::fs::remove_dir_all(&plan.dest).ok();
356    }
357    crate::nar::decode(&source_nar, &plan.dest).map_err(|e| SpecError::Interp {
358        phase: "materialize-decode".into(),
359        message: format!("decode → {}: {e}", plan.dest.display()),
360    })?;
361
362    let dest_nar = crate::nar::encode(&plan.dest).map_err(|e| SpecError::Interp {
363        phase: "materialize-encode".into(),
364        message: format!("encode {}: {e}", plan.dest.display()),
365    })?;
366    let dest_hash = sha2::Sha256::digest(&dest_nar);
367    let dest_hash_hex = hex_encode(&dest_hash);
368
369    let parsed = ParsedNar::parse(&source_nar).map_err(|e| SpecError::Interp {
370        phase: "materialize-parse".into(),
371        message: format!("parse: {e}"),
372    })?;
373
374    Ok(MaterializationOutcome {
375        source: plan.source.clone(),
376        dest: plan.dest.clone(),
377        source_nar_sha256: source_hash_hex.clone(),
378        dest_nar_sha256: dest_hash_hex.clone(),
379        byte_equivalent: source_hash_hex == dest_hash_hex,
380        source_size: parsed.root.total_bytes(),
381        file_count: parsed.root.file_count(),
382    })
383}
384
385fn hex_encode(bytes: &[u8]) -> String {
386    let mut s = String::with_capacity(bytes.len() * 2);
387    for b in bytes {
388        s.push_str(&format!("{b:02x}"));
389    }
390    s
391}
392
393// ── Canonical Lisp spec ────────────────────────────────────────────
394
395pub const CANONICAL_STORE_OPS_LISP: &str =
396    include_str!("../specs/store_ops.lisp");
397
398/// Compile every authored store slice declaration.
399///
400/// # Errors
401///
402/// Returns an error if the Lisp source can't be parsed.
403pub fn load_canonical_slices() -> Result<Vec<StoreSlice>, SpecError> {
404    crate::loader::load_all::<StoreSlice>(CANONICAL_STORE_OPS_LISP)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn parsed_nar_walk_counts_correctly() {
413        let tmp = std::env::temp_dir().join("sui-parsed-nar-walk-test");
414        let _ = std::fs::remove_dir_all(&tmp);
415        std::fs::create_dir_all(&tmp).unwrap();
416        std::fs::write(tmp.join("a"), b"aaaa").unwrap();
417        std::fs::write(tmp.join("b"), b"bb").unwrap();
418        std::fs::create_dir_all(tmp.join("d")).unwrap();
419        std::fs::write(tmp.join("d/c"), b"ccc").unwrap();
420        let nar = crate::nar::encode(&tmp).unwrap();
421        let parsed = ParsedNar::parse(&nar).unwrap();
422        assert_eq!(parsed.root.file_count(), 3);
423        assert_eq!(parsed.root.total_bytes(), 4 + 2 + 3);
424        let _ = std::fs::remove_dir_all(&tmp);
425    }
426
427    #[test]
428    fn parsed_nar_at_path_finds_nested_file() {
429        let tmp = std::env::temp_dir().join("sui-parsed-nar-at-test");
430        let _ = std::fs::remove_dir_all(&tmp);
431        std::fs::create_dir_all(tmp.join("sub")).unwrap();
432        std::fs::write(tmp.join("sub/hello"), b"world").unwrap();
433        let nar = crate::nar::encode(&tmp).unwrap();
434        let parsed = ParsedNar::parse(&nar).unwrap();
435        let node = parsed.root.at_path("sub/hello").unwrap();
436        assert!(matches!(node, NarNode::File { .. }));
437        let _ = std::fs::remove_dir_all(&tmp);
438    }
439
440    #[test]
441    fn canonical_slices_parse() {
442        let slices = load_canonical_slices().unwrap();
443        assert!(!slices.is_empty(), "at least one canonical slice authored");
444    }
445}