1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
//! Conversion builtins: toJSON, fromJSON, fromTOML, toXML, convertHash, hashFile.
use super::*;
use sui_compat::versions::xml_escape;
pub(crate) fn register(builtins: &mut NixAttrs) {
register_builtin(builtins, "toJSON", |args| {
// CppNix `builtins.toJSON` PRESERVES string context: every string
// (and every derivation-outPath / copy-to-store path) it serializes
// contributes its store-path references to the OUTPUT string's
// context. `to_json` (plain) drops context — which silently broke
// `lib.generators.toLua` (`toJSON v` over a `toString drv`) so the
// generated luarocks config `.drv` lost its `external_deps_dirs`
// inputDrvs (neovim's `mpack-luarocks-config.lua`). Thread context
// through `to_json_with_context` and attach it to the result.
// This also matches CppNix's stricter type handling (a lambda or a
// non-store path throws, rather than emitting a placeholder string).
let mut ctx = crate::value::StringContext::default();
let json = args[0].to_json_with_context(&mut ctx)?;
// Nix-value JSON: floats must render as CppNix writes them.
let s = sui_compat::versions::nix_json_to_string(&json)
.unwrap_or_else(|_| "null".to_string());
Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
});
register_builtin(builtins, "fromJSON", |args| {
let s = args[0].as_string()?;
let json: serde_json::Value = serde_json::from_str(s)
.map_err(|e| EvalError::TypeError(format!("fromJSON: {e}")))?;
Ok(json_to_value(&json))
});
register_builtin(builtins, "fromTOML", |args| {
let s = args[0].as_string()?;
let table: toml::Value = toml::from_str(s)
.map_err(|e| EvalError::TypeError(format!("fromTOML: {e}")))?;
Ok(toml_to_value(&table))
});
// convertHash
register_builtin(builtins, "convertHash", |args| {
use base64::Engine;
let attrs = args[0].to_attrs()?;
let hash_str = attrs
.get("hash")
.ok_or_else(|| EvalError::AttrNotFound("hash".into()))?
.to_str()?;
let to_format = attrs
.get("toHashFormat")
.ok_or_else(|| EvalError::AttrNotFound("toHashFormat".into()))?
.to_str()?;
let (algo, raw_hash): (String, String) = if let Some(algo_v) =
attrs.get("hashAlgo")
{
(algo_v.to_str()?, hash_str.clone())
} else if let Some(stripped) = hash_str.strip_prefix("sha256-") {
("sha256".to_string(), stripped.to_string())
} else if let Some(stripped) = hash_str.strip_prefix("sha512-") {
("sha512".to_string(), stripped.to_string())
} else {
return Err(EvalError::TypeError(
"convertHash: missing hashAlgo".into(),
));
};
let expected_len = match algo.as_str() {
"md5" => 16,
"sha1" => 20,
"sha256" => 32,
"sha512" => 64,
other => {
return Err(EvalError::TypeError(format!(
"convertHash: unsupported algo {other}"
)))
}
};
let bytes: Vec<u8> = if raw_hash.len() == expected_len * 2
&& raw_hash.chars().all(|c| c.is_ascii_hexdigit())
{
(0..raw_hash.len())
.step_by(2)
.map(|i| u8::from_str_radix(&raw_hash[i..i + 2], 16))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| EvalError::TypeError(format!("convertHash hex: {e}")))?
} else if let Ok(b) = sui_compat::store_path::nix_base32_decode(&raw_hash) {
if expected_len != 20 {
return Err(EvalError::TypeError(
"convertHash: nix32 only supported for 20-byte (sha1) hashes".into(),
));
}
b.to_vec()
} else if let Ok(b) = base64::engine::general_purpose::STANDARD.decode(&raw_hash)
{
b
} else {
return Err(EvalError::TypeError(format!(
"convertHash: cannot decode hash '{raw_hash}'"
)));
};
if bytes.len() != expected_len {
return Err(EvalError::TypeError(format!(
"convertHash: decoded {} bytes, expected {expected_len} for {algo}",
bytes.len()
)));
}
let out = match to_format.as_str() {
"base16" => {
let mut s = String::with_capacity(bytes.len() * 2);
for b in &bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
"nix32" => {
if expected_len != 20 {
return Err(EvalError::TypeError(
"convertHash: nix32 output only supported for 20-byte hashes".into(),
));
}
sui_compat::store_path::nix_base32_encode(&bytes)
}
"base64" => base64::engine::general_purpose::STANDARD.encode(&bytes),
"sri" => format!(
"{algo}-{}",
base64::engine::general_purpose::STANDARD.encode(&bytes)
),
other => {
return Err(EvalError::TypeError(format!(
"convertHash: unsupported toHashFormat {other}"
)))
}
};
Ok(Value::string(out))
});
// hashFile (curried)
//
// ── ★ THE PATH MUST BE REALIZED *AND* MATERIALIZED ────────────────────
// Both halves, in this order, or this builtin ENOENTs on a path that
// `pathExists` just confirmed. `coerce_to_realized_path` is IFD (build a
// derivation argument before reading it); `materialize_str` is the
// FILESYSTEM-READ-ONLY redirect that rewrites a fetched input's
// `/nix/store/<narhash>-source` NAME onto the fetcher cache dir where the
// bytes actually live. A fetched flake input's store path never exists on
// the real filesystem, so the second half is not an optimisation.
//
// Measured 2026-08-17: substrate's D2 freshness gate is
// `if pathExists p && … then hashFile p`, so the guard passed and this
// threw, and `sui system rebuild` on every fleet darwin/NixOS config died
// at `navigate attrs: hashFile: No such file or directory` — identically
// for a local `.#host` ref and a remote `github:…/<rev>#host` one, which
// is what proved it was eval, not the fetcher. `pathExists` (paths.rs),
// `readFile`, `readDir`, `import` and `builtins.path` all already spell
// both halves; this one spelled neither.
register_curried(builtins, "hashFile", |algo, path_val| {
let algo_str = algo.as_string()?;
let path_str =
crate::path::materialize_str(&path_val.coerce_to_realized_path("hashFile")?);
let contents = std::fs::read(&path_str)
.map_err(|e| EvalError::IoError { context: "hashFile".into(), message: e.to_string() })?;
let hex = match algo_str {
"sha256" => {
use sha2::{Sha256, Digest};
format!("{:x}", Sha256::digest(&contents))
}
"sha512" => {
use sha2::{Sha512, Digest};
format!("{:x}", Sha512::digest(&contents))
}
_ => return Err(EvalError::TypeError(format!("hashFile: unsupported algorithm: {algo_str}"))),
};
Ok(Value::string(hex))
});
// toXML — CppNix `printValueAsXML(state, strict=true, location=false, …)`.
//
// Three shipped defects this shape exists to prevent:
//
// 1. sui emitted `<thunk />` — an element CppNix CANNOT produce — for
// every unforced value, yielding a well-formed document that parsed
// fine and was wrong. `value_to_xml` therefore renders `Concrete`,
// whose enum has NO `Thunk` variant, so that arm is UNWRITABLE rather
// than merely unreached. Demanding at each node as we descend is
// exactly what CppNix's `strict = true` means.
// 2. The `<expr>` root wrapper was missing, so EVERY call was wrong —
// including the ones whose bodies looked correct. That is why nothing
// caught it: all seven existing tests are substring `contains` checks
// against the body, and none of them looks at the root.
// 3. `xml_escape` left LF raw inside an attribute value, where XML
// attribute-value normalization turns it into a space on the way back
// out. Data loss, not cosmetics — CppNix's XMLWriter emits `
`.
register_builtin(builtins, "toXML", |args| {
let mut drvs_seen: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut ancestors: Vec<usize> = Vec::new();
let body = value_to_xml(&args[0], 2, &mut drvs_seen, &mut ancestors)?;
Ok(Value::string(format!(
"<?xml version='1.0' encoding='utf-8'?>\n<expr>\n{body}\n</expr>\n"
)))
});
}
/// `Some((drvPath, outPath))` when this attrset is a derivation — CppNix's
/// `isDerivation` test, which gates the `<derivation>` element.
fn derivation_ids(attrs: &NixAttrs) -> Result<Option<(String, String)>, EvalError> {
let Some(ty) = attrs.get("type") else {
return Ok(None);
};
let Concrete::String(ty) = ty.demand()? else {
return Ok(None);
};
if &*ty.chars != "derivation" {
return Ok(None);
}
let (Some(drv), Some(out)) = (attrs.get("drvPath"), attrs.get("outPath")) else {
return Ok(None);
};
let Concrete::String(drv) = drv.demand()? else {
return Ok(None);
};
let Concrete::String(out) = out.demand()? else {
return Ok(None);
};
Ok(Some((drv.chars.to_string(), out.chars.to_string())))
}
/// CppNix has NO cycle protection in `printValueAsXML`: `builtins.toXML` on a
/// self-referential value SIGSEGVs (measured 2026-08-18, rc=139). sui refuses
/// with a typed error instead — a segfault and an invented element are both
/// worse than saying so, and there is no parity cost, because the oracle emits
/// no bytes to be unequal to.
///
/// The guard is an ANCESTOR stack, not a seen-set. CppNix re-expands a shared
/// non-cyclic value in full, so a seen-set would break DAG parity:
/// `let a = { n = 1; }; in builtins.toXML { p = a; q = a; }` must render `a`
/// twice. Only a genuine ancestor is a cycle.
fn enter_cycle_guard(ancestors: &mut Vec<usize>, key: usize) -> Result<(), EvalError> {
if ancestors.contains(&key) {
return Err(EvalError::InfiniteRecursion(
"toXML: value contains a cycle".into(),
));
}
ancestors.push(key);
Ok(())
}
/// Render one node, byte-for-byte as CppNix's `printValueAsXML` does.
///
/// Takes `&Value` but immediately demands it to [`Concrete`] — see defect (1)
/// on the registration above. Every recursion re-demands, so laziness resolves
/// top-down exactly as `strict = true` prescribes, and a forcing failure
/// propagates out of `toXML` (CppNix does not contain the error either).
fn value_to_xml(
v: &Value,
indent: usize,
drvs_seen: &mut std::collections::HashSet<String>,
ancestors: &mut Vec<usize>,
) -> Result<String, EvalError> {
let pad = " ".repeat(indent);
Ok(match v.demand()? {
Concrete::Null => format!("{pad}<null />"),
Concrete::Bool(b) => format!("{pad}<bool value=\"{b}\" />"),
Concrete::Int(n) => format!("{pad}<int value=\"{n}\" />"),
// The SHARED cross-engine formatter, never Rust's `{}` Display:
// `format!("{f}")` renders 3.0e10 as `30000000000` where CppNix
// (and sui's own value printer) say `3e+10`. Re-deriving a
// cross-engine fact locally is how the two answers drift.
Concrete::Float(f) => format!(
"{pad}<float value=\"{}\" />",
sui_compat::versions::cppnix_format_float(f)
),
Concrete::String(ns) => {
format!("{pad}<string value=\"{}\" />", xml_escape(&ns.chars))
}
Concrete::Path(p) => format!("{pad}<path value=\"{}\" />", xml_escape(&p)),
Concrete::List(items) => {
enter_cycle_guard(ancestors, Rc::as_ptr(&items) as usize)?;
let mut out = format!("{pad}<list>\n");
for item in items.iter() {
out.push_str(&value_to_xml(item, indent + 2, drvs_seen, ancestors)?);
out.push('\n');
}
out.push_str(&format!("{pad}</list>"));
ancestors.pop();
out
}
Concrete::Attrs(attrs) => {
let drv = derivation_ids(&attrs)?;
// CppNix's seen-set is DERIVATIONS ONLY, and `<repeated />` is a
// CHILD of `<derivation>`, not a replacement for it — the open tag
// keeps both attributes either way. (Measured; the natural reading
// of "emits <repeated /> for a seen derivation" is wrong.)
if let Some((drv_path, out_path)) = &drv
&& !drvs_seen.insert(drv_path.clone())
{
return Ok(format!(
"{pad}<derivation drvPath=\"{}\" outPath=\"{}\">\n\
{pad} <repeated />\n\
{pad}</derivation>",
xml_escape(drv_path),
xml_escape(out_path)
));
}
enter_cycle_guard(ancestors, Rc::as_ptr(&attrs) as usize)?;
let (open, close) = match &drv {
Some((d, o)) => (
format!(
"{pad}<derivation drvPath=\"{}\" outPath=\"{}\">\n",
xml_escape(d),
xml_escape(o)
),
format!("{pad}</derivation>"),
),
None => (format!("{pad}<attrs>\n"), format!("{pad}</attrs>")),
};
let mut out = open;
for (k, val) in attrs.iter() {
out.push_str(&format!("{pad} <attr name=\"{}\">\n", xml_escape(&k)));
out.push_str(&value_to_xml(val, indent + 4, drvs_seen, ancestors)?);
out.push('\n');
out.push_str(&format!("{pad} </attr>\n"));
}
out.push_str(&close);
ancestors.pop();
out
}
// CppNix distinguishes THREE function shapes. Collapsing them to a bare
// `<function />` discarded the parameter names entirely.
Concrete::Lambda(cl) => {
let inner = match &cl.param {
rnix::ast::Param::IdentParam(ip) => ip
.ident()
.map(|i| {
format!(
"{pad} <varpat name=\"{}\" />\n",
xml_escape(&crate::eval::ident_text(&i))
)
})
.unwrap_or_default(),
rnix::ast::Param::Pattern(pat) => {
let name = pat
.pat_bind()
.and_then(|b| b.ident())
.map(|i| {
format!(
" name=\"{}\"",
xml_escape(&crate::eval::ident_text(&i))
)
})
.unwrap_or_default();
let ellipsis = if pat.ellipsis_token().is_some() {
" ellipsis=\"1\""
} else {
""
};
// CppNix writes `ellipsis` BEFORE `name`
// (`<attrspat ellipsis="1" name="args">`); the
// opposite order is well-formed XML and not
// byte-parity, which is the bar here.
let mut s = format!("{pad} <attrspat{ellipsis}{name}>\n");
for e in pat.pat_entries() {
if let Some(id) = e.ident() {
s.push_str(&format!(
"{pad} <attr name=\"{}\" />\n",
xml_escape(&crate::eval::ident_text(&id))
));
}
}
s.push_str(&format!("{pad} </attrspat>\n"));
s
}
};
format!("{pad}<function>\n{inner}{pad}</function>")
}
// A primop, or a partial application of one, is `<unevaluated />`.
Concrete::Builtin(_) => format!("{pad}<unevaluated />"),
})
}