sui_eval/builtins/flake_eval.rs
1//! Flake evaluation pipeline.
2//!
3//! Implements the in-process equivalent of `nix eval --raw '(builtins.getFlake
4//! "<dir>")'` for path-based flake references.
5
6use crate::value::*;
7
8thread_local! {
9 pub(crate) static FLAKE_EVAL_DEPTH: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };
10}
11
12pub(crate) const MAX_FLAKE_EVAL_DEPTH: u32 = 50;
13
14/// The authoritative lock context threaded through transitive-input
15/// resolution. Holds the ROOT flake's lock (shared, immutable) plus the
16/// *node name* of the flake currently being evaluated.
17///
18/// CppNix pins a flake's ENTIRE transitive input closure in the root lock's
19/// node graph and passes each sub-flake its inputs as resolved by THAT graph
20/// (walking `follows`), never letting a sub-flake re-resolve from its own
21/// `flake.lock`. Threading this context is what makes sui's transitive
22/// resolution byte-identical to nix — the marquee root cause where sui read
23/// `ishou`'s own lock (`substrate = fcd35143…`) instead of honoring the root
24/// lock's `ishou.inputs.substrate = ["substrate"]` follows edge (→ root's
25/// `substrate_5 = b2802c62…`), diverging every downstream drvPath.
26#[derive(Clone)]
27struct FlakeContext {
28 lock: std::rc::Rc<sui_compat::flake::FlakeLock>,
29 /// Node name of the flake at `flake_dir` in `lock`'s node graph.
30 node_name: String,
31}
32
33/// Evaluate a flake directory — reads flake.nix, parses flake.lock, resolves
34/// inputs, calls `outputs(inputs)`, and returns the merged result attrset.
35///
36/// This is the native implementation of `builtins.getFlake` for path-based
37/// references. External callers (orchestrate, CLI) can use this to evaluate
38/// a local flake without shelling out to `nix eval`.
39///
40/// This is the ROOT entrypoint: it reads `flake_dir/flake.lock` as the
41/// authoritative closure and evaluates from its root node. Transitive inputs
42/// are resolved against THIS lock (see [`FlakeContext`]), never their own.
43pub fn evaluate_flake(flake_dir: &std::path::Path) -> Result<Value, EvalError> {
44 evaluate_flake_ctx(flake_dir, None)
45}
46
47/// Depth-guarded flake evaluation with an optional inherited lock context.
48///
49/// `ctx = None` ⇒ the ROOT flake: read `flake_dir/flake.lock`.
50/// `ctx = Some(_)` ⇒ a transitive input: resolve its inputs from the inherited
51/// root lock at the given node name; the sub-flake's own lock is ignored.
52fn evaluate_flake_ctx(
53 flake_dir: &std::path::Path,
54 ctx: Option<FlakeContext>,
55) -> Result<Value, EvalError> {
56 let depth = FLAKE_EVAL_DEPTH.with(|d| {
57 let mut d = d.borrow_mut();
58 *d += 1;
59 *d
60 });
61
62 if depth > MAX_FLAKE_EVAL_DEPTH {
63 FLAKE_EVAL_DEPTH.with(|d| *d.borrow_mut() -= 1);
64 return Err(EvalError::RecursionLimit(
65 format!(
66 "maximum flake evaluation depth ({MAX_FLAKE_EVAL_DEPTH}) exceeded at {}",
67 flake_dir.display()
68 ),
69 ));
70 }
71
72 let result = evaluate_flake_inner(flake_dir, ctx);
73 FLAKE_EVAL_DEPTH.with(|d| *d.borrow_mut() -= 1);
74 result
75}
76
77fn evaluate_flake_inner(
78 flake_dir: &std::path::Path,
79 ctx: Option<FlakeContext>,
80) -> Result<Value, EvalError> {
81 let flake_nix = flake_dir.join("flake.nix");
82 let flake_lock_path = flake_dir.join("flake.lock");
83
84 // 1. Read and evaluate flake.nix.
85 let source = std::fs::read_to_string(&flake_nix).map_err(|e| {
86 EvalError::IoError {
87 context: format!("getFlake: {}", flake_nix.display()),
88 message: e.to_string(),
89 }
90 })?;
91 let _flake_file_guard = crate::eval::push_eval_file(flake_nix.clone());
92 let flake_value = crate::eval::eval_with_file(&source, Some(flake_nix.clone()))?;
93 let flake_attrs = flake_value.to_attrs()?.clone();
94
95 // 2. Pull out the outputs function (required by every flake).
96 let outputs_value = flake_attrs
97 .get("outputs")
98 .ok_or_else(|| EvalError::AttrNotFound("outputs".into()))?
99 .clone();
100 let outputs_fn = crate::eval::force_value(&outputs_value)?;
101
102 // 3. Establish the authoritative lock context.
103 //
104 // ROOT invocation (`ctx = None`): read THIS dir's flake.lock — it is the
105 // authoritative closure for the whole tree. TRANSITIVE invocation
106 // (`ctx = Some`): INHERIT the root lock + this input's node name; the
107 // sub-flake's own flake.lock is deliberately NOT read (that was the
108 // divergence — a sub-flake re-resolving its inputs from its own pins
109 // instead of the root lock's `follows`-redirected node graph).
110 let (lock, current_node): (Option<std::rc::Rc<sui_compat::flake::FlakeLock>>, String) =
111 match &ctx {
112 Some(c) => (Some(c.lock.clone()), c.node_name.clone()),
113 None => {
114 if flake_lock_path.exists() {
115 let lock_content =
116 std::fs::read_to_string(&flake_lock_path).map_err(|e| {
117 EvalError::IoError {
118 context: format!("getFlake: {}", flake_lock_path.display()),
119 message: e.to_string(),
120 }
121 })?;
122 let parsed = sui_compat::flake::FlakeLock::parse(&lock_content).map_err(
123 |e| EvalError::TypeError(format!("getFlake: invalid flake.lock: {e}")),
124 )?;
125 let root = parsed.root.clone();
126 (Some(std::rc::Rc::new(parsed)), root)
127 } else {
128 (None, String::new())
129 }
130 }
131 };
132
133 // 3b. Create the content-addressed input fetcher.
134 let fetcher = crate::fetcher::InputFetcher::new();
135
136 // 4. Resolve every direct input of the CURRENT node against the root lock.
137 let self_path = flake_dir.to_string_lossy().to_string();
138 let mut resolved_inputs = NixAttrs::new();
139
140 if let Some(ref lock) = lock {
141 // Resolved `(input_name, target_node_name)` edges of the current
142 // node in the ROOT lock's graph — `follows` already redirected.
143 let edges = lock.node_input_edges(¤t_node);
144 for (input_name, target_node_name) in edges {
145 let Some(node) = lock.nodes.get(&target_node_name) else {
146 continue;
147 };
148
149 let mut input_val = NixAttrs::new();
150
151 // `out_path` is the cppnix `/nix/store/<narhash>-source`
152 // STORE-PATH STRING the input's `outPath`/`sourceInfo`
153 // must expose (byte-parity). `read_dir` is the ACTUAL
154 // on-disk directory the tree lives at — the sui fetcher
155 // cache (`~/.cache/sui/inputs/…`) for a fetched input, or
156 // the literal path for a `type = "path"` input. These two
157 // DIFFER for fetched inputs: sui computes the store-path
158 // string via `nar_hash_source_tree` but does NOT copy the
159 // tree into `/nix/store` at that path, so reading
160 // `flake.nix` / recursing via `evaluate_flake` MUST use
161 // `read_dir`, never `out_path` (the marquee darwin root
162 // that made every un-materialized transitive input —
163 // blackmatter-vpn, …-tailscale — silently drop its flake
164 // outputs and surface as `AttrNotFound("darwinModules")`).
165 let mut read_dir: Option<std::path::PathBuf> = None;
166 let source_out_path = if let Some(ref locked) = node.locked {
167 if locked.source_type == "path" {
168 let p = locked.path.clone().unwrap_or_default();
169 read_dir = Some(std::path::PathBuf::from(&p));
170 p
171 } else {
172 // Byte-parity root (marquee darwin proof, 2026-07-11):
173 // CppNix copies every fetched flake input tree INTO the
174 // nix store as `/nix/store/<narhash>-source` and exposes
175 // THAT store path as the input's `outPath` — the same
176 // step `self` already runs below (§4c
177 // `nar_hash_source_tree`). The transitive inputs
178 // previously used the raw fetcher CACHE path
179 // (`~/.cache/sui/inputs/…`) verbatim, so any system
180 // config that embeds `nixpkgs.source` into a derivation
181 // (nix-darwin's `/etc/nix/registry.json`, NIX_PATH)
182 // diverged from cppnix at the toplevel drvPath while the
183 // whole module fixpoint matched byte-for-byte. Mirror
184 // `self`: NAR-hash the fetched tree to its cppnix
185 // `-source` store path.
186 match fetcher.fetch(locked) {
187 Ok(fetched_path) => {
188 read_dir = Some(fetched_path.clone());
189 match sui_compat::source::nar_hash_source_tree(
190 &fetched_path,
191 "source",
192 ) {
193 Ok(sh) => sh.store_path,
194 Err(e) => {
195 return Err(EvalError::TypeError(format!(
196 "nar-hashing flake input '{input_name}' tree at {}: {e}",
197 fetched_path.display(),
198 )));
199 }
200 }
201 }
202 Err(e) => {
203 return Err(EvalError::IoError {
204 context: format!("fetch flake input '{input_name}'"),
205 message: e.to_string(),
206 });
207 }
208 }
209 }
210 } else {
211 // No `locked` section for this node, so there is nothing to
212 // fetch and no store path to compute. This used to emit
213 // `/nix/store/flake-input-<name>` — a path that does not
214 // exist, yet which PASSES the `starts_with("/nix/store/")`
215 // tests below and so acquired copy-to-store string context
216 // and an input-source registration, masquerading as a real
217 // store reference until it failed as an opaque ENOENT.
218 //
219 // The sentinel is now self-describing and deliberately NOT
220 // store-shaped, so the two guards below skip it and any use
221 // names the input it came from. See `theory/BALIZA-PLAN.md`
222 // §2.5 (measured on rio, 2026-08-08).
223 format!("<unresolved-flake-input:{input_name}>")
224 };
225
226 // ── `dir=` SUBFLAKES ───────────────────────────────────────
227 // A flake input may name a flake in a SUBDIRECTORY of its
228 // source (`github:owner/repo?dir=sub`, carried as `dir` on the
229 // locked ref). CppNix then exposes two DIFFERENT paths:
230 //
231 // sourceInfo.outPath = /nix/store/<narhash>-source
232 // outPath = /nix/store/<narhash>-source/<dir>
233 //
234 // and reads `flake.nix` from the subdirectory.
235 //
236 // `dir` was PARSED (`sui-compat::flake`, with tests asserting it
237 // round-trips) and then consumed NOWHERE — so sui evaluated the
238 // repo-ROOT `flake.nix` for every subflake input. The field
239 // existed, its test passed, the behaviour was absent.
240 //
241 // Measured on rio 2026-08-08 (`theory/BALIZA-PLAN.md` §2.5):
242 // `blue-bidamas` is `github:pleme-io/blue?dir=bidamas`, whose own
243 // `bidamas/flake.nix` declares ONLY `nixpkgs` (outputs are
244 // `{ self, nixpkgs }`), while blue's ROOT flake.nix also declares
245 // `substrate`. sui read the root, saw a `substrate` input the
246 // lock correctly had no edge for, and the failure surfaced far
247 // away as a non-existent store path. CppNix on the same input:
248 // `outPath = <root>/bidamas`, `sourceInfo.outPath = <root>`.
249 //
250 // `sourceInfo` keeps the ROOT; only `outPath` and the on-disk
251 // read directory descend into `dir`.
252 let subdir = node.locked.as_ref().and_then(|l| l.dir.clone());
253 let out_path = match subdir {
254 Some(ref d) if !d.is_empty() => {
255 read_dir = read_dir.map(|p| p.join(d));
256 format!("{source_out_path}/{d}")
257 }
258 _ => source_out_path.clone(),
259 };
260 // Register the `outPath` → real-tree mapping so any read the
261 // flake's own Nix code issues under this input's `-source`
262 // store path (`import "${input.outPath}/lib/foo.nix"`,
263 // `readFile`, `pathExists`, `readDir`, `builtins.path`)
264 // resolves against `read_dir` (the fetcher cache / literal
265 // path) instead of the un-materialized store path — while the
266 // store-path STRING flowing through eval stays byte-correct.
267 // (The prior peel special-cased only recursing into
268 // `flake.nix`; this generalizes to ALL `${outPath}/subpath`
269 // reads.)
270 if out_path.starts_with("/nix/store/")
271 && let Some(ref rd) = read_dir {
272 crate::path::register_input_source(
273 std::path::Path::new(&out_path),
274 rd,
275 );
276 }
277
278 // A flake input's `outPath` is a `/nix/store/…-source` store
279 // reference: it must carry copy-to-store STRING CONTEXT so that,
280 // when a downstream derivation embeds it (nix-darwin's
281 // `registry.json` `to.path`), the ATerm gains the matching
282 // `source` inputSrc — cppnix records exactly this, and the
283 // parity-bisect on the darwin toplevel flagged it as the
284 // `nix-only=["source"]` inputSrc gap. Non-store paths (a
285 // `type = "path"` input, the pre-fetch placeholder) stay
286 // context-free.
287 let out_path_val = if out_path.starts_with("/nix/store/") {
288 let mut ctx = crate::value::StringContext::new();
289 ctx.add_plain(out_path.as_str());
290 Value::String(std::rc::Rc::new(
291 crate::value::NixString::with_context(out_path.as_str(), ctx),
292 ))
293 } else {
294 Value::string(out_path.clone())
295 };
296 input_val.insert("outPath".to_string(), out_path_val.clone());
297
298 if let Some(ref locked) = node.locked {
299 if let Some(ref rev) = locked.rev {
300 input_val.insert("rev".to_string(), Value::string(rev.clone()));
301 let short: String = rev.chars().take(7).collect();
302 input_val.insert("shortRev".to_string(), Value::string(short));
303 }
304 if let Some(ref nar_hash) = locked.nar_hash {
305 input_val.insert(
306 "narHash".to_string(),
307 Value::string(nar_hash.clone()),
308 );
309 }
310 if let Some(last_modified) = locked.last_modified {
311 // CppNix emits BOTH `lastModified` (int) and `lastModifiedDate`
312 // (YYYYMMDDHHMMSS string) on every flake input. sui emitted only
313 // the int, so nixpkgs' `versionSuffix` — built from
314 // lastModifiedDate — fell back to the Unix epoch and every NixOS
315 // system got named `...25.11.19700101.<rev>` instead of
316 // `...25.11.20260630.<rev>`. That changes the system NAME, hence
317 // the toplevel drvPath: a silent whole-system divergence.
318 input_val.insert(
319 "lastModified".to_string(),
320 Value::Int(last_modified as i64),
321 );
322 input_val.insert(
323 "lastModifiedDate".to_string(),
324 Value::string(super::fetchers::format_unix_yyyymmddhhmmss(
325 last_modified as i64,
326 )),
327 );
328 }
329
330 let mut source_info = NixAttrs::new();
331 // `sourceInfo.outPath` is the SOURCE ROOT, never the `dir=`
332 // subdirectory — cppnix keeps the two distinct, and only
333 // `outPath` above descends. For a non-subflake input the two
334 // are equal, so this is a no-op there.
335 source_info.insert(
336 "outPath".to_string(),
337 if source_out_path.starts_with("/nix/store/") {
338 let mut ctx = crate::value::StringContext::new();
339 ctx.add_plain(source_out_path.as_str());
340 Value::String(std::rc::Rc::new(
341 crate::value::NixString::with_context(
342 source_out_path.as_str(),
343 ctx,
344 ),
345 ))
346 } else {
347 Value::string(source_out_path.clone())
348 },
349 );
350 if let Some(ref rev) = locked.rev {
351 source_info.insert("rev".to_string(), Value::string(rev.clone()));
352 }
353 if let Some(ref nar_hash) = locked.nar_hash {
354 source_info.insert(
355 "narHash".to_string(),
356 Value::string(nar_hash.clone()),
357 );
358 }
359 if let Some(last_modified) = locked.last_modified {
360 source_info.insert(
361 "lastModified".to_string(),
362 Value::Int(last_modified as i64),
363 );
364 source_info.insert(
365 "lastModifiedDate".to_string(),
366 Value::string(super::fetchers::format_unix_yyyymmddhhmmss(
367 last_modified as i64,
368 )),
369 );
370 }
371 input_val.insert("sourceInfo".to_string(), Value::Attrs(Rc::new(source_info)));
372 }
373
374 let is_flake = node.flake.unwrap_or(true);
375 if is_flake {
376 // Read `flake.nix` and recurse from the ACTUAL on-disk
377 // tree (`read_dir` — the fetcher cache / literal path),
378 // NOT the `out_path` STORE-PATH STRING which sui does
379 // not materialize into `/nix/store`. Fall back to
380 // `out_path` only when it genuinely exists (e.g. a store
381 // path already materialized by a prior real nix build).
382 let eval_dir: std::path::PathBuf = match &read_dir {
383 Some(d) if d.join("flake.nix").exists() => d.clone(),
384 _ => std::path::PathBuf::from(&out_path),
385 };
386 let has_flake_nix = eval_dir.join("flake.nix").exists();
387 if has_flake_nix {
388 let immediate = input_val;
389 let dir = eval_dir;
390 // Recurse with the INHERITED root lock at the target
391 // input's node name — never re-reading the sub-flake's
392 // own flake.lock. This is the byte-parity fix: the
393 // sub-flake's transitive inputs resolve against the one
394 // authoritative closure (with `follows` redirection),
395 // exactly as CppNix does.
396 let child_ctx = FlakeContext {
397 lock: lock.clone(),
398 node_name: target_node_name.clone(),
399 };
400 let thunk = Thunk::new_native(move || {
401 let mut merged = immediate;
402 let flake_result =
403 evaluate_flake_ctx(&dir, Some(child_ctx.clone()))?;
404 match flake_result {
405 Value::Attrs(ref flake_out_attrs) => {
406 for (k, v) in flake_out_attrs.iter() {
407 if !merged.contains_key(&k) {
408 merged.insert(k.clone(), v.clone());
409 }
410 }
411 }
412 // SILENT DROP, CLOSED 2026-08-08. A non-attrs
413 // result used to be ignored, leaving the input
414 // with only its sourceInfo-shaped attrs and NONE
415 // of its outputs — which surfaces far away as
416 // `AttrNotFound('nixosModules' | 'darwinModules')`
417 // on a consumer that had every right to expect
418 // them. Name it here instead.
419 other => {
420 return Err(EvalError::TypeError(format!(
421 "flake input '{}' evaluated its flake.nix to a \
422 {} rather than an attribute set, so it \
423 contributes NO outputs. Every attribute a \
424 consumer reads from this input (nixosModules, \
425 darwinModules, overlays, packages…) would \
426 otherwise fail as a bare AttrNotFound far from \
427 this point.",
428 child_ctx.node_name,
429 other.type_name(),
430 )));
431 }
432 }
433 Ok(Value::Attrs(Rc::new(merged)))
434 });
435 resolved_inputs.insert(input_name, Value::Thunk(thunk));
436 continue;
437 }
438
439 // SILENT DROP, CLOSED 2026-08-08 — the second half of the
440 // same class. The node is declared a FLAKE (`flake` absent
441 // or true) but no `flake.nix` was found at either candidate
442 // directory, so the code fell through to the plain-attrs
443 // insert below and the input arrived carrying its sourceInfo
444 // fields and NONE of its outputs. Nothing said so; the
445 // failure surfaced later and elsewhere as
446 // `AttrNotFound('nixosModules' | 'darwinModules')` — the
447 // shape `theory/BALIZA-PLAN.md` records for blackmatter-vpn
448 // and …-tailscale.
449 //
450 // cppnix refuses this outright ("path ... does not contain a
451 // 'flake.nix', consider using 'flake = false'"), so erroring
452 // is the parity-correct behaviour, not a stricter one. It is
453 // LAZY here only so that a flake declaring an input it never
454 // touches is not punished for it.
455 let node_for_msg = target_node_name.clone();
456 let searched = eval_dir.display().to_string();
457 let thunk = Thunk::new_native(move || {
458 Err(EvalError::TypeError(format!(
459 "flake input '{node_for_msg}' is declared as a flake but no \
460 `flake.nix` was found for it (looked in '{searched}'). It \
461 therefore contributes no outputs at all — reads of \
462 nixosModules / darwinModules / overlays / packages on it \
463 would otherwise fail as a bare AttrNotFound far from here. \
464 If this input is genuinely not a flake, declare it \
465 `flake = false`."
466 )))
467 });
468 resolved_inputs.insert(input_name, Value::Thunk(thunk));
469 continue;
470 }
471
472 resolved_inputs.insert(input_name, Value::Attrs(Rc::new(input_val)));
473 }
474 }
475
476 // 4b. Fill in stub entries for declared-but-unresolved inputs.
477 if let Some(inputs_value) = flake_attrs.get("inputs")
478 && let Ok(inputs_forced) = crate::eval::force_value(inputs_value)
479 && let Value::Attrs(declared_inputs) = inputs_forced {
480 for key in declared_inputs.keys() {
481 if !resolved_inputs.contains_key(&key) {
482 // An input reaches here ONLY when it was declared in
483 // `flake.nix` but never resolved from the lock — either
484 // its edge target is missing from `lock.nodes` (§4's
485 // `continue`) or `node_input_edges` could not resolve
486 // the ref at all (it pushes only `if let Ok(target)`,
487 // and `adjacency_map`'s doc concedes "any unresolvable
488 // edges are silently skipped").
489 //
490 // This used to hand the input a FABRICATED store path,
491 // `/nix/store/flake-input-<key>`. That path does not
492 // exist and never will, so the failure surfaced
493 // thousands of eval-steps later as a bare ENOENT on
494 // `import`, naming neither the flake nor the input —
495 // measured on rio 2026-08-08 (`theory/BALIZA-PLAN.md`
496 // §2.5). Worse, the fake path STARTS WITH `/nix/store/`,
497 // so it also picked up copy-to-store string context and
498 // an input-source registration, i.e. it masqueraded as a
499 // real store reference all the way down.
500 //
501 // A resolution miss now fails AT THE BORDER with a typed
502 // error naming `(node, input)`. It stays LAZY — CppNix
503 // only errors when an unresolved input is actually used,
504 // and a flake may legally declare an input it never
505 // touches, so erroring eagerly here would reject flakes
506 // cppnix accepts.
507 let mut stub = NixAttrs::new();
508 let input_key = key.clone();
509 let owner = current_node.clone();
510 stub.insert(
511 "outPath".to_string(),
512 Value::Thunk(crate::value::Thunk::new_native(move || {
513 Err(EvalError::TypeError(format!(
514 "flake input '{input_key}' is declared in the \
515 `inputs` of flake node '{owner}' but was not \
516 resolved from flake.lock: its lock edge is \
517 missing or unresolvable. sui previously \
518 substituted the non-existent path \
519 `/nix/store/flake-input-{input_key}` here, \
520 which failed later as an opaque ENOENT."
521 )))
522 })),
523 );
524 resolved_inputs.insert(key.clone(), Value::Attrs(Rc::new(stub)));
525 }
526 }
527 }
528
529 // 4c. Hash the source tree. Computes the CppNix-compatible
530 // /nix/store/<hash>-source path + SRI narHash that flake
531 // consumers see under `outPath` / `sourceInfo.narHash`.
532 // Verified byte-identical to CppNix on both trivial fixtures
533 // and real pleme-io flakes with .git present.
534 let source_hash = sui_compat::source::nar_hash_source_tree(
535 std::path::Path::new(&self_path),
536 "source",
537 ).map_err(|e| EvalError::TypeError(
538 format!("getFlake: nar-hashing source tree at {self_path}: {e}")
539 ))?;
540 let source_store_path = source_hash.store_path.clone();
541 let source_nar_sri = source_hash.nar_hash_sri.clone();
542
543 // ── ★ THE ROOT FLAKE NEEDS THE SAME REGISTRATION ITS INPUTS GET ───────
544 // `register_input_source` was called in the INPUTS loop only, so `self`
545 // (and `self.sourceInfo`) handed out a `/nix/store/<narhash>-source` path
546 // that resolved to nothing: it is never copied into the store, and with no
547 // map entry there was nothing to redirect it to either.
548 //
549 // Measured 2026-08-17, on this repo and on a trivial fixture:
550 //
551 // builtins.pathExists (f.outPath + "/flake.nix") => false
552 // builtins.readFile (f.outPath + "/flake.nix") => ENOENT
553 //
554 // where CppNix answers `true` and the contents. That is the shape behind
555 // the `hashFile` failure chased earlier today: substrate's D2 gate reads
556 // `${src}/Cargo.lock` where `src = self`, so the guard passed on paths the
557 // read then could not find. Fixing `hashFile`'s incantation was necessary
558 // and not sufficient — the path it was handed pointed nowhere.
559 //
560 // `self_path` is the real on-disk tree (a local dir or a fetched input's
561 // cache dir), which is exactly what the redirect wants.
562 if source_store_path.starts_with("/nix/store/") {
563 crate::path::register_input_source(
564 std::path::Path::new(&source_store_path),
565 std::path::Path::new(&self_path),
566 );
567 }
568 // Byte-parity root (marquee darwin, GATE 1 2026-07-15): a LOCKED flake
569 // input's `self` must carry the input's own `rev`/`shortRev`/`lastModified`
570 // — exactly as CppNix populates `sourceInfo` for a fetched git input.
571 // nix-darwin's `flake.nix` derives the system label from
572 // `self.shortRev or self.dirtyShortRev or "dirty"` (→ `darwin-system-25.11.<shortRev>`);
573 // without the input's own self-rev, sui fell to `"dirty"` and the cid
574 // toplevel drvPath diverged only in that one label string. `current_node`
575 // is this flake's node in the ROOT lock (transitive ctx → the locked input;
576 // ROOT ctx → the dirty local tree, which carries no `rev` and stays
577 // `dirty`-capable, matching CppNix's dirty top-level self).
578 let (self_rev, self_last_modified): (Option<String>, Option<i64>) = lock
579 .as_ref()
580 .and_then(|l| l.nodes.get(¤t_node))
581 .and_then(|n| n.locked.as_ref())
582 .map(|lk| (lk.rev.clone(), lk.last_modified.map(|m| m as i64)))
583 .unwrap_or((None, None));
584 let self_short_rev: Option<String> =
585 self_rev.as_ref().map(|r| r.chars().take(7).collect());
586
587 // `self.outPath` (and `self.sourceInfo.outPath`) is a
588 // `/nix/store/<narhash>-source` store reference — the flake's OWN source
589 // tree copied into the store. It MUST carry copy-to-store STRING CONTEXT so
590 // that, when a downstream derivation embeds it as `src` (the substrate rust
591 // builder's `src = self`/`./.` workspace source — `rust_sui`'s whole tree),
592 // the dependent's ATerm records the matching `source` inputSrc. cppnix
593 // records exactly this; the parity-bisect on the cid darwin toplevel flagged
594 // its absence as the `rust_sui` `nix-only=["source"]` inputSrc gap. This is
595 // the `self` sibling of the input-outPath context fix above (the input
596 // branch attaches this context to each resolved input's `outPath`; here we
597 // do the same for the flake's own `self`). Non-store `self_path` (a dirty
598 // local tree whose source-hash is still a `-source` store path) also carries
599 // the context — `source_store_path` is always a `/nix/store/<h>-source` here.
600 let self_out_path_val = {
601 let mut ctx = crate::value::StringContext::new();
602 ctx.add_plain(source_store_path.as_str());
603 Value::String(std::rc::Rc::new(crate::value::NixString::with_context(
604 source_store_path.as_str(),
605 ctx,
606 )))
607 };
608
609 let source_info = {
610 let mut a = NixAttrs::new();
611 a.insert("outPath".to_string(), self_out_path_val.clone());
612 a.insert("narHash".to_string(), Value::string(source_nar_sri.clone()));
613 if let Some(ref rev) = self_rev {
614 a.insert("rev".to_string(), Value::string(rev.clone()));
615 }
616 if let Some(ref short) = self_short_rev {
617 a.insert("shortRev".to_string(), Value::string(short.clone()));
618 }
619 if let Some(lm) = self_last_modified {
620 a.insert("lastModified".to_string(), Value::Int(lm));
621 a.insert(
622 "lastModifiedDate".to_string(),
623 Value::string(super::fetchers::format_unix_yyyymmddhhmmss(lm)),
624 );
625 }
626 a
627 };
628
629 // 5. Build `self` as a CppNix-equivalent fixpoint reference.
630 //
631 // Critical: `self` must point at the FINAL flake result
632 // including outputs (lib, darwinModules, packages, ...) — not
633 // just flake-body metadata. CppNix achieves this via a
634 // self-fixpoint: lambdas in outputs body capture `self`, then
635 // access e.g. `self.lib.evalConfig` at invocation time AFTER
636 // outputs has already returned.
637 //
638 // We implement this with a shared `OnceCell<Rc<NixAttrs>>`:
639 // - At outputs-call time, `self` is a thunk that reads the
640 // OnceCell (initially empty).
641 // - After outputs returns, we fill the OnceCell with the
642 // final merged attrset.
643 // - Later, when a captured `self` is forced, the OnceCell is
644 // populated and the thunk yields the final attrset.
645 //
646 // This was the load-bearing M2.1 bug: previously sui passed
647 // outputs a `self` containing only outPath/sourceInfo/inputs/
648 // flake-body metadata, so `nix-darwin`'s `darwinSystem` body
649 // (`self.lib.evalConfig (...)`) errored "attribute not found:
650 // 'lib'" at invocation time.
651 let self_promise: Rc<std::cell::OnceCell<Rc<NixAttrs>>> = Rc::new(std::cell::OnceCell::new());
652 // Records whether the outputs body forced `self` before the promise was
653 // filled — i.e. whether the one-attribute fallback below was ever handed
654 // out. `Thunk::force` MEMOISES (`value.rs`, the OnceCell fast path), so a
655 // single early force caches that skeleton for the life of the thunk and
656 // every later `self.<attr>` resolves against it. This flag is what lets
657 // step 9 notice and re-run with a `self` that resolves. See
658 // `theory/BALIZA-PLAN.md` §2.5.2.
659 let self_forced_early: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
660 let self_thunk = {
661 let self_promise = self_promise.clone();
662 let self_forced_early = self_forced_early.clone();
663 // Carry the same store-path context on the fallback skeleton's
664 // `outPath` (see `self_out_path_val` above) so a `src = self`
665 // coerced in the rare pre-output path still records its `source`
666 // inputSrc.
667 let fallback_out_path = self_out_path_val.clone();
668 Thunk::new_native(move || {
669 if let Some(attrs) = self_promise.get() {
670 Ok(Value::Attrs(attrs.clone()))
671 } else {
672 // outputs body forced `self` BEFORE we filled the
673 // OnceCell. NOT rare: `flake-parts.lib.mkFlake` runs a
674 // module fixpoint DURING the outputs call, so every
675 // flake-parts flake whose `flake = {…}` block contains a
676 // self-reference (`default = self.nixosModules.topology`)
677 // lands here. Because this thunk memoises, the skeleton
678 // below would otherwise be `self` forever — which is
679 // exactly how `nix-topology` produced
680 // `AttrNotFound('nixosModules')` on every node config that
681 // imports it. Flag it so step 9 can re-run outputs once
682 // the promise is filled.
683 self_forced_early.set(true);
684 let mut fallback = NixAttrs::new();
685 fallback.insert("outPath".to_string(),
686 fallback_out_path.clone());
687 Ok(Value::Attrs(Rc::new(fallback)))
688 }
689 })
690 };
691
692 // 6. Build arguments for `outputs`.
693 // `resolved_inputs` is shared rather than moved: step 9 may need to call
694 // `outputs` a second time with the identical input set.
695 let resolved_inputs_rc = Rc::new(resolved_inputs);
696 let mut outputs_args = NixAttrs::new();
697 outputs_args.insert("self".to_string(), Value::Thunk(self_thunk));
698 for (k, v) in resolved_inputs_rc.iter() {
699 outputs_args.insert(k.clone(), v.clone());
700 }
701
702 // 7. Call outputs(args). `outputs_fn` is cloned, not moved, for the same
703 // reason.
704 let result = crate::eval::apply(outputs_fn.clone(), Value::Attrs(Rc::new(outputs_args)))?;
705 let result = crate::eval::force_value(&result)?;
706
707 // 8. Build the final flake value.
708 //
709 // Shape policy lives in `sui-spec/specs/flake.lisp` as a
710 // `(defflake-shape :name "cppnix" …)` form. We consult the
711 // spec for the type marker, the spread-outputs rule, and the
712 // never-leak denylist — so changes to CppNix's flake shape are
713 // one-line Lisp edits, not Rust surgery. (Previously this
714 // function was the drift surface for leak bugs like the
715 // `description`-at-top-level regression.)
716 let shape = sui_spec::flake::load_canonical().map_err(|e| {
717 EvalError::TypeError(format!("flake shape spec failed to load: {e}"))
718 })?;
719 let mut final_attrs = NixAttrs::new();
720 final_attrs.insert("_type".to_string(), Value::string(shape.type_marker.clone()));
721 final_attrs.insert("outPath".to_string(), self_out_path_val.clone());
722 final_attrs.insert("sourceInfo".to_string(), Value::Attrs(Rc::new(source_info)));
723 final_attrs.insert("narHash".to_string(), Value::string(source_nar_sri));
724 // Self-rev at the TOP LEVEL of `self` (not only under `sourceInfo`):
725 // nix-darwin reads `self.shortRev` / `self.rev` directly. See the
726 // source_info block above for the byte-parity rationale.
727 if let Some(ref rev) = self_rev {
728 final_attrs.insert("rev".to_string(), Value::string(rev.clone()));
729 }
730 if let Some(ref short) = self_short_rev {
731 final_attrs.insert("shortRev".to_string(), Value::string(short.clone()));
732 }
733 if let Some(lm) = self_last_modified {
734 final_attrs.insert("lastModified".to_string(), Value::Int(lm));
735 final_attrs.insert(
736 "lastModifiedDate".to_string(),
737 Value::string(super::fetchers::format_unix_yyyymmddhhmmss(lm)),
738 );
739 }
740 final_attrs.insert("inputs".to_string(), Value::Attrs(resolved_inputs_rc.clone()));
741 final_attrs.insert("outputs".to_string(), result.clone());
742
743 if shape.spreads_output_fn() {
744 if let Value::Attrs(out_attrs) = &result {
745 for (k, v) in out_attrs.iter() {
746 if !final_attrs.contains_key(k.as_str()) {
747 final_attrs.insert(k.clone(), v.clone());
748 }
749 }
750 }
751 }
752
753 // Also surface flake-body metadata (description, nixConfig) on
754 // self. CppNix exposes these on the flake result, so lambdas
755 // captured during outputs that read e.g. `self.description`
756 // should resolve correctly.
757 for (k, v) in flake_attrs.iter() {
758 if k != "outputs" && k != "inputs" && !final_attrs.contains_key(k.as_str()) {
759 final_attrs.insert(k.clone(), v.clone());
760 }
761 }
762
763 let final_attrs_rc = Rc::new(final_attrs);
764
765 // Fill the self-fixpoint promise. Lambdas captured during
766 // outputs now resolve `self.lib`, `self.darwinModules`, etc.
767 // against this final attrset.
768 let _ = self_promise.set(final_attrs_rc.clone());
769
770 // 9. THE SECOND PASS — only for flakes that forced `self` too early.
771 //
772 // Pass 1 handed those flakes the one-attribute skeleton, and because
773 // `Thunk::force` memoises, every value that captured it is permanently
774 // wrong. Re-running `outputs` with a FRESH `self` thunk fixes them: the
775 // promise is filled now, so the fresh thunk resolves to the real attrset on
776 // its first (and only) force.
777 //
778 // Pass 1's attrset is a sound seed because the KEYS of a `flake = {…}`
779 // block do not depend on `self` — only their values do. `nix-topology`
780 // exposes `nixosModules = { topology = ./nixos/module.nix; default =
781 // self.nixosModules.topology; }`: `topology` is a plain path and is already
782 // correct in pass 1, so pass 2's `default` resolves through it.
783 //
784 // Cost is paid ONLY by flakes that trip the flag; every other flake keeps
785 // exactly one `outputs` call. Do not "fix" this by making the `self` thunk
786 // non-memoising instead — that cache is `Thunk::force`'s 150M-hit fast path.
787 //
788 // HONEST LIMIT: this is one fixpoint iteration, not a fixpoint. A flake
789 // whose pass-2 result depends on a pass-1 value that was ITSELF poisoned
790 // would need a third pass. Two passes cover the flake-parts shape that
791 // motivated this; a deeper case would need convergence-to-stable, which is
792 // not implemented and is not claimed. See `theory/BALIZA-PLAN.md` §2.5.2.
793 if self_forced_early.get() {
794 let fresh_self = {
795 let self_promise = self_promise.clone();
796 Thunk::new_native(move || match self_promise.get() {
797 Some(attrs) => Ok(Value::Attrs(attrs.clone())),
798 // Unreachable: set() above ran before this thunk can be forced.
799 // Named rather than silently falling back to a skeleton, which
800 // is the failure mode this whole step exists to remove.
801 None => Err(EvalError::TypeError(
802 "flake self-fixpoint: promise unfilled entering the second \
803 pass — this is a bug in sui, not in the flake"
804 .to_string(),
805 )),
806 })
807 };
808
809 let mut args2 = NixAttrs::new();
810 args2.insert("self".to_string(), Value::Thunk(fresh_self));
811 for (k, v) in resolved_inputs_rc.iter() {
812 args2.insert(k.clone(), v.clone());
813 }
814 let result2 = crate::eval::apply(outputs_fn, Value::Attrs(Rc::new(args2)))?;
815 let result2 = crate::eval::force_value(&result2)?;
816
817 let mut f2 = NixAttrs::new();
818 for (k, v) in final_attrs_rc.iter() {
819 f2.insert(k.clone(), v.clone());
820 }
821 f2.insert("outputs".to_string(), result2.clone());
822 if shape.spreads_output_fn()
823 && let Value::Attrs(out2) = &result2
824 {
825 for (k, v) in out2.iter() {
826 // An output key may never overwrite the flake-identity attrs
827 // sui computed itself — that is the `description`-at-top-level
828 // regression class the shape spec exists to prevent.
829 if !matches!(
830 k.as_str(),
831 "_type"
832 | "outPath"
833 | "sourceInfo"
834 | "narHash"
835 | "rev"
836 | "shortRev"
837 | "lastModified"
838 | "inputs"
839 | "outputs"
840 ) {
841 f2.insert(k.clone(), v.clone());
842 }
843 }
844 }
845 return Ok(Value::Attrs(Rc::new(f2)));
846 }
847
848 Ok(Value::Attrs(final_attrs_rc))
849}
850
851// ── Cached attribute evaluation ──────────────────────────────
852
853/// Evaluate a flake and navigate to a specific attribute, with caching.
854///
855/// If the derivation path for `(lock_hash, source_hash, attr_path)` is already
856/// in the drv cache, returns a synthetic derivation attrset without evaluating
857/// the flake (near-zero memory). Otherwise, evaluates normally and caches the
858/// result for future lookups.
859pub fn evaluate_flake_attr(
860 flake_dir: &std::path::Path,
861 attr_path: &[&str],
862) -> Result<Value, EvalError> {
863 let lock_path = flake_dir.join("flake.lock");
864 let source_path = flake_dir.join("flake.nix");
865
866 // Compute cache keys from file content.
867 let lock_hash = std::fs::read(&lock_path)
868 .ok()
869 .map(|c| crate::drv_cache::DrvCache::hash_bytes(&c));
870 let source_hash = std::fs::read(&source_path)
871 .ok()
872 .map(|c| crate::drv_cache::DrvCache::hash_bytes(&c));
873 let attr_key = attr_path.join(".");
874
875 // Check drv cache.
876 if let (Some(lh), Some(sh)) = (&lock_hash, &source_hash) {
877 if let Some(entry) = crate::drv_cache::with_cache(|cache| cache.get(lh, sh, &attr_key)) {
878 tracing::info!(
879 attr_path = %attr_key,
880 out_path = %entry.out_path,
881 "drv cache hit — skipping full evaluation"
882 );
883 return Ok(synthetic_drv_value(&entry));
884 }
885 }
886
887 // Cache miss — full evaluation.
888 tracing::info!(attr_path = %attr_key, "drv cache miss — evaluating flake");
889 let flake_result = evaluate_flake(flake_dir)?;
890
891 // Navigate to the target attribute.
892 let target = navigate_attr_path(&flake_result, attr_path)?;
893
894 // If the result is a derivation, cache it.
895 if let (Some(lh), Some(sh)) = (&lock_hash, &source_hash) {
896 if let Value::Attrs(ref attrs) = target {
897 let drv_path = attrs.get("drvPath").and_then(|v| v.as_string().ok());
898 let out_path = attrs.get("outPath").and_then(|v| v.as_string().ok());
899 if let (Some(dp), Some(op)) = (drv_path, out_path) {
900 crate::drv_cache::with_cache_mut(|cache| {
901 let entry = crate::drv_cache::DrvCacheEntry {
902 drv_path: dp.to_string(),
903 out_path: op.to_string(),
904 };
905 if let Err(e) = cache.put(lh, sh, &attr_key, &entry) {
906 tracing::warn!(error = %e, "Failed to cache derivation path");
907 } else {
908 tracing::info!(attr_path = %attr_key, out_path = %op, "Cached derivation path");
909 }
910 });
911 }
912 }
913 }
914
915 Ok(target)
916}
917
918/// Navigate an attribute path like `["packages", "x86_64-linux", "default"]`
919/// through a Value, forcing thunks at each level.
920fn navigate_attr_path(value: &Value, path: &[&str]) -> Result<Value, EvalError> {
921 let mut current = crate::eval::force_value(value)?;
922 for segment in path {
923 let attrs = current.as_attrs().map_err(|_| {
924 EvalError::TypeError(format!(
925 "expected attrset at '.{segment}', got {}",
926 current.type_name()
927 ))
928 })?;
929 let next = attrs.get(*segment).ok_or_else(|| {
930 EvalError::AttrNotFound((*segment).to_string())
931 })?;
932 current = crate::eval::force_value(next)?;
933 }
934 Ok(current)
935}
936
937/// Build a synthetic derivation Value from cached paths.
938/// The caller only needs `drvPath`, `outPath`, and `type = "derivation"`.
939fn synthetic_drv_value(entry: &crate::drv_cache::DrvCacheEntry) -> Value {
940 let mut attrs = NixAttrs::new();
941 attrs.insert("type".to_string(), Value::string("derivation"));
942 attrs.insert("drvPath".to_string(), Value::string(entry.drv_path.clone()));
943 attrs.insert("outPath".to_string(), Value::string(entry.out_path.clone()));
944 // Extract name from store path: /nix/store/hash-name → name
945 if let Some(name) = entry.out_path.rsplit('/').next().and_then(|b| b.split_once('-').map(|(_, n)| n)) {
946 attrs.insert("name".to_string(), Value::string(name));
947 }
948 Value::Attrs(Rc::new(attrs))
949}