Skip to main content

sui_spec/
store_transform.rs

1//! Typed transformation AST over ParsedNar trees.
2//!
3//! `StoreTransform` is the substrate's declarative authoring
4//! surface for operating on NAR contents.  Operators write
5//! `(defstore-transform :name … :match-kind … :pattern …)` in
6//! Lisp; the Rust executor applies the transforms in declared
7//! order to any ParsedNar tree.
8//!
9//! Three transform kinds:
10//!
11//! - **FileContents** — regex match-and-replace over the bytes of
12//!   every file in the tree.  Used for redaction, secret-stripping,
13//!   build-id rewriting.
14//! - **StorePathReference** — replaces every literal
15//!   `/nix/store/<from>-*` occurrence (anywhere in file contents
16//!   OR symlink targets) with `/nix/store/<to>-*`.  Mirrors
17//!   cppnix's referrer-graft semantics.
18//! - **EntryName** — renames the top-level entries of a directory
19//!   matching a regex.
20
21use serde::{Deserialize, Serialize};
22use tatara_lisp::DeriveTataraDomain;
23
24use crate::SpecError;
25use crate::store_ops::{NarNode, ParsedNar};
26
27/// Typed transformation declaration.  Authored as
28/// `(defstore-transform …)` in Lisp.
29#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
30#[tatara(keyword = "defstore-transform")]
31pub struct StoreTransform {
32    /// Stable name (used for catalog lookup + report rows).
33    pub name: String,
34    /// Operator-facing description.
35    pub description: String,
36    /// Kind discriminator.
37    #[serde(rename = "matchKind")]
38    pub match_kind: TransformKind,
39    /// Pattern: regex for `FileContents`, prefix for
40    /// `StorePathReference` and `EntryName`.
41    pub pattern: String,
42    /// Replacement string.
43    pub replacement: String,
44}
45
46/// Discriminator for the three transform shapes.
47#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum TransformKind {
49    /// regex-replace over file bytes (UTF-8 lossy).
50    FileContents,
51    /// Replace `/nix/store/<from>-*` with `/nix/store/<to>-*`
52    /// throughout file contents + symlink targets.
53    StorePathReference,
54    /// Rename top-level directory entries matching the regex.
55    EntryName,
56}
57
58/// Outcome of applying one transform to one node.  Aggregated by
59/// the executor.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct TransformOutcome {
62    /// Transform name (for report rows).
63    pub transform_name: String,
64    /// Number of file-content rewrites applied.
65    pub file_rewrites: usize,
66    /// Number of store-path-reference rewrites applied.
67    pub ref_rewrites: usize,
68    /// Number of entries renamed.
69    pub entries_renamed: usize,
70}
71
72impl TransformOutcome {
73    /// `true` if the transform produced no changes (idempotent
74    /// re-apply path).
75    #[must_use]
76    pub fn is_noop(&self) -> bool {
77        self.file_rewrites == 0
78            && self.ref_rewrites == 0
79            && self.entries_renamed == 0
80    }
81}
82
83/// Apply a sequence of transforms to a `ParsedNar`.  Each
84/// transform fires in declared order; outcomes are returned in
85/// the same order.  The tree is mutated in place.
86///
87/// # Errors
88///
89/// - `transform-bad-regex` for invalid regexes in FileContents/EntryName.
90/// - `transform-bad-prefix` for malformed StorePathReference patterns.
91pub fn apply_all(
92    tree: &mut ParsedNar,
93    transforms: &[StoreTransform],
94) -> Result<Vec<TransformOutcome>, SpecError> {
95    let mut outcomes = Vec::with_capacity(transforms.len());
96    for t in transforms {
97        let outcome = apply_one(&mut tree.root, t)?;
98        outcomes.push(outcome);
99    }
100    Ok(outcomes)
101}
102
103/// Apply one transform, returning its outcome.
104///
105/// # Errors
106///
107/// Same as [`apply_all`].
108pub fn apply_one(
109    node: &mut NarNode,
110    transform: &StoreTransform,
111) -> Result<TransformOutcome, SpecError> {
112    match transform.match_kind {
113        TransformKind::FileContents => {
114            let re = regex::bytes::Regex::new(&transform.pattern)
115                .map_err(|e| SpecError::Interp {
116                    phase: "transform-bad-regex".into(),
117                    message: format!("{}: {e}", transform.name),
118                })?;
119            let replacement = transform.replacement.as_bytes();
120            let mut count = 0usize;
121            walk_files(node, &mut |contents| {
122                let new_bytes = re.replace_all(contents, replacement).into_owned();
123                if new_bytes != *contents {
124                    count += 1;
125                    *contents = new_bytes;
126                }
127            });
128            Ok(TransformOutcome {
129                transform_name: transform.name.clone(),
130                file_rewrites: count,
131                ..Default::default()
132            })
133        }
134        TransformKind::StorePathReference => {
135            // `pattern` is the 32-char base32 hash to match;
136            // `replacement` is the 32-char base32 hash to swap in.
137            // Both must be the same length so byte-offset math
138            // stays sane.
139            if transform.pattern.len() != transform.replacement.len() {
140                return Err(SpecError::Interp {
141                    phase: "transform-bad-prefix".into(),
142                    message: format!(
143                        "{}: StorePathReference requires same-length pattern + replacement (got {} vs {})",
144                        transform.name,
145                        transform.pattern.len(),
146                        transform.replacement.len(),
147                    ),
148                });
149            }
150            let from = transform.pattern.as_bytes();
151            let to = transform.replacement.as_bytes();
152            let mut count = 0usize;
153            walk_files(node, &mut |contents| {
154                let mut new_bytes = Vec::with_capacity(contents.len());
155                let mut i = 0;
156                while i < contents.len() {
157                    if i + from.len() <= contents.len()
158                        && &contents[i..i + from.len()] == from
159                    {
160                        new_bytes.extend_from_slice(to);
161                        count += 1;
162                        i += from.len();
163                    } else {
164                        new_bytes.push(contents[i]);
165                        i += 1;
166                    }
167                }
168                *contents = new_bytes;
169            });
170            walk_symlinks(node, &mut |target| {
171                if target.contains(&transform.pattern) {
172                    *target = target.replace(&transform.pattern, &transform.replacement);
173                    count += 1;
174                }
175            });
176            Ok(TransformOutcome {
177                transform_name: transform.name.clone(),
178                ref_rewrites: count,
179                ..Default::default()
180            })
181        }
182        TransformKind::EntryName => {
183            let re = regex::Regex::new(&transform.pattern)
184                .map_err(|e| SpecError::Interp {
185                    phase: "transform-bad-regex".into(),
186                    message: format!("{}: {e}", transform.name),
187                })?;
188            let replacement = &transform.replacement;
189            let mut count = 0usize;
190            if let NarNode::Directory { entries } = node {
191                for (name, _) in entries.iter_mut() {
192                    let new_name = re.replace_all(name, replacement.as_str()).into_owned();
193                    if new_name != *name {
194                        *name = new_name;
195                        count += 1;
196                    }
197                }
198                // Re-sort to maintain canonical NAR ordering.
199                entries.sort_by(|a, b| a.0.cmp(&b.0));
200            }
201            Ok(TransformOutcome {
202                transform_name: transform.name.clone(),
203                entries_renamed: count,
204                ..Default::default()
205            })
206        }
207    }
208}
209
210/// Walk every file node in the tree, calling `f(contents)` so
211/// the visitor can mutate the bytes in place.
212fn walk_files<F: FnMut(&mut Vec<u8>)>(node: &mut NarNode, f: &mut F) {
213    match node {
214        NarNode::File { contents, .. } => f(contents),
215        NarNode::Directory { entries } => {
216            for (_, child) in entries.iter_mut() {
217                walk_files(child, f);
218            }
219        }
220        NarNode::Symlink { .. } => {}
221    }
222}
223
224/// Walk every symlink node in the tree, calling `f(target)`.
225fn walk_symlinks<F: FnMut(&mut String)>(node: &mut NarNode, f: &mut F) {
226    match node {
227        NarNode::Symlink { target } => f(target),
228        NarNode::Directory { entries } => {
229            for (_, child) in entries.iter_mut() {
230                walk_symlinks(child, f);
231            }
232        }
233        NarNode::File { .. } => {}
234    }
235}
236
237// ── Canonical Lisp spec ────────────────────────────────────────────
238
239pub const CANONICAL_STORE_TRANSFORMS_LISP: &str =
240    include_str!("../specs/store_transforms.lisp");
241
242/// Compile every authored transform.
243///
244/// # Errors
245///
246/// Returns an error if the Lisp source can't be parsed.
247pub fn load_canonical() -> Result<Vec<StoreTransform>, SpecError> {
248    crate::loader::load_all::<StoreTransform>(CANONICAL_STORE_TRANSFORMS_LISP)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::store_ops::NarNode;
255
256    fn file(s: &[u8]) -> NarNode {
257        NarNode::File { executable: false, contents: s.to_vec() }
258    }
259    fn dir(entries: Vec<(&str, NarNode)>) -> NarNode {
260        NarNode::Directory {
261            entries: entries.into_iter().map(|(n, e)| (n.to_string(), e)).collect(),
262        }
263    }
264    fn symlink(target: &str) -> NarNode {
265        NarNode::Symlink { target: target.to_string() }
266    }
267
268    #[test]
269    fn canonical_transforms_parse() {
270        let xs = load_canonical().unwrap();
271        assert!(!xs.is_empty());
272    }
273
274    #[test]
275    fn file_contents_regex_rewrites() {
276        let mut tree = ParsedNar { root: dir(vec![
277            ("a", file(b"hello world")),
278            ("b", file(b"world peace")),
279        ]) };
280        let t = StoreTransform {
281            name: "test".into(),
282            description: "world → planet".into(),
283            match_kind: TransformKind::FileContents,
284            pattern: "world".into(),
285            replacement: "planet".into(),
286        };
287        let out = apply_one(&mut tree.root, &t).unwrap();
288        assert_eq!(out.file_rewrites, 2);
289
290        if let NarNode::Directory { entries } = &tree.root {
291            if let NarNode::File { contents, .. } = &entries[0].1 {
292                assert_eq!(contents, b"hello planet");
293            }
294            if let NarNode::File { contents, .. } = &entries[1].1 {
295                assert_eq!(contents, b"planet peace");
296            }
297        }
298    }
299
300    #[test]
301    fn store_path_reference_rewrites_in_files_and_symlinks() {
302        let mut tree = ParsedNar { root: dir(vec![
303            ("file", file(b"link to /nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x")),
304            ("ln",   symlink("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x")),
305        ]) };
306        let t = StoreTransform {
307            name: "graft".into(),
308            description: "".into(),
309            match_kind: TransformKind::StorePathReference,
310            pattern: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
311            replacement: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
312        };
313        let out = apply_one(&mut tree.root, &t).unwrap();
314        assert_eq!(out.ref_rewrites, 2);  // 1 in file + 1 in symlink
315
316        if let NarNode::Directory { entries } = &tree.root {
317            if let NarNode::File { contents, .. } = &entries[0].1 {
318                assert!(contents.windows(32).any(|w| w == b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
319            }
320            if let NarNode::Symlink { target } = &entries[1].1 {
321                assert_eq!(target, "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-x");
322            }
323        }
324    }
325
326    #[test]
327    fn store_path_reference_rejects_different_length() {
328        let mut tree = ParsedNar { root: file(b"") };
329        let t = StoreTransform {
330            name: "bad-len".into(),
331            description: "".into(),
332            match_kind: TransformKind::StorePathReference,
333            pattern: "short".into(),
334            replacement: "longer".into(),
335        };
336        let err = apply_one(&mut tree.root, &t).unwrap_err();
337        match err {
338            SpecError::Interp { phase, .. } => assert_eq!(phase, "transform-bad-prefix"),
339            _ => panic!("expected transform-bad-prefix"),
340        }
341    }
342
343    #[test]
344    fn entry_name_renames_top_level_only() {
345        let mut tree = ParsedNar { root: dir(vec![
346            ("old-x", file(b"")),
347            ("old-y", file(b"")),
348        ]) };
349        let t = StoreTransform {
350            name: "rename".into(),
351            description: "".into(),
352            match_kind: TransformKind::EntryName,
353            pattern: "^old-".into(),
354            replacement: "new-".into(),
355        };
356        let out = apply_one(&mut tree.root, &t).unwrap();
357        assert_eq!(out.entries_renamed, 2);
358        if let NarNode::Directory { entries } = &tree.root {
359            assert_eq!(entries[0].0, "new-x");
360            assert_eq!(entries[1].0, "new-y");
361        }
362    }
363
364    #[test]
365    fn idempotent_reapply_is_noop() {
366        let mut tree = ParsedNar { root: file(b"hello world") };
367        let t = StoreTransform {
368            name: "test".into(),
369            description: "".into(),
370            match_kind: TransformKind::FileContents,
371            pattern: "world".into(),
372            replacement: "planet".into(),
373        };
374        let first = apply_one(&mut tree.root, &t).unwrap();
375        let second = apply_one(&mut tree.root, &t).unwrap();
376        assert_eq!(first.file_rewrites, 1);
377        assert!(second.is_noop());
378    }
379}