1use serde::{Deserialize, Serialize};
11use tatara_lisp::DeriveTataraDomain;
12
13use crate::SpecError;
14
15#[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 #[serde(default)]
28 pub aux_dirs: Vec<AuxDir>,
29 #[serde(rename = "pathFormat")]
31 pub path_format: StorePathFormat,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone)]
36pub struct AuxDir {
37 pub name: String,
38 pub purpose: AuxDirPurpose,
39}
40
41#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum AuxDirPurpose {
44 Db,
46 GcRoots,
48 Profiles,
50 DaemonSocket,
52 Temp,
54 UserState,
56 EvalCache,
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
62pub enum StorePathFormat {
63 HashDashName,
65 AlgoColonHashDashName,
68}
69
70pub const CANONICAL_STORE_LAYOUT_LISP: &str =
73 include_str!("../specs/store_layout.lisp");
74
75pub fn load_canonical() -> Result<Vec<StoreLayout>, SpecError> {
81 crate::loader::load_all::<StoreLayout>(CANONICAL_STORE_LAYOUT_LISP)
82}
83
84pub 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 let top = entry.split('/').next().unwrap_or(entry);
109 match layout.path_format {
110 StorePathFormat::HashDashName => {
111 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 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
153pub 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#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ParsedStorePath {
183 pub algorithm: Option<String>,
186 pub hash: String,
188 pub name: String,
191 pub sub_path: Option<String>,
194}
195
196pub 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 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 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 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..]; 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#[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 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 #[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 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}