Skip to main content

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(&current_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(&current_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}