sui_eval/path.rs
1//! Centralized path resolution for the Nix evaluator.
2//!
3//! All path operations (normalization, relative resolution, import resolution)
4//! go through this module to ensure consistent behavior.
5
6use std::cell::RefCell;
7use std::path::{Component, Path, PathBuf};
8
9thread_local! {
10 /// Registry of fetched flake-input source trees: each entry maps the
11 /// cppnix `/nix/store/<narhash>-source` STORE-PATH STRING (what an
12 /// input's `outPath`/`sourceInfo.outPath` exposes for byte-parity) to
13 /// the ACTUAL on-disk directory the tree lives at (the sui fetcher
14 /// cache `~/.cache/sui/inputs/…`, or the literal path for a
15 /// `type = "path"` input).
16 ///
17 /// sui does NOT copy fetched trees into `/nix/store` at that path, so a
18 /// flake's own Nix code that reads `${input.outPath}/<subpath>` (an
19 /// `import`, `readFile`, `pathExists`, `readDir`, …) would resolve
20 /// against a directory that does not exist. `materialize` redirects the
21 /// FILESYSTEM READ to the real tree while the store-path STRING flowing
22 /// through eval stays byte-correct — no hash, no derivation, no value
23 /// ever changes.
24 ///
25 /// The marquee darwin root (2026-07-11) set the store-path string; this
26 /// registry closes its sibling: arbitrary `${outPath}/subpath` reads
27 /// (the prior peel special-cased only reading `flake.nix`).
28 /// Tuple is `(store_path, read_dir, read_dir_canon)` — the third field is
29 /// `read_dir.canonicalize()` computed ONCE at registration (falling back to
30 /// `read_dir` on failure, the same semantics the per-call fallback had).
31 ///
32 /// WHY REGISTRATION-TIME (perf root, found live 2026-07-21): `dematerialize`
33 /// used to call `read_dir.canonicalize()` INSIDE its per-entry loop, on
34 /// every call — and its callers are the path-literal arms of `eval_expr`,
35 /// i.e. every `./x` in every .nix file. On the cid marquee eval (~60
36 /// registered inputs), sampling the live process showed **61% of the eval
37 /// thread's wall-clock inside `__getattrlist`**, the syscall macOS
38 /// `realpath` issues per path component. One canonicalize per registration
39 /// replaces N-per-dematerialize-call; the registered trees are immutable
40 /// fetcher caches, so the value cannot go stale.
41 static INPUT_SOURCE_MAP: RefCell<Vec<(PathBuf, PathBuf, PathBuf)>> = const { RefCell::new(Vec::new()) };
42
43 /// Registry of `builtins.fetch*` result trees: each entry maps the REAL
44 /// on-disk fetcher-cache directory (e.g. `$TMPDIR/sui-fetchGit/<cache-hash>`)
45 /// to the STORE-PATH NAME CppNix gives the tree when it is copied into the
46 /// store as a derivation `src` — `"source"` for `fetchGit`/`fetchTree`/
47 /// `fetchTarball` (or the explicit `name` arg where one is honored).
48 ///
49 /// sui's fetchers return a raw temp `Value::Path` whose basename is a
50 /// content-addressing cache-hash (a 64-char sha256 hex), NOT a store path.
51 /// When that path is later coerced-to-store as a derivation `src`
52 /// (`zshSynHlSrc = builtins.fetchGit {…}`), the copy-to-store code named
53 /// the store path after the temp basename (`<store-hash>-<cache-hash>`)
54 /// instead of CppNix's `<store-hash>-source`. This registry is the
55 /// fetcher-family sibling of `INPUT_SOURCE_MAP`: it records the correct
56 /// `-source` NAME for each fetcher result so `source_name_for_read_dir`
57 /// resolves the copy-to-store name to CppNix's convention. Only the NAME
58 /// changes — the bytes (→ NAR hash) are identical either way.
59 /// Tuple is `(read_dir, name, read_dir_canon)` — canon computed once at
60 /// registration, same rationale as `INPUT_SOURCE_MAP` above.
61 static FETCHED_SOURCE_NAMES: RefCell<Vec<(PathBuf, String, PathBuf)>> = const { RefCell::new(Vec::new()) };
62
63 /// Success-only memo for probe-side `canonicalize` calls.
64 ///
65 /// `dematerialize`/`source_name_for_read_dir` canonicalize their argument
66 /// on every call, and eval hands them the same paths over and over (every
67 /// path literal in a file, every position report). A SUCCESSFUL
68 /// canonicalization of a source path is stable for the life of an eval —
69 /// the same frozen-source assumption CppNix itself makes when it copies a
70 /// tree to the store. FAILURES are deliberately NOT memoized: a path that
71 /// does not exist yet (an IFD output, a store path about to be realized)
72 /// may exist later, and caching the failure would wrongly pin it.
73 static CANON_MEMO: RefCell<std::collections::HashMap<PathBuf, PathBuf>> =
74 RefCell::new(std::collections::HashMap::new());
75}
76
77/// `path.canonicalize()` through the success-only thread-local memo.
78fn canonicalize_memo(path: &Path) -> Option<PathBuf> {
79 if let Some(hit) = CANON_MEMO.with(|m| m.borrow().get(path).cloned()) {
80 return Some(hit);
81 }
82 match path.canonicalize() {
83 Ok(canon) => {
84 CANON_MEMO.with(|m| {
85 m.borrow_mut().insert(path.to_path_buf(), canon.clone());
86 });
87 Some(canon)
88 }
89 Err(_) => None,
90 }
91}
92
93/// Register a `builtins.fetch*` result directory with the store-path NAME
94/// CppNix would give its copied-to-store tree (`"source"` for
95/// `fetchGit`/`fetchTree`/`fetchTarball`). Idempotent per `read_dir`. Does
96/// not affect any string value — only the name a later copy-to-store
97/// coercion of this exact path assigns to its store path.
98pub fn register_fetched_source(read_dir: &Path, name: &str) {
99 FETCHED_SOURCE_NAMES.with(|m| {
100 let mut m = m.borrow_mut();
101 if m.iter().any(|(rd, _, _)| rd == read_dir) {
102 return;
103 }
104 // Canon once here instead of per lookup — the tree is an immutable
105 // fetcher cache, so this cannot go stale. Fallback mirrors the old
106 // per-call `unwrap_or_else`.
107 let canon = read_dir
108 .canonicalize()
109 .unwrap_or_else(|_| read_dir.to_path_buf());
110 m.push((read_dir.to_path_buf(), name.to_string(), canon));
111 });
112}
113
114/// Register a fetched flake-input source tree so subsequent filesystem
115/// reads under its `/nix/store/<narhash>-source` store path resolve to the
116/// real on-disk `read_dir`. Idempotent per `store_path`. Does not affect
117/// any string value — only where reads land on disk.
118pub fn register_input_source(store_path: &Path, read_dir: &Path) {
119 // Only store paths need remapping — a `type = "path"` input whose
120 // outPath already equals its real on-disk dir is a no-op (and would
121 // shadow nothing), so skip it.
122 if store_path == read_dir {
123 return;
124 }
125 INPUT_SOURCE_MAP.with(|m| {
126 let mut m = m.borrow_mut();
127 if m.iter().any(|(sp, _, _)| sp == store_path) {
128 return;
129 }
130 // Canon once at registration — see the INPUT_SOURCE_MAP doc for the
131 // measured 61%-of-eval-in-getattrlist root this replaces.
132 let canon = read_dir
133 .canonicalize()
134 .unwrap_or_else(|_| read_dir.to_path_buf());
135 m.push((store_path.to_path_buf(), read_dir.to_path_buf(), canon));
136 });
137}
138
139/// If `path` lies under a registered flake-input store-path prefix, rewrite
140/// that prefix to the input's real on-disk `read_dir`; otherwise return
141/// `path` unchanged. This is a FILESYSTEM-READ-ONLY redirect — callers use
142/// the result to touch disk, never to build a value the evaluator observes.
143#[must_use]
144pub fn materialize(path: &Path) -> PathBuf {
145 INPUT_SOURCE_MAP.with(|m| {
146 for (store_path, read_dir, _) in m.borrow().iter() {
147 if path == store_path {
148 return read_dir.clone();
149 }
150 if let Ok(suffix) = path.strip_prefix(store_path) {
151 return read_dir.join(suffix);
152 }
153 }
154 path.to_path_buf()
155 })
156}
157
158/// Convenience: `materialize` a `&str` path, returning an owned `String`.
159#[must_use]
160pub fn materialize_str(path: &str) -> String {
161 materialize(Path::new(path)).to_string_lossy().into_owned()
162}
163
164/// REVERSE of [`materialize`] for SOURCE POSITIONS: given a REAL on-disk
165/// path (a fetcher-cache `~/.cache/sui/inputs/…` file that eval actually
166/// read from), return the flake input's `/nix/store/<narhash>-source`
167/// STORE-PATH equivalent (with the same relative subpath appended); returns
168/// `path` unchanged when it is not under any registered input's cache dir.
169///
170/// This closes the position half of the store↔cache seam. `materialize`
171/// redirects a store-path READ down to the cache; `dematerialize` lifts a
172/// cache-path back up to the store path for REPORTING — so
173/// `builtins.unsafeGetAttrPos`/`__curPos` reports the store-source `.file`
174/// CppNix reports (`/nix/store/<h>-source/lib/foo.nix`), NOT the sui fetcher
175/// cache dir. nix-darwin's `doc/manual` `hasPrefix <nix-darwin>.outPath decl`
176/// rewrite only fires when `decl` carries the store prefix — the
177/// `options.json` dock-declarations root.
178///
179/// Both sides are compared after `canonicalize` on the cache side (a
180/// symlinked cache dir — macOS `/tmp` → `/private/tmp`, `~` expansion —
181/// still matches the registered `read_dir`, which is canonicalized here
182/// too). Only the reported STRING changes; no value the evaluator observes
183/// is mutated — the byte-parity invariant.
184#[must_use]
185pub fn dematerialize(path: &Path) -> PathBuf {
186 // Probe canon through the success-only memo; a failed canon falls back to
187 // the raw path exactly as before. The per-entry `read_dir` canon is now
188 // precomputed at registration — this loop used to re-realpath every
189 // registered input on every call, which sampling showed as 61% of the
190 // eval thread's wall-clock on the cid marquee (getattrlist per component,
191 // per input, per path literal).
192 let canon = canonicalize_memo(path);
193 let probe: &Path = canon.as_deref().unwrap_or(path);
194 INPUT_SOURCE_MAP.with(|m| {
195 for (store_path, read_dir, rd_canon) in m.borrow().iter() {
196 if probe == rd_canon.as_path() {
197 return store_path.clone();
198 }
199 if let Ok(suffix) = probe.strip_prefix(rd_canon) {
200 return store_path.join(suffix);
201 }
202 // Also try the un-canonicalized read_dir (registration may have
203 // stored a symlinked path); harmless when it already matched above.
204 if path == read_dir.as_path() {
205 return store_path.clone();
206 }
207 if let Ok(suffix) = path.strip_prefix(read_dir) {
208 return store_path.join(suffix);
209 }
210 }
211 path.to_path_buf()
212 })
213}
214
215/// Convenience: [`dematerialize`] a `&str` path, returning an owned `String`.
216#[must_use]
217pub fn dematerialize_str(path: &str) -> String {
218 dematerialize(Path::new(path)).to_string_lossy().into_owned()
219}
220
221/// Reverse of [`materialize`] for the copy-to-store NAMING rule: given a
222/// REAL on-disk directory (the fetcher-cache `read_dir` a `materialize`
223/// already resolved to, then `canonicalize`d), return the store-path
224/// BASENAME of the flake input that tree belongs to — i.e. CppNix's
225/// `-source` name — when `real` IS that input's whole tree root.
226///
227/// This closes the darwin `system-path` root: a fetched flake input's
228/// `src = ./.` copies the input's own tree back into the store; CppNix names
229/// that copy after the input's `/nix/store/<h>-source` basename
230/// (`<h>-source`), but sui reads the tree from `~/.cache/sui/inputs/<narhash>`
231/// whose basename is `<repo>-<rev>`. Only the NAME needs correcting — the
232/// bytes (→ NAR hash) are identical either way — so this maps the physical
233/// read location back to the logical `-source` name.
234///
235/// Both sides are `canonicalize`d before comparison so a symlinked cache dir
236/// (macOS `/tmp` → `/private/tmp`, `~` expansion) still matches. Returns
237/// `None` when `real` is not a registered input root (a normal local
238/// `src = ./.` keeps its own directory basename).
239#[must_use]
240pub fn source_name_for_read_dir(real: &Path) -> Option<String> {
241 // Probe via the success-only memo; per-entry canons are precomputed at
242 // registration (see the INPUT_SOURCE_MAP doc for the measured root).
243 let real_canon = canonicalize_memo(real)?;
244 // 1) Flake-input trees: a fetched input's `src = ./.` copies the input's
245 // whole tree, named after the input's `/nix/store/<h>-source` basename.
246 let from_input = INPUT_SOURCE_MAP.with(|m| {
247 for (store_path, _read_dir, rd_canon) in m.borrow().iter() {
248 // Only the WHOLE tree root maps to the input's `-source` name; a
249 // subpath (`src = ./subdir`) copies a sub-tree CppNix names after
250 // that subdir, so require an exact root match.
251 if real_canon == *rd_canon {
252 return store_path
253 .file_name()
254 .map(|n| n.to_string_lossy().into_owned());
255 }
256 }
257 None
258 });
259 if from_input.is_some() {
260 return from_input;
261 }
262 // 2) `builtins.fetch*` result trees: `zshSynHlSrc = builtins.fetchGit {…}`
263 // coerced-to-store must be named `source` (CppNix's convention), NOT the
264 // fetcher-cache temp basename (a 64-char sha256 cache-hash). The fetcher
265 // registered the correct name for this exact dir.
266 FETCHED_SOURCE_NAMES.with(|m| {
267 for (_read_dir, name, rd_canon) in m.borrow().iter() {
268 if real_canon == *rd_canon {
269 return Some(name.clone());
270 }
271 }
272 None
273 })
274}
275
276/// Normalize a path by removing `.` components and resolving `..` components.
277/// Unlike `canonicalize()`, this doesn't require the path to exist on disk.
278#[must_use]
279pub fn normalize(path: &Path) -> PathBuf {
280 let mut out = Vec::new();
281 for component in path.components() {
282 match component {
283 Component::CurDir => {}
284 Component::ParentDir => {
285 out.pop();
286 }
287 other => out.push(other),
288 }
289 }
290 if out.is_empty() {
291 PathBuf::from(".")
292 } else {
293 out.iter().collect()
294 }
295}
296
297/// Canonicalize an ABSOLUTE path string exactly the way CppNix's
298/// `canonPath` does — the byte-for-byte semantics of a Nix path *value*.
299///
300/// CppNix canonicalizes every path literal on evaluation: `.` components
301/// vanish, `..` pops the preceding component **but is clamped at the
302/// filesystem root** (`/..` → `/`, never below), and redundant separators
303/// collapse. The result always begins with a single `/` and never carries
304/// a trailing `/` (except the root itself, which is `/`).
305///
306/// This differs from [`normalize`] in the one load-bearing way the marquee
307/// cid root exposed: `normalize` uses `Path::components()`, whose
308/// `ParentDir` arm unconditionally `pop()`s — so `/..` collapses to `.`
309/// (root is popped, out empties) instead of clamping to `/`. A path VALUE
310/// must never escape its root, so absolute paths take this dedicated
311/// root-aware canonicalizer.
312///
313/// Only ABSOLUTE inputs (leading `/`) are canonicalized here; a
314/// non-absolute input is returned unchanged so callers can keep their own
315/// resolution semantics (relative-to-eval-dir, `~`-home, `<search>`).
316///
317/// Examples (all verified against CppNix):
318/// - `/.` → `/` (the `lib.path.hasStorePathPrefix` root case)
319/// - `/foo/./bar` → `/foo/bar`
320/// - `/foo/../bar` → `/bar`
321/// - `/..` → `/` (root clamp)
322/// - `/a/../..` → `/` (root clamp after underflow)
323/// - `/nix/store` → `/nix/store` (identity)
324#[must_use]
325pub fn canon_abs(raw: &str) -> String {
326 if !raw.starts_with('/') {
327 return raw.to_string();
328 }
329 let mut components: Vec<&str> = Vec::new();
330 for seg in raw.split('/') {
331 match seg {
332 // Empty (leading `/`, doubled `//`, trailing `/`) and `.` vanish.
333 "" | "." => {}
334 ".." => {
335 // Clamp at root: popping an empty stack is a no-op, so an
336 // absolute path can never escape below `/`.
337 components.pop();
338 }
339 other => components.push(other),
340 }
341 }
342 if components.is_empty() {
343 "/".to_string()
344 } else {
345 let mut out = String::with_capacity(raw.len());
346 for c in &components {
347 out.push('/');
348 out.push_str(c);
349 }
350 out
351 }
352}
353
354/// Resolve a relative path against a base directory, normalizing the result.
355#[must_use]
356pub fn resolve_relative(base: &Path, relative: &str) -> PathBuf {
357 normalize(&base.join(relative))
358}
359
360/// Resolve an import path.
361/// - Absolute paths are returned as-is (normalized).
362/// - Relative paths are resolved against `base_dir`.
363/// - If the result is a directory, append `/default.nix`.
364///
365/// # Errors
366///
367/// Returns an error if the path is relative but no `base_dir` is provided.
368pub fn resolve_import(base_dir: Option<&Path>, raw: &str) -> Result<PathBuf, String> {
369 let resolved = if Path::new(raw).is_absolute() {
370 normalize(Path::new(raw))
371 } else {
372 let base = base_dir.ok_or_else(|| {
373 format!("relative import '{raw}' with no base directory")
374 })?;
375 resolve_relative(base, raw)
376 };
377
378 // The directory-vs-file probe must consult the REAL tree (a fetched
379 // input's `/nix/store/<narhash>-source` prefix isn't materialized on
380 // disk), but the RETURNED path keeps the store-path prefix so relative
381 // imports inside the target re-enter this remap and eval-dir/string
382 // tracking stays byte-correct. Only the on-disk read (at the call site)
383 // is redirected via `materialize`.
384 if materialize(&resolved).is_dir() {
385 Ok(resolved.join("default.nix"))
386 } else {
387 Ok(resolved)
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn normalize_removes_dot() {
397 assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
398 }
399
400 #[test]
401 fn normalize_resolves_dotdot() {
402 assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c"));
403 }
404
405 #[test]
406 fn normalize_multiple_dots() {
407 assert_eq!(
408 normalize(Path::new("/a/./b/./c")),
409 PathBuf::from("/a/b/c")
410 );
411 }
412
413 #[test]
414 fn normalize_preserves_absolute() {
415 assert_eq!(normalize(Path::new("/a/b/c")), PathBuf::from("/a/b/c"));
416 }
417
418 #[test]
419 fn normalize_empty_result_becomes_dot() {
420 assert_eq!(normalize(Path::new(".")), PathBuf::from("."));
421 }
422
423 #[test]
424 fn resolve_relative_basic() {
425 assert_eq!(
426 resolve_relative(Path::new("/base"), "sub/file.nix"),
427 PathBuf::from("/base/sub/file.nix")
428 );
429 }
430
431 #[test]
432 fn resolve_relative_with_dotdot() {
433 assert_eq!(
434 resolve_relative(Path::new("/base/sub"), "../file.nix"),
435 PathBuf::from("/base/file.nix")
436 );
437 }
438
439 #[test]
440 fn resolve_import_absolute() {
441 let r = resolve_import(None, "/absolute/path.nix").unwrap();
442 assert_eq!(r, PathBuf::from("/absolute/path.nix"));
443 }
444
445 #[test]
446 fn resolve_import_relative_needs_base() {
447 assert!(resolve_import(None, "./relative.nix").is_err());
448 }
449
450 // ── canon_abs: CppNix path-value canonicalization ──────────────
451 //
452 // Every case verified against `nix eval --raw --expr 'toString <p>'`.
453 // The marquee case is `/.` → `/` — the failing clause of
454 // `lib.path.hasStorePathPrefix`'s root assertion in the cid closure.
455
456 #[test]
457 fn canon_abs_root_dot() {
458 // THE marquee root: `/.` must canonicalize to `/`.
459 assert_eq!(canon_abs("/."), "/");
460 }
461
462 #[test]
463 fn canon_abs_root_identity() {
464 assert_eq!(canon_abs("/"), "/");
465 }
466
467 #[test]
468 fn canon_abs_removes_dot() {
469 assert_eq!(canon_abs("/foo/./bar"), "/foo/bar");
470 }
471
472 #[test]
473 fn canon_abs_resolves_dotdot() {
474 assert_eq!(canon_abs("/foo/../bar"), "/bar");
475 }
476
477 #[test]
478 fn canon_abs_dotdot_clamps_at_root() {
479 // CppNix never escapes root: `/..` → `/`, `/a/../..` → `/`.
480 assert_eq!(canon_abs("/.."), "/");
481 assert_eq!(canon_abs("/../.."), "/");
482 assert_eq!(canon_abs("/a/../.."), "/");
483 }
484
485 #[test]
486 fn canon_abs_collapses_redundant_slashes() {
487 assert_eq!(canon_abs("/foo//bar"), "/foo/bar");
488 assert_eq!(canon_abs("/nix/store/"), "/nix/store");
489 }
490
491 #[test]
492 fn canon_abs_store_path_identity() {
493 assert_eq!(canon_abs("/nix/store"), "/nix/store");
494 assert_eq!(
495 canon_abs("/nix/store/nvl9ic0pj1fpyln3zaqrf4cclbqdfn1j-foo"),
496 "/nix/store/nvl9ic0pj1fpyln3zaqrf4cclbqdfn1j-foo"
497 );
498 }
499
500 #[test]
501 fn canon_abs_leaves_relative_untouched() {
502 // Non-absolute inputs are the caller's concern (eval-dir / ~ / <search>).
503 assert_eq!(canon_abs("~/foo"), "~/foo");
504 assert_eq!(canon_abs("./foo"), "./foo");
505 }
506
507 #[test]
508 fn resolve_import_absolute_with_dotdot() {
509 let r = resolve_import(None, "/a/b/../c.nix").unwrap();
510 assert_eq!(r, PathBuf::from("/a/c.nix"));
511 }
512
513 #[test]
514 fn resolve_import_directory_appends_default_nix() {
515 // Use a known directory that exists on all systems.
516 let r = resolve_import(None, "/tmp").unwrap();
517 assert_eq!(r, PathBuf::from("/tmp/default.nix"));
518 }
519
520 #[test]
521 fn resolve_import_relative_with_base() {
522 // The relative path won't be a directory on disk, so no default.nix append.
523 let r = resolve_import(Some(Path::new("/base")), "sub/file.nix").unwrap();
524 assert_eq!(r, PathBuf::from("/base/sub/file.nix"));
525 }
526
527 // ── flake-input source materialization (marquee darwin root #3) ──
528 //
529 // A fetched flake input's `outPath` is a `/nix/store/<narhash>-source`
530 // STORE-PATH STRING that sui never copies to disk; the real tree lives
531 // in the fetcher cache. `register_input_source` + `materialize` redirect
532 // the FILESYSTEM READ of `${outPath}/subpath` to the cache while the
533 // store-path string is never mutated — the byte-parity invariant.
534
535 #[test]
536 fn materialize_remaps_registered_store_prefix() {
537 register_input_source(
538 Path::new("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source"),
539 Path::new("/home/u/.cache/sui/inputs/dead"),
540 );
541 // A read of `${outPath}/lib/foo.nix` lands in the real cache tree.
542 assert_eq!(
543 materialize(Path::new(
544 "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source/lib/foo.nix"
545 )),
546 PathBuf::from("/home/u/.cache/sui/inputs/dead/lib/foo.nix"),
547 );
548 // The bare store path itself remaps to the tree root.
549 assert_eq!(
550 materialize(Path::new(
551 "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-source"
552 )),
553 PathBuf::from("/home/u/.cache/sui/inputs/dead"),
554 );
555 }
556
557 #[test]
558 fn dematerialize_lifts_cache_path_to_store_source() {
559 // The options.json dock-declarations root (Layer B): a source
560 // position read from the fetcher-cache dir must be REPORTED as the
561 // input's `/nix/store/<h>-source` path so nix-darwin's `hasPrefix`
562 // rewrite fires. `dematerialize` is the reverse of `materialize`.
563 let cache = tempfile::tempdir().unwrap();
564 std::fs::create_dir_all(cache.path().join("modules/system")).unwrap();
565 let store = Path::new(
566 "/nix/store/npm9dap7j0i92l524y09x255zi9447qp-source",
567 );
568 register_input_source(store, cache.path());
569 // A subpath under the cache dir lifts to the store path + subpath.
570 let real = cache.path().join("modules/system/dock.nix");
571 assert_eq!(
572 dematerialize(&real),
573 store.join("modules/system/dock.nix"),
574 );
575 // The cache root itself lifts to the bare store path.
576 assert_eq!(dematerialize(cache.path()), store.to_path_buf());
577 }
578
579 #[test]
580 fn dematerialize_passes_unregistered_paths_through() {
581 // A local (unregistered) path is reported verbatim — only a
582 // registered input's cache subtree is lifted to its store path.
583 let unrelated = tempfile::tempdir().unwrap();
584 let p = unrelated.path().join("some/file.nix");
585 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
586 std::fs::write(&p, b"x").unwrap();
587 assert_eq!(dematerialize(&p), p);
588 }
589
590 #[test]
591 fn materialize_passes_unregistered_paths_through() {
592 // An unrelated store path (or any other path) is untouched — the
593 // remap only fires for a registered input source prefix.
594 let p = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-hello-2.12/bin/hello";
595 assert_eq!(materialize(Path::new(p)), PathBuf::from(p));
596 assert_eq!(materialize(Path::new("/etc/nix/nix.conf")), PathBuf::from("/etc/nix/nix.conf"));
597 }
598
599 #[test]
600 fn register_input_source_ignores_identity_mapping() {
601 // A `type = "path"` input whose outPath already equals its real dir
602 // registers nothing (and cannot shadow the tree with itself).
603 let dir = Path::new("/some/local/path-input");
604 register_input_source(dir, dir);
605 assert_eq!(materialize(dir), PathBuf::from(dir));
606 }
607
608 #[test]
609 fn register_input_source_prefix_boundary_is_exact() {
610 // A sibling store path that merely SHARES a textual prefix with a
611 // registered one must NOT be remapped — `strip_prefix` is
612 // path-component-aware, so `…-source-extra` is not under `…-source`.
613 register_input_source(
614 Path::new("/nix/store/cccccccccccccccccccccccccccccccc-source"),
615 Path::new("/cache/c"),
616 );
617 let sibling = "/nix/store/cccccccccccccccccccccccccccccccc-source-extra/x";
618 assert_eq!(materialize(Path::new(sibling)), PathBuf::from(sibling));
619 }
620
621 #[test]
622 fn source_name_maps_read_dir_root_to_store_source_name() {
623 // The darwin `system-path` root: a fetched flake input's `src = ./.`
624 // copies the tree read from the fetcher cache `read_dir`, but the copy
625 // must carry the input's `/nix/store/<h>-source` BASENAME (CppNix's
626 // name), not the cache dir's `<repo>-<rev>` basename.
627 let cache = tempfile::tempdir().unwrap();
628 let store_path = Path::new(
629 "/nix/store/9qcaaxf4dyy09df0gv4ibfj93aplq3jk-source",
630 );
631 register_input_source(store_path, cache.path());
632 // The whole-tree root maps to the input's `-source` name.
633 assert_eq!(
634 source_name_for_read_dir(cache.path()).as_deref(),
635 Some("9qcaaxf4dyy09df0gv4ibfj93aplq3jk-source"),
636 );
637 }
638
639 #[test]
640 fn source_name_returns_none_for_unregistered_or_subpath() {
641 // A local `src = ./.` (unregistered) keeps its own basename → None,
642 // so the caller falls back to the real dir's file_name. A SUBpath of a
643 // registered root also returns None (only the whole-tree root is the
644 // `-source` copy — `src = ./subdir` is named after the subdir).
645 let unrelated = tempfile::tempdir().unwrap();
646 assert_eq!(source_name_for_read_dir(unrelated.path()), None);
647
648 let cache = tempfile::tempdir().unwrap();
649 std::fs::create_dir(cache.path().join("subdir")).unwrap();
650 register_input_source(
651 Path::new("/nix/store/dddddddddddddddddddddddddddddddd-source"),
652 cache.path(),
653 );
654 assert_eq!(source_name_for_read_dir(&cache.path().join("subdir")), None);
655 }
656
657 #[test]
658 fn fetched_source_dir_maps_to_source_name() {
659 // The zsh-syntax-highlighting-config root: `builtins.fetchGit` returns a
660 // temp dir whose basename is a 64-char sha256 cache-hash. Coerced-to-
661 // store as a derivation `src`, CppNix names it `-source`, not the
662 // cache-hash. `register_fetched_source` records that intended name.
663 let cache = tempfile::tempdir().unwrap();
664 register_fetched_source(cache.path(), "source");
665 assert_eq!(
666 source_name_for_read_dir(cache.path()).as_deref(),
667 Some("source"),
668 );
669 }
670
671 #[test]
672 fn fetched_source_registry_honors_explicit_name() {
673 // The registered name flows through verbatim (a fetcher that honors an
674 // explicit `name` arg would register that name instead of "source").
675 let cache = tempfile::tempdir().unwrap();
676 register_fetched_source(cache.path(), "my-thing");
677 assert_eq!(
678 source_name_for_read_dir(cache.path()).as_deref(),
679 Some("my-thing"),
680 );
681 }
682}