Skip to main content

sui_spec/
store_layout.rs

1//! Typed border for the `/nix/store` directory layout.
2//!
3//! cppnix's store has a strict on-disk convention: hash component +
4//! `-` + sanitised name, plus a small set of auxiliary directories
5//! (`/nix/var/nix/{db,gcroots,profiles,daemon-socket,...}`).  This
6//! module names the layout as a typed Lisp spec so future
7//! store-implementations (sui-store today, alternate backends
8//! eventually) ride on the same contract.
9
10use serde::{Deserialize, Serialize};
11use tatara_lisp::DeriveTataraDomain;
12
13use crate::SpecError;
14
15// ── Typed border ───────────────────────────────────────────────────
16
17#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
18#[tatara(keyword = "defstore-layout")]
19pub struct StoreLayout {
20    pub name: String,
21    #[serde(rename = "storeRoot")]
22    pub store_root: String,
23    #[serde(rename = "stateRoot")]
24    pub state_root: String,
25    /// Auxiliary directories under `state_root` that must exist
26    /// for nix-compat operations.
27    #[serde(default)]
28    pub aux_dirs: Vec<AuxDir>,
29    /// Path-naming rule for entries directly under `store_root`.
30    #[serde(rename = "pathFormat")]
31    pub path_format: StorePathFormat,
32}
33
34/// One auxiliary directory under the store's state root.
35#[derive(Serialize, Deserialize, Debug, Clone)]
36pub struct AuxDir {
37    pub name: String,
38    pub purpose: AuxDirPurpose,
39}
40
41/// What each auxiliary directory holds.
42#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum AuxDirPurpose {
44    /// SQLite database of store metadata.
45    Db,
46    /// Symlinks holding paths alive through GC.
47    GcRoots,
48    /// Per-user profile generation symlinks.
49    Profiles,
50    /// Unix socket for the nix-daemon.
51    DaemonSocket,
52    /// Temporary build directories.
53    Temp,
54    /// Per-user shared state (lock files, etc.).
55    UserState,
56    /// Eval cache directory.
57    EvalCache,
58}
59
60/// Path-naming rule for entries directly under the store root.
61#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
62pub enum StorePathFormat {
63    /// `<hash>-<name>` where hash is 32-char nix-base32.  cppnix.
64    HashDashName,
65    /// `<algo>:<hash>-<name>` — multi-algo variant (CA-drv variants
66    /// may eventually need this).
67    AlgoColonHashDashName,
68}
69
70// ── Canonical spec ─────────────────────────────────────────────────
71
72pub const CANONICAL_STORE_LAYOUT_LISP: &str =
73    include_str!("../specs/store_layout.lisp");
74
75/// Compile every authored store layout.
76///
77/// # Errors
78///
79/// Returns an error if the Lisp source fails to parse.
80pub fn load_canonical() -> Result<Vec<StoreLayout>, SpecError> {
81    crate::loader::load_all::<StoreLayout>(CANONICAL_STORE_LAYOUT_LISP)
82}
83
84// ── M3.0 store-path validator ──────────────────────────────────────
85
86/// Validate that a path conforms to the layout's `path_format`
87/// rule + lives under the layout's `store_root`.
88///
89/// # Errors
90///
91/// - `store-path-not-rooted` if `path` doesn't start with
92///   `store_root + "/"`.
93/// - `store-path-bad-format` if the entry name doesn't match
94///   `<hash>-<name>` (or the algo-colon variant).
95pub fn validate_path(layout: &StoreLayout, path: &str) -> Result<(), SpecError> {
96    let prefix = format!("{}/", layout.store_root);
97    let Some(entry) = path.strip_prefix(&prefix) else {
98        return Err(SpecError::Interp {
99            phase: "store-path-not-rooted".into(),
100            message: format!(
101                "path `{path}` doesn't live under store root `{}`",
102                layout.store_root,
103            ),
104        });
105    };
106    // The entry may have a trailing /subdir; take only the
107    // top-level component.
108    let top = entry.split('/').next().unwrap_or(entry);
109    match layout.path_format {
110        StorePathFormat::HashDashName => {
111            // hash is the 32-char nix-base32 prefix; followed by
112            // `-` + name.
113            let Some(dash) = top.find('-') else {
114                return Err(SpecError::Interp {
115                    phase: "store-path-bad-format".into(),
116                    message: format!(
117                        "entry `{top}` missing `-` separator (HashDashName)",
118                    ),
119                });
120            };
121            if dash != 32 {
122                return Err(SpecError::Interp {
123                    phase: "store-path-bad-format".into(),
124                    message: format!(
125                        "entry `{top}` has hash component of length {dash}, expected 32",
126                    ),
127                });
128            }
129            Ok(())
130        }
131        StorePathFormat::AlgoColonHashDashName => {
132            // alg:hash-name
133            let Some(colon) = top.find(':') else {
134                return Err(SpecError::Interp {
135                    phase: "store-path-bad-format".into(),
136                    message: format!(
137                        "entry `{top}` missing `:` separator (AlgoColonHashDashName)",
138                    ),
139                });
140            };
141            let rest = &top[colon + 1..];
142            if !rest.contains('-') {
143                return Err(SpecError::Interp {
144                    phase: "store-path-bad-format".into(),
145                    message: format!("entry `{top}` missing `-` after algo prefix"),
146                });
147            }
148            Ok(())
149        }
150    }
151}
152
153/// Validate a path against every canonical store layout, returning
154/// the first match.  The substrate-level abstraction over the
155/// `load_canonical().iter().find_map(parse_path)` dance that 10+
156/// commands in the sui binary all wrote inline.
157///
158/// # Errors
159///
160/// - Propagates `load_canonical` errors.
161/// - `store-path-no-layout-matches` when no layout accepts the path.
162pub fn validate_against_canonical(path: &str) -> Result<ParsedStorePath, SpecError> {
163    let layouts = load_canonical()?;
164    for layout in &layouts {
165        if let Ok(parsed) = parse_path(layout, path) {
166            return Ok(parsed);
167        }
168    }
169    Err(SpecError::Interp {
170        phase: "store-path-no-layout-matches".into(),
171        message: format!(
172            "`{path}` doesn't parse under any canonical store layout (`{}`)",
173            layouts.iter().map(|l| l.name.as_str()).collect::<Vec<_>>().join("` / `"),
174        ),
175    })
176}
177
178/// Parsed store path components.  cppnix store paths decompose
179/// into `<hash>-<name>` (HashDashName) or `<algo>:<hash>-<name>`
180/// (AlgoColonHashDashName), optionally followed by `/subpath`.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ParsedStorePath {
183    /// Optional algorithm prefix (`sha256` etc.) — only set when
184    /// the layout's path_format is AlgoColonHashDashName.
185    pub algorithm: Option<String>,
186    /// The hash component (cppnix: 32-char nix-base32).
187    pub hash: String,
188    /// The name component (everything after the hash separator
189    /// up to a possible `/subpath`).
190    pub name: String,
191    /// Optional sub-path beneath the top-level store entry.
192    /// `/nix/store/<hash>-<name>/bin/hello` → `bin/hello`.
193    pub sub_path: Option<String>,
194}
195
196/// Decompose a store path into its typed components.
197///
198/// Sibling of [`validate_path`] — `validate_path` returns Ok/Err;
199/// `parse_path` returns the actual structure so operators and
200/// downstream tooling can inspect it.
201///
202/// # Errors
203///
204/// Returns the same typed errors `validate_path` does
205/// (`store-path-not-rooted`, `store-path-bad-format`).
206pub fn parse_path(layout: &StoreLayout, path: &str) -> Result<ParsedStorePath, SpecError> {
207    let prefix = format!("{}/", layout.store_root);
208    let Some(entry) = path.strip_prefix(&prefix) else {
209        return Err(SpecError::Interp {
210            phase: "store-path-not-rooted".into(),
211            message: format!(
212                "path `{path}` doesn't live under store root `{}`",
213                layout.store_root,
214            ),
215        });
216    };
217
218    // Split top-level entry from optional sub-path.
219    let (top, sub_path) = match entry.split_once('/') {
220        Some((t, rest)) => (t, Some(rest.to_string())),
221        None => (entry, None),
222    };
223
224    match layout.path_format {
225        StorePathFormat::HashDashName => {
226            // <hash>-<name>
227            let Some(dash) = top.find('-') else {
228                return Err(SpecError::Interp {
229                    phase: "store-path-bad-format".into(),
230                    message: format!(
231                        "entry `{top}` missing `-` separator (HashDashName)",
232                    ),
233                });
234            };
235            if dash != 32 {
236                return Err(SpecError::Interp {
237                    phase: "store-path-bad-format".into(),
238                    message: format!(
239                        "entry `{top}` has hash component of length {dash}, expected 32",
240                    ),
241                });
242            }
243            Ok(ParsedStorePath {
244                algorithm: None,
245                hash: top[..dash].to_string(),
246                name: top[dash + 1..].to_string(),
247                sub_path,
248            })
249        }
250        StorePathFormat::AlgoColonHashDashName => {
251            // <algo>:<hash>-<name>
252            let Some(colon) = top.find(':') else {
253                return Err(SpecError::Interp {
254                    phase: "store-path-bad-format".into(),
255                    message: format!(
256                        "entry `{top}` missing `:` separator (AlgoColonHashDashName)",
257                    ),
258                });
259            };
260            let (algo, rest) = top.split_at(colon);
261            let rest = &rest[1..]; // skip ':'
262            let Some(dash) = rest.find('-') else {
263                return Err(SpecError::Interp {
264                    phase: "store-path-bad-format".into(),
265                    message: format!("entry `{top}` missing `-` after algo prefix"),
266                });
267            };
268            Ok(ParsedStorePath {
269                algorithm: Some(algo.to_string()),
270                hash: rest[..dash].to_string(),
271                name: rest[dash + 1..].to_string(),
272                sub_path,
273            })
274        }
275    }
276}
277
278/// Compute the absolute path of an auxiliary directory inside the
279/// layout's state root.  Returns `None` if the layout doesn't
280/// declare the requested purpose.
281#[must_use]
282pub fn aux_dir_path(layout: &StoreLayout, purpose: AuxDirPurpose) -> Option<String> {
283    layout
284        .aux_dirs
285        .iter()
286        .find(|d| d.purpose == purpose)
287        .map(|d| format!("{}/{}", layout.state_root, d.name))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn canonical_store_layouts_parse() {
296        let layouts = load_canonical().expect("canonical store layouts must compile");
297        assert!(!layouts.is_empty());
298    }
299
300    #[test]
301    fn cppnix_layout_has_canonical_paths() {
302        let layouts = load_canonical().unwrap();
303        let cppnix = layouts
304            .iter()
305            .find(|l| l.name == "cppnix")
306            .expect("cppnix layout must exist");
307        assert_eq!(cppnix.store_root, "/nix/store");
308        assert_eq!(cppnix.state_root, "/nix/var/nix");
309        assert_eq!(cppnix.path_format, StorePathFormat::HashDashName);
310    }
311
312    // ── M3.0 validator tests ───────────────────────────────────
313
314    fn cppnix() -> StoreLayout {
315        load_canonical().unwrap().into_iter()
316            .find(|l| l.name == "cppnix").unwrap()
317    }
318
319    #[test]
320    fn validate_path_accepts_well_formed() {
321        let layout = cppnix();
322        validate_path(&layout, "/nix/store/0000000000000000000000000000abcd-hello").unwrap();
323        validate_path(&layout, "/nix/store/0000000000000000000000000000abcd-hello/bin/hello").unwrap();
324    }
325
326    // ── parse_path tests ────────────────────────────────────────
327
328    #[test]
329    fn parse_path_decomposes_hash_dash_name() {
330        let layout = cppnix();
331        let parsed = parse_path(
332            &layout,
333            "/nix/store/0000000000000000000000000000abcd-hello-2.12",
334        ).unwrap();
335        assert_eq!(parsed.algorithm, None);
336        assert_eq!(parsed.hash, "0000000000000000000000000000abcd");
337        assert_eq!(parsed.name, "hello-2.12");
338        assert_eq!(parsed.sub_path, None);
339    }
340
341    #[test]
342    fn parse_path_captures_sub_path() {
343        let layout = cppnix();
344        let parsed = parse_path(
345            &layout,
346            "/nix/store/0000000000000000000000000000abcd-hello/bin/hello",
347        ).unwrap();
348        assert_eq!(parsed.sub_path.as_deref(), Some("bin/hello"));
349    }
350
351    #[test]
352    fn parse_path_errors_for_unrooted() {
353        let layout = cppnix();
354        let err = parse_path(&layout, "/tmp/not-in-store").unwrap_err();
355        match err {
356            SpecError::Interp { phase, .. } => {
357                assert_eq!(phase, "store-path-not-rooted");
358            }
359            _ => panic!("expected Interp error"),
360        }
361    }
362
363    #[test]
364    fn parse_path_errors_for_short_hash() {
365        let layout = cppnix();
366        let err = parse_path(&layout, "/nix/store/short-hello").unwrap_err();
367        match err {
368            SpecError::Interp { phase, .. } => {
369                assert_eq!(phase, "store-path-bad-format");
370            }
371            _ => panic!("expected Interp error"),
372        }
373    }
374
375    #[test]
376    fn parse_path_errors_when_dash_missing() {
377        let layout = cppnix();
378        // 32 chars, no dash.
379        let err = parse_path(
380            &layout,
381            "/nix/store/00000000000000000000000000000000",
382        ).unwrap_err();
383        match err {
384            SpecError::Interp { phase, .. } => {
385                assert_eq!(phase, "store-path-bad-format");
386            }
387            _ => panic!("expected Interp error"),
388        }
389    }
390
391    #[test]
392    fn validate_path_rejects_unrooted() {
393        let layout = cppnix();
394        let err = validate_path(&layout, "/tmp/foo").unwrap_err();
395        match err {
396            SpecError::Interp { phase, .. } => assert_eq!(phase, "store-path-not-rooted"),
397            _ => panic!("expected store-path-not-rooted"),
398        }
399    }
400
401    #[test]
402    fn validate_path_rejects_missing_dash() {
403        let layout = cppnix();
404        let err = validate_path(&layout, "/nix/store/no_separator_here").unwrap_err();
405        match err {
406            SpecError::Interp { phase, .. } => assert_eq!(phase, "store-path-bad-format"),
407            _ => panic!("expected store-path-bad-format"),
408        }
409    }
410
411    #[test]
412    fn aux_dir_path_resolves_canonical_purposes() {
413        let layout = cppnix();
414        assert_eq!(
415            aux_dir_path(&layout, AuxDirPurpose::Db).as_deref(),
416            Some("/nix/var/nix/db"),
417        );
418        assert_eq!(
419            aux_dir_path(&layout, AuxDirPurpose::GcRoots).as_deref(),
420            Some("/nix/var/nix/gcroots"),
421        );
422    }
423
424    #[test]
425    fn cppnix_layout_includes_essential_auxdirs() {
426        let layouts = load_canonical().unwrap();
427        let cppnix = layouts.iter().find(|l| l.name == "cppnix").unwrap();
428        let purposes: std::collections::HashSet<AuxDirPurpose> =
429            cppnix.aux_dirs.iter().map(|d| d.purpose).collect();
430        for required in [
431            AuxDirPurpose::Db,
432            AuxDirPurpose::GcRoots,
433            AuxDirPurpose::Profiles,
434            AuxDirPurpose::DaemonSocket,
435        ] {
436            assert!(
437                purposes.contains(&required),
438                "cppnix layout missing {required:?} aux dir",
439            );
440        }
441    }
442}