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        let tmp = std::env::temp_dir().join(format!(
132            "sui-parsed-nar-{}-{}",
133            std::process::id(),
134            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
135                .map(|d| d.as_nanos()).unwrap_or(0),
136        ));
137        let _ = std::fs::remove_dir_all(&tmp);
138        crate::nar::decode(bytes, &tmp)?;
139        let root = read_node(&tmp)
140            .map_err(|e| crate::nar::NarDecodeError::Io(e.to_string()))?;
141        let _ = std::fs::remove_dir_all(&tmp);
142        Ok(ParsedNar { root })
143    }
144
145    /// Re-encode the typed tree back to NAR bytes.  Round-trip
146    /// equivalent: `ParsedNar::parse(encode(parse(b)))?.serialize() == b`.
147    #[must_use]
148    pub fn serialize(&self) -> Vec<u8> {
149        let mut buf = Vec::new();
150        crate::nar::write_string_for_test(&mut buf, b"nix-archive-1");
151        write_node(&mut buf, &self.root);
152        buf
153    }
154}
155
156fn read_node(path: &std::path::Path) -> std::io::Result<NarNode> {
157    let meta = std::fs::symlink_metadata(path)?;
158    if meta.file_type().is_symlink() {
159        let target = std::fs::read_link(path)?;
160        return Ok(NarNode::Symlink {
161            target: target.to_string_lossy().to_string(),
162        });
163    }
164    if meta.is_file() {
165        let contents = std::fs::read(path)?;
166        #[cfg(unix)]
167        let executable = {
168            use std::os::unix::fs::PermissionsExt;
169            meta.permissions().mode() & 0o111 != 0
170        };
171        #[cfg(not(unix))]
172        let executable = false;
173        return Ok(NarNode::File { executable, contents });
174    }
175    // Directory
176    let mut entries: Vec<_> = std::fs::read_dir(path)?
177        .filter_map(Result::ok)
178        .collect();
179    entries.sort_by_key(|e| e.file_name());
180    let mut children = Vec::with_capacity(entries.len());
181    for entry in entries {
182        let name = entry.file_name().to_string_lossy().to_string();
183        let child = read_node(&entry.path())?;
184        children.push((name, child));
185    }
186    Ok(NarNode::Directory { entries: children })
187}
188
189fn write_node(buf: &mut Vec<u8>, node: &NarNode) {
190    crate::nar::write_string_for_test(buf, b"(");
191    match node {
192        NarNode::File { executable, contents } => {
193            crate::nar::write_string_for_test(buf, b"type");
194            crate::nar::write_string_for_test(buf, b"regular");
195            if *executable {
196                crate::nar::write_string_for_test(buf, b"executable");
197                crate::nar::write_string_for_test(buf, b"");
198            }
199            crate::nar::write_string_for_test(buf, b"contents");
200            crate::nar::write_string_for_test(buf, contents);
201        }
202        NarNode::Directory { entries } => {
203            crate::nar::write_string_for_test(buf, b"type");
204            crate::nar::write_string_for_test(buf, b"directory");
205            for (name, child) in entries {
206                crate::nar::write_string_for_test(buf, b"entry");
207                crate::nar::write_string_for_test(buf, b"(");
208                crate::nar::write_string_for_test(buf, b"name");
209                crate::nar::write_string_for_test(buf, name.as_bytes());
210                crate::nar::write_string_for_test(buf, b"node");
211                write_node(buf, child);
212                crate::nar::write_string_for_test(buf, b")");
213            }
214        }
215        NarNode::Symlink { target } => {
216            crate::nar::write_string_for_test(buf, b"type");
217            crate::nar::write_string_for_test(buf, b"symlink");
218            crate::nar::write_string_for_test(buf, b"target");
219            crate::nar::write_string_for_test(buf, target.as_bytes());
220        }
221    }
222    crate::nar::write_string_for_test(buf, b")");
223}
224
225// ── Typed StoreSlice + MaterializationPlan ─────────────────────────
226
227/// Typed declaration of "a subset of /nix/store" for operations.
228/// Lisp surface: `(defstore-slice …)`.
229#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
230#[tatara(keyword = "defstore-slice")]
231pub struct StoreSlice {
232    /// Stable name (used in reports + tests).
233    pub name: String,
234    /// Source store root (typically `/nix/store`).
235    #[serde(rename = "sourceRoot")]
236    pub source_root: String,
237    /// Selection predicate — a regex over the basename.  Matches
238    /// every path whose name (e.g. `0mrdxm84...-source`) matches.
239    #[serde(rename = "namePattern")]
240    pub name_pattern: String,
241    /// Maximum entries to include from the source.  Prevents
242    /// disk-blowup when probing large stores.
243    #[serde(rename = "maxEntries")]
244    pub max_entries: usize,
245    /// Skip entries larger than this size (bytes) — avoids
246    /// rematerializing massive sources.  0 = unlimited.
247    #[serde(rename = "maxSizeBytes", default)]
248    pub max_size_bytes: u64,
249}
250
251/// Plan for rematerializing a slice at an alternate root.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct MaterializationPlan {
254    /// Source path under `source_root`.
255    pub source: std::path::PathBuf,
256    /// Destination path under the operator's chosen root.
257    pub dest: std::path::PathBuf,
258}
259
260/// Outcome of running a [`MaterializationPlan`].
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct MaterializationOutcome {
263    pub source: std::path::PathBuf,
264    pub dest: std::path::PathBuf,
265    pub source_nar_sha256: String,
266    pub dest_nar_sha256: String,
267    pub byte_equivalent: bool,
268    pub source_size: u64,
269    pub file_count: usize,
270}
271
272/// Build the materialization plan from a typed slice.  Scans
273/// `slice.source_root`, applies the name pattern + max entries +
274/// size cap, returns a list of (source, dest) pairs.
275///
276/// # Errors
277///
278/// Returns `SpecError::Interp { phase: "slice-scan" }` if the
279/// source dir can't be read.
280pub fn build_materialization_plan(
281    slice: &StoreSlice,
282    dest_root: &std::path::Path,
283) -> Result<Vec<MaterializationPlan>, SpecError> {
284    let pattern = regex::Regex::new(&slice.name_pattern).map_err(|e| SpecError::Interp {
285        phase: "slice-pattern".into(),
286        message: format!("invalid name regex: {e}"),
287    })?;
288    let source_root = std::path::Path::new(&slice.source_root);
289    let entries = std::fs::read_dir(source_root).map_err(|e| SpecError::Interp {
290        phase: "slice-scan".into(),
291        message: format!("read_dir {}: {e}", source_root.display()),
292    })?;
293
294    let mut plans: Vec<MaterializationPlan> = Vec::new();
295    for entry in entries.flatten() {
296        if plans.len() >= slice.max_entries {
297            break;
298        }
299        let name = entry.file_name().to_string_lossy().to_string();
300        if !pattern.is_match(&name) {
301            continue;
302        }
303        // Size cap: estimate via metadata.len() for files,
304        // skip dirs > max_size (we don't du each dir to keep it cheap).
305        if slice.max_size_bytes > 0 {
306            if let Ok(meta) = entry.metadata() {
307                if meta.is_file() && meta.len() > slice.max_size_bytes {
308                    continue;
309                }
310            }
311        }
312        plans.push(MaterializationPlan {
313            source: entry.path(),
314            dest: dest_root.join(&name),
315        });
316    }
317    Ok(plans)
318}
319
320/// Run a materialization plan: NAR-encode the source, decode to
321/// the destination, then compare NAR sha256.  Byte-equivalence
322/// of the rematerialized tree against the source is the proof.
323///
324/// # Errors
325///
326/// Propagates encoder / decoder / hash errors.
327pub fn run_materialization(
328    plan: &MaterializationPlan,
329) -> Result<MaterializationOutcome, SpecError> {
330    use sha2::Digest;
331
332    let source_nar = crate::nar::encode(&plan.source).map_err(|e| SpecError::Interp {
333        phase: "materialize-encode".into(),
334        message: format!("encode {}: {e}", plan.source.display()),
335    })?;
336    let source_hash = sha2::Sha256::digest(&source_nar);
337    let source_hash_hex = hex_encode(&source_hash);
338
339    if plan.dest.exists() {
340        std::fs::remove_dir_all(&plan.dest).ok();
341    }
342    crate::nar::decode(&source_nar, &plan.dest).map_err(|e| SpecError::Interp {
343        phase: "materialize-decode".into(),
344        message: format!("decode → {}: {e}", plan.dest.display()),
345    })?;
346
347    let dest_nar = crate::nar::encode(&plan.dest).map_err(|e| SpecError::Interp {
348        phase: "materialize-encode".into(),
349        message: format!("encode {}: {e}", plan.dest.display()),
350    })?;
351    let dest_hash = sha2::Sha256::digest(&dest_nar);
352    let dest_hash_hex = hex_encode(&dest_hash);
353
354    let parsed = ParsedNar::parse(&source_nar).map_err(|e| SpecError::Interp {
355        phase: "materialize-parse".into(),
356        message: format!("parse: {e}"),
357    })?;
358
359    Ok(MaterializationOutcome {
360        source: plan.source.clone(),
361        dest: plan.dest.clone(),
362        source_nar_sha256: source_hash_hex.clone(),
363        dest_nar_sha256: dest_hash_hex.clone(),
364        byte_equivalent: source_hash_hex == dest_hash_hex,
365        source_size: parsed.root.total_bytes(),
366        file_count: parsed.root.file_count(),
367    })
368}
369
370fn hex_encode(bytes: &[u8]) -> String {
371    let mut s = String::with_capacity(bytes.len() * 2);
372    for b in bytes {
373        s.push_str(&format!("{b:02x}"));
374    }
375    s
376}
377
378// ── Canonical Lisp spec ────────────────────────────────────────────
379
380pub const CANONICAL_STORE_OPS_LISP: &str =
381    include_str!("../specs/store_ops.lisp");
382
383/// Compile every authored store slice declaration.
384///
385/// # Errors
386///
387/// Returns an error if the Lisp source can't be parsed.
388pub fn load_canonical_slices() -> Result<Vec<StoreSlice>, SpecError> {
389    crate::loader::load_all::<StoreSlice>(CANONICAL_STORE_OPS_LISP)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn parsed_nar_walk_counts_correctly() {
398        let tmp = std::env::temp_dir().join("sui-parsed-nar-walk-test");
399        let _ = std::fs::remove_dir_all(&tmp);
400        std::fs::create_dir_all(&tmp).unwrap();
401        std::fs::write(tmp.join("a"), b"aaaa").unwrap();
402        std::fs::write(tmp.join("b"), b"bb").unwrap();
403        std::fs::create_dir_all(tmp.join("d")).unwrap();
404        std::fs::write(tmp.join("d/c"), b"ccc").unwrap();
405        let nar = crate::nar::encode(&tmp).unwrap();
406        let parsed = ParsedNar::parse(&nar).unwrap();
407        assert_eq!(parsed.root.file_count(), 3);
408        assert_eq!(parsed.root.total_bytes(), 4 + 2 + 3);
409        let _ = std::fs::remove_dir_all(&tmp);
410    }
411
412    #[test]
413    fn parsed_nar_at_path_finds_nested_file() {
414        let tmp = std::env::temp_dir().join("sui-parsed-nar-at-test");
415        let _ = std::fs::remove_dir_all(&tmp);
416        std::fs::create_dir_all(tmp.join("sub")).unwrap();
417        std::fs::write(tmp.join("sub/hello"), b"world").unwrap();
418        let nar = crate::nar::encode(&tmp).unwrap();
419        let parsed = ParsedNar::parse(&nar).unwrap();
420        let node = parsed.root.at_path("sub/hello").unwrap();
421        assert!(matches!(node, NarNode::File { .. }));
422        let _ = std::fs::remove_dir_all(&tmp);
423    }
424
425    #[test]
426    fn canonical_slices_parse() {
427        let slices = load_canonical_slices().unwrap();
428        assert!(!slices.is_empty(), "at least one canonical slice authored");
429    }
430}