Skip to main content

sui_spec/
derivation.rs

1//! Derivation path computation — the CppNix algorithm as Lisp data.
2//!
3//! This module hosts the four-phase input-addressed derivation
4//! algorithm that used to live in hand-written Rust, duplicated
5//! between `sui-eval` (tree-walker) and `sui-bytecode` (VM).  Four
6//! distinct spec bugs were found in that code during the parity
7//! session:
8//!
9//! | # | Bug                                                 |
10//! |---|-----------------------------------------------------|
11//! |11 | Missing `env.<output>` placeholder after fill       |
12//! |12 | `.drv` path hashed unresolved form, not final form  |
13//! |13 | Unresolved form must have `env.out = ""` present    |
14//! |14 | VM args reader didn't force list items (empty args) |
15//!
16//! Every one of those was a *spec* mistake.  They came in pairs
17//! because each engine had its own copy.  The cure is here: one
18//! typed Rust algorithm definition, one authored `.lisp` spec,
19//! one interpreter, two engine call sites — and no way to drift.
20//!
21//! ## Authoring surface
22//!
23//! ```lisp
24//! (defderivation-algorithm cppnix-input-addressed
25//!   :name "cppnix-input-addressed"
26//!   :phases ((:kind MaskOutputsAndEnv)
27//!            (:kind Serialize :bind "unresolved")
28//!            (:kind Sha256 :from "unresolved" :bind "inner-hex")
29//!            (:kind ComputeOutputPaths :from-hash "inner-hex")
30//!            (:kind FillPlaceholders)
31//!            (:kind Serialize :bind "final")
32//!            (:kind Sha256 :from "final" :bind "final-hex")
33//!            (:kind ComputeDrvPath :from-hash "final-hex")))
34//! ```
35//!
36//! Each bug's fix is one line of Lisp.  Future additions — e.g. a
37//! `cppnix-fixed-output` algorithm, a `cppnix-ca-derivation` variant
38//! for content-addressed derivations — each become one `.lisp`
39//! form, inheriting the interpreter for free.
40
41use std::collections::{BTreeMap, HashMap};
42
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use sui_compat::derivation::{Derivation, DerivationOutput};
46use tatara_lisp::DeriveTataraDomain;
47
48use crate::SpecError;
49
50// ── Typed border ───────────────────────────────────────────────────
51
52/// Top-level algorithm definition, authored as `(defderivation-algorithm ...)`.
53///
54/// `phases` is interpreted left-to-right by [`apply`].  Each phase
55/// reads from a scratchpad of named slots (populated by earlier
56/// phases) and writes to zero or more output slots.  The typed border
57/// is declarative: there is no way to author a phase whose inputs
58/// aren't statically representable here.
59#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
60#[tatara(keyword = "defderivation-algorithm")]
61pub struct DerivationAlgorithm {
62    pub name: String,
63    pub phases: Vec<Phase>,
64}
65
66/// A single pipeline phase.  Each phase declares its `kind` and
67/// optionally binds inputs (`from`, `from_hash`) or an output slot
68/// (`bind`).  The `#[serde(default)]`s are what let simple phases be
69/// authored as `(:kind MaskOutputsAndEnv)` with no extra kwargs.
70#[derive(Serialize, Deserialize, Debug, Clone)]
71pub struct Phase {
72    pub kind: PhaseKind,
73    #[serde(default)]
74    pub bind: Option<String>,
75    #[serde(default)]
76    pub from: Option<String>,
77    #[serde(default, rename = "fromHash")]
78    pub from_hash: Option<String>,
79}
80
81/// Enumeration of every phase the interpreter knows how to run.
82/// Adding a new phase here IS adding a new primitive to the spec
83/// language — the typed border is exactly this set.
84#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85pub enum PhaseKind {
86    /// Set every output's `path` to `""` AND every env entry whose
87    /// name matches an output to `""`.  This is what CppNix calls
88    /// "maskOutputs" — it's the precondition for hashing the
89    /// "unresolved" form of a derivation.
90    MaskOutputsAndEnv,
91
92    /// ATerm-serialize the current derivation into the bytes slot
93    /// named by `bind`.
94    Serialize,
95
96    /// Compute SHA-256 of the bytes in slot `from`, store the
97    /// lowercase-hex digest into slot `bind`.
98    Sha256,
99
100    /// Given the inner hex stored in slot `from_hash`, compute the
101    /// per-output store path via `sui_compat::store_path::compute_output_path`
102    /// and populate the shared `out_paths` map.
103    ComputeOutputPaths,
104
105    /// Copy each entry of `out_paths` back into the derivation:
106    /// `drv.outputs[<name>].path = <path>` AND `drv.env[<name>] = <path>`.
107    /// After this phase the derivation is in CppNix's "final" form.
108    FillPlaceholders,
109
110    /// Given the final hex stored in slot `from_hash`, compute the
111    /// `.drv` store path via `sui_compat::store_path::compute_drv_path`
112    /// and record it as the overall result.
113    ComputeDrvPath,
114
115    /// Like [`Serialize`] but replace every `input_derivations`
116    /// key (a `.drv` path) with its `hashDerivationModulo` lookup
117    /// from the thread-local modulo cache — CppNix's recursion
118    /// over dependent derivations.  Input drvs with no cached
119    /// modulo hash are passed through unchanged, which matches
120    /// the parity-on-leaves case (a drv with no dependencies
121    /// serialises identically in both forms).
122    ///
123    /// [`Serialize`]: PhaseKind::Serialize
124    SerializeModulo,
125
126    /// Record the current hex hash in `from_hash` as the modulo
127    /// hash for the produced `.drv` path, so derivations that
128    /// depend on this one can look it up during their own
129    /// `SerializeModulo` phase.
130    CacheSelfModulo,
131
132    // ── Fixed-output / content-addressed extensions (M3 stubs) ──
133    //
134    // The two phase variants below name the contract for the
135    // remaining cppnix store-path schemes.  Today they return
136    // SpecError::Interp; the M3 implementation wires them up to
137    // sui_compat::store_path's fixed-output / CA computation paths.
138
139    /// For a fixed-output derivation: read `drv.env.outputHash`,
140    /// `outputHashAlgo`, `outputHashMode` and seed the per-output
141    /// fingerprint from the *output content hash* rather than the
142    /// recipe hash.  Output store path follows from this seed via
143    /// the same `compute_output_path` route as input-addressed.
144    SeedFixedOutputHash,
145
146    /// Mark the derivation as CA (`__contentAddressed = true`).
147    /// CA outputs' paths aren't known at recipe time; the builder
148    /// resolves them post-realisation and rewrites the .drv in
149    /// place.  This phase plants the marker so downstream phases
150    /// route correctly.
151    MarkContentAddressed,
152
153    /// For CA derivations only: write a *placeholder* output path
154    /// (cppnix uses `/nix/store/<placeholder>-<name>` style); the
155    /// builder substitutes real paths after a successful build.
156    EmitCaPlaceholders,
157}
158
159// ── Interpreter ────────────────────────────────────────────────────
160
161/// Interpreter scratchpad — shared state threaded through phases.
162///
163/// Every slot has a documented producer and consumer (see
164/// [`PhaseKind`]).  `binds` is a generic name→bytes scratchpad; for
165/// hashes we intern the hex into the same map (bytes carry either
166/// ATerm text or hex digests).
167pub struct DerivationState {
168    pub drv: Derivation,
169    pub outputs_list: Vec<String>,
170    pub drv_name: String,
171    pub binds: HashMap<String, Vec<u8>>,
172    pub out_paths: BTreeMap<String, String>,
173    pub drv_path: Option<String>,
174}
175
176impl DerivationState {
177    #[must_use]
178    pub fn new(drv: Derivation, outputs_list: Vec<String>, drv_name: String) -> Self {
179        Self {
180            drv,
181            outputs_list,
182            drv_name,
183            binds: HashMap::new(),
184            out_paths: BTreeMap::new(),
185            drv_path: None,
186        }
187    }
188
189    fn get_bytes(&self, key: &str) -> Result<&[u8], SpecError> {
190        self.binds
191            .get(key)
192            .map(std::vec::Vec::as_slice)
193            .ok_or_else(|| SpecError::UnboundSlot(key.to_string()))
194    }
195}
196
197/// Apply every phase in order.  Returns the final `.drv` path, the
198/// per-output store paths, and the mutated derivation with paths
199/// filled in.
200///
201/// Callers (tree-walker, VM) pass in a partially-populated
202/// `Derivation` (outputs empty; env already has the non-output
203/// entries) and a list of output names.  This function performs the
204/// full input-addressed algorithm.  Both engines call exactly this
205/// function, with exactly these arguments, so they cannot drift.
206///
207/// # Errors
208///
209/// Returns an error if a phase refers to an unbound slot, or if an
210/// individual phase's precondition is violated (e.g. `ComputeDrvPath`
211/// runs before any placeholders are filled).
212pub fn apply(
213    algo: &DerivationAlgorithm,
214    drv: Derivation,
215    outputs_list: Vec<String>,
216    name: &str,
217) -> Result<(String, BTreeMap<String, String>, Derivation), SpecError> {
218    let mut state = DerivationState::new(drv, outputs_list, name.to_string());
219    for phase in &algo.phases {
220        run_phase(phase, &mut state)?;
221    }
222    let drv_path = state.drv_path.ok_or_else(|| SpecError::Interp {
223        phase: "finalize".into(),
224        message: "algorithm completed without binding a .drv path \
225                  (missing ComputeDrvPath phase?)".into(),
226    })?;
227    Ok((drv_path, state.out_paths, state.drv))
228}
229
230fn run_phase(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
231    match phase.kind {
232        PhaseKind::MaskOutputsAndEnv => mask_outputs_and_env(s),
233        PhaseKind::Serialize => serialize(phase, s),
234        PhaseKind::Sha256 => sha256(phase, s),
235        PhaseKind::ComputeOutputPaths => compute_output_paths(phase, s),
236        PhaseKind::FillPlaceholders => fill_placeholders(s),
237        PhaseKind::ComputeDrvPath => compute_drv_path(phase, s),
238        PhaseKind::SerializeModulo => serialize_modulo(phase, s),
239        PhaseKind::CacheSelfModulo => cache_self_modulo(phase, s),
240        PhaseKind::SeedFixedOutputHash => Err(SpecError::Interp {
241            phase: "SeedFixedOutputHash".into(),
242            message: "fixed-output derivation phase not yet implemented — \
243                      M3 will wire to sui_compat::store_path::compute_fixed_output_path"
244                .into(),
245        }),
246        PhaseKind::MarkContentAddressed => Err(SpecError::Interp {
247            phase: "MarkContentAddressed".into(),
248            message: "content-addressed derivation phase not yet implemented — \
249                      M4 work hangs off this border"
250                .into(),
251        }),
252        PhaseKind::EmitCaPlaceholders => Err(SpecError::Interp {
253            phase: "EmitCaPlaceholders".into(),
254            message: "CA placeholder emission not yet implemented (M4)".into(),
255        }),
256    }
257}
258
259// ── Modulo cache (for hashDerivationModulo recursion) ──────────────
260//
261// CppNix builds a dependent derivation's `.drv` path by hashing its
262// final ATerm WITH each input derivation's `.drv` path replaced by
263// that input's own recursive modulo hash.  We cache the modulo hash
264// alongside every `.drv` path we produce so subsequent derivations
265// that depend on it can look up the right substitute.
266//
267// Thread-local keeps this simple: single-threaded eval, same cache
268// visible to tree-walker + VM, cleared between test runs by the
269// test harness reseting thread-locals.  A production deploy that
270// wanted cross-eval memoization would swap this for a persistent
271// store — the interface stays the same.
272
273use std::cell::RefCell;
274
275thread_local! {
276    static MODULO_CACHE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
277}
278
279/// Record a `.drv` path → modulo hash binding, so dependent
280/// derivations can look it up later.
281pub fn remember_modulo_hash(drv_path: &str, modulo_hex: &str) {
282    MODULO_CACHE.with(|c| {
283        c.borrow_mut().insert(drv_path.to_string(), modulo_hex.to_string());
284    });
285}
286
287/// Look up the modulo hash for `drv_path`.  Returns the `drv_path`
288/// unchanged if no entry exists — which is correct for leaves
289/// (derivations with no inputs produce the same bytes in both
290/// normal and modulo forms).
291#[must_use]
292pub fn modulo_of(drv_path: &str) -> String {
293    MODULO_CACHE.with(|c| {
294        c.borrow().get(drv_path).cloned().unwrap_or_else(|| drv_path.to_string())
295    })
296}
297
298/// Clear the cache.  Intended for test isolation.
299pub fn reset_modulo_cache() {
300    MODULO_CACHE.with(|c| c.borrow_mut().clear());
301}
302
303fn mask_outputs_and_env(s: &mut DerivationState) -> Result<(), SpecError> {
304    for o in &s.outputs_list {
305        s.drv.outputs.insert(o.clone(), DerivationOutput {
306            path: String::new(),
307            hash_algo: String::new(),
308            hash: String::new(),
309        });
310        s.drv.env.insert(o.clone(), String::new());
311    }
312    Ok(())
313}
314
315fn serialize(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
316    let slot = phase.bind.clone().ok_or_else(|| SpecError::Interp {
317        phase: "Serialize".into(),
318        message: ":bind is required".into(),
319    })?;
320    let bytes = s.drv.serialize().into_bytes();
321    s.binds.insert(slot, bytes);
322    Ok(())
323}
324
325fn sha256(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
326    let from = phase.from.clone().ok_or_else(|| SpecError::Interp {
327        phase: "Sha256".into(),
328        message: ":from is required".into(),
329    })?;
330    let bind = phase.bind.clone().ok_or_else(|| SpecError::Interp {
331        phase: "Sha256".into(),
332        message: ":bind is required".into(),
333    })?;
334    let input = s.get_bytes(&from)?;
335    let digest = Sha256::digest(input);
336    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
337    s.binds.insert(bind, hex.into_bytes());
338    Ok(())
339}
340
341fn compute_output_paths(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
342    let from = phase.from_hash.clone().ok_or_else(|| SpecError::Interp {
343        phase: "ComputeOutputPaths".into(),
344        message: ":from-hash is required".into(),
345    })?;
346    let hex = {
347        let bytes = s.get_bytes(&from)?;
348        std::str::from_utf8(bytes).map_err(|e| SpecError::Interp {
349            phase: "ComputeOutputPaths".into(),
350            message: format!("slot {from} is not valid utf-8: {e}"),
351        })?.to_string()
352    };
353    let outputs_snapshot: Vec<String> = s.outputs_list.clone();
354    let drv_name = s.drv_name.clone();
355    for o in &outputs_snapshot {
356        let p = sui_compat::store_path::compute_output_path(&hex, o, &drv_name);
357        s.out_paths.insert(o.clone(), p);
358    }
359    Ok(())
360}
361
362fn fill_placeholders(s: &mut DerivationState) -> Result<(), SpecError> {
363    for o in &s.outputs_list {
364        let placeholder = s.out_paths.get(o).cloned().ok_or_else(|| SpecError::Interp {
365            phase: "FillPlaceholders".into(),
366            message: format!("no path computed for output {o} \
367                              (did ComputeOutputPaths run first?)"),
368        })?;
369        if let Some(entry) = s.drv.outputs.get_mut(o) {
370            entry.path = placeholder.clone();
371        }
372        s.drv.env.insert(o.clone(), placeholder);
373    }
374    Ok(())
375}
376
377fn compute_drv_path(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
378    let from = phase.from_hash.clone().ok_or_else(|| SpecError::Interp {
379        phase: "ComputeDrvPath".into(),
380        message: ":from-hash is required".into(),
381    })?;
382    // Validate the hex slot is present + utf-8 (defensive check —
383    // produces a crisp error if the author references a slot that
384    // no earlier phase populated).
385    {
386        let bytes = s.get_bytes(&from)?;
387        let _ = std::str::from_utf8(bytes).map_err(|e| SpecError::Interp {
388            phase: "ComputeDrvPath".into(),
389            message: format!("slot {from} is not valid utf-8: {e}"),
390        })?;
391    }
392    // Convention: the hex slot name is `<bytes-slot>-hex`, so the
393    // raw ATerm bytes live at the same name without that suffix.
394    // `compute_drv_path_with_refs` re-hashes the bytes internally
395    // but ALSO folds the derivation's input refs (input_derivations
396    // + input_sources) into the fingerprint — `makeTextPath` style.
397    // Without refs, any derivation that references another drv or
398    // a /nix/store source disagrees with CppNix (discovered while
399    // wiring transitive parity).
400    let bytes_slot = from.trim_end_matches("-hex").to_string();
401    let drv_name = s.drv_name.clone();
402    let refs: Vec<String> = s.drv.input_derivations
403        .keys()
404        .cloned()
405        .chain(s.drv.input_sources.iter().cloned())
406        .collect();
407    let drv_path = {
408        let bytes = s.get_bytes(&bytes_slot)?;
409        sui_compat::store_path::compute_drv_path_with_refs(bytes, &drv_name, &refs)
410    };
411    s.drv_path = Some(drv_path);
412    Ok(())
413}
414
415fn serialize_modulo(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
416    let slot = phase.bind.clone().ok_or_else(|| SpecError::Interp {
417        phase: "SerializeModulo".into(),
418        message: ":bind is required".into(),
419    })?;
420    let bytes = s.drv.serialize_modulo(|drv_path| modulo_of(drv_path)).into_bytes();
421    s.binds.insert(slot, bytes);
422    Ok(())
423}
424
425fn cache_self_modulo(phase: &Phase, s: &mut DerivationState) -> Result<(), SpecError> {
426    let from = phase.from_hash.clone().ok_or_else(|| SpecError::Interp {
427        phase: "CacheSelfModulo".into(),
428        message: ":from-hash is required".into(),
429    })?;
430    let drv_path = s.drv_path.clone().ok_or_else(|| SpecError::Interp {
431        phase: "CacheSelfModulo".into(),
432        message: "no drv path bound yet (run ComputeDrvPath first)".into(),
433    })?;
434    let hex = {
435        let bytes = s.get_bytes(&from)?;
436        std::str::from_utf8(bytes).map_err(|e| SpecError::Interp {
437            phase: "CacheSelfModulo".into(),
438            message: format!("slot {from} is not valid utf-8: {e}"),
439        })?.to_string()
440    };
441    remember_modulo_hash(&drv_path, &hex);
442    Ok(())
443}
444
445// ── Canonical spec, compiled in ────────────────────────────────────
446
447/// The CppNix input-addressed algorithm as a compile-time string.
448/// Callers use [`load_canonical`] to parse this into a typed
449/// [`DerivationAlgorithm`] — we keep the source embedded so the spec
450/// ships with the crate and is verifiable by reading this file
451/// alongside `specs/derivation.lisp`.
452pub const CPPNIX_INPUT_ADDRESSED_LISP: &str = include_str!("../specs/derivation.lisp");
453
454/// Compile the embedded canonical spec into a typed algorithm.
455/// Returns the `cppnix-input-addressed` algorithm specifically; for
456/// the FOD or CA variants see [`load_named`].
457///
458/// # Errors
459///
460/// Returns an error if the compile-time spec fails to parse or
461/// the input-addressed algorithm is missing from the corpus.
462pub fn load_canonical() -> Result<DerivationAlgorithm, SpecError> {
463    load_named("cppnix-input-addressed")
464}
465
466/// Compile the canonical spec and return every authored algorithm.
467///
468/// # Errors
469///
470/// Returns an error if the spec fails to parse.
471pub fn load_all_canonical() -> Result<Vec<DerivationAlgorithm>, SpecError> {
472    Ok(tatara_lisp::compile_typed::<DerivationAlgorithm>(
473        CPPNIX_INPUT_ADDRESSED_LISP,
474    )?)
475}
476
477/// Compile the canonical spec and return the algorithm whose `name`
478/// matches.  Today's named algorithms:
479///
480/// - `"cppnix-input-addressed"` — the default builder path.
481/// - `"cppnix-fixed-output"` — fetchurl/fetchTarball-style FODs (M3 stub).
482/// - `"cppnix-content-addressed"` — CA-drv experimental (M4 stub).
483///
484/// # Errors
485///
486/// Returns an error if the spec fails to parse or `name` is missing.
487pub fn load_named(name: &str) -> Result<DerivationAlgorithm, SpecError> {
488    let all = load_all_canonical()?;
489    all.into_iter()
490        .find(|a| a.name == name)
491        .ok_or_else(|| SpecError::Load(
492            format!("no (defderivation-algorithm) with :name {name:?}"),
493        ))
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn canonical_spec_parses() {
502        let algo = load_canonical().expect("canonical spec must compile");
503        assert_eq!(algo.name, "cppnix-input-addressed");
504        // Six declared phases — masking, two serialize/hash pairs,
505        // one placeholder fill, one final drv-path emission.
506        assert!(!algo.phases.is_empty(), "algorithm must have phases");
507    }
508
509    #[test]
510    fn fixed_output_algorithm_parses() {
511        let algo = load_named("cppnix-fixed-output")
512            .expect("FOD algorithm must compile");
513        let kinds: Vec<PhaseKind> = algo.phases.iter().map(|p| p.kind).collect();
514        assert!(kinds.contains(&PhaseKind::SeedFixedOutputHash));
515        assert!(kinds.contains(&PhaseKind::ComputeDrvPath));
516    }
517
518    #[test]
519    fn content_addressed_algorithm_parses() {
520        let algo = load_named("cppnix-content-addressed")
521            .expect("CA-drv algorithm must compile");
522        let kinds: Vec<PhaseKind> = algo.phases.iter().map(|p| p.kind).collect();
523        assert!(kinds.contains(&PhaseKind::MarkContentAddressed));
524        assert!(kinds.contains(&PhaseKind::EmitCaPlaceholders));
525    }
526
527    #[test]
528    fn all_canonical_algorithms_load() {
529        let all = load_all_canonical().expect("all algos must compile");
530        let names: std::collections::HashSet<&str> =
531            all.iter().map(|a| a.name.as_str()).collect();
532        for required in [
533            "cppnix-input-addressed",
534            "cppnix-fixed-output",
535            "cppnix-content-addressed",
536        ] {
537            assert!(
538                names.contains(required),
539                "canonical corpus missing algorithm `{required}`",
540            );
541        }
542    }
543
544    #[test]
545    fn fod_apply_returns_typed_not_yet() {
546        let algo = load_named("cppnix-fixed-output").unwrap();
547        let mut env = std::collections::BTreeMap::new();
548        env.insert("outputHash".into(), "sha256-abc123".into());
549        let drv = Derivation {
550            outputs: std::collections::BTreeMap::new(),
551            input_derivations: std::collections::BTreeMap::new(),
552            input_sources: Vec::new(),
553            system: "aarch64-darwin".into(),
554            builder: "/bin/sh".into(),
555            args: vec![],
556            env,
557        };
558        let err = apply(&algo, drv, vec!["out".into()], "fixed-output-test")
559            .expect_err("FOD apply must surface typed not-yet");
560        match err {
561            SpecError::Interp { phase, message } => {
562                assert_eq!(phase, "SeedFixedOutputHash");
563                assert!(message.contains("M3"));
564            }
565            _ => panic!("expected SpecError::Interp, got {err:?}"),
566        }
567    }
568
569    #[test]
570    fn canonical_spec_matches_cppnix_on_hello_derivation() {
571        let algo = load_canonical().unwrap();
572        let mut env = std::collections::BTreeMap::new();
573        env.insert("builder".into(), "/bin/sh".into());
574        env.insert("name".into(), "hello".into());
575        env.insert("system".into(), "aarch64-darwin".into());
576        let drv = Derivation {
577            outputs: std::collections::BTreeMap::new(),
578            input_derivations: std::collections::BTreeMap::new(),
579            input_sources: Vec::new(),
580            system: "aarch64-darwin".into(),
581            builder: "/bin/sh".into(),
582            args: vec!["-c".into(), "echo hi > $out".into()],
583            env,
584        };
585        let (drv_path, out_paths, _final_drv) =
586            apply(&algo, drv, vec!["out".to_string()], "hello").unwrap();
587        // This is THE parity assertion — same input, same output as
588        // CppNix, verified empirically on 2026-04-18.
589        assert_eq!(
590            drv_path,
591            "/nix/store/mypmkciickjnhjjimhzjn6w7qj7g8n2k-hello.drv"
592        );
593        assert_eq!(
594            out_paths.get("out").map(String::as_str),
595            Some("/nix/store/k6lq59b6dilrfy0blhkr10m27ga7ncwr-hello"),
596        );
597    }
598}