blue_lang_runtime/inputs.rs
1//! Build inputs — the macro phase's **only** channel to the outside world.
2//!
3//! Closes `theory/BLUE.md` §VI OPEN #6, which the spec names as gating "blue's
4//! whole 'stronger than Ruby's metaprogramming' claim": tenet 2 installs a
5//! `NoLoader`, so a macro could read *nothing*, which also meant it could not
6//! generate code from a schema — the thing that would make blue's
7//! metaprogramming exceed Ruby's rather than merely match it.
8//!
9//! ```text
10//! definput("schema", "b3:1d9e…") # the DECLARATION: name + content hash
11//!
12//! defmacro columns()
13//! quote
14//! unquote(input("schema")) # the macro receives the BYTES
15//! end
16//! end
17//! ```
18//!
19//! # Why this is stronger than what Ruby or Elixir can express
20//!
21//! Both have compile-time/load-time I/O, and in both it is **ambient
22//! authority**:
23//!
24//! - Ruby runs arbitrary code at load time with the whole filesystem open. A
25//! gem's metaprogramming can read anything the process can read.
26//! - Elixir's `@external_resource` plus `File.read!/1` is the same authority,
27//! and its recompilation tracking keys on **mtime**, not content — so the
28//! same bytes at a new timestamp force a rebuild, and different bytes at the
29//! same timestamp do not.
30//!
31//! blue's channel is **capability-restricted and content-addressed**:
32//!
33//! 1. There is no path anywhere in the API. A macro names an *input*, never a
34//! file, so it cannot reach something the author did not declare — and the
35//! restriction is the absence of a primitive, not a policy consulted at call
36//! time.
37//! 2. Bytes are verified against the declared BLAKE3 hash **before** anything
38//! can read them. Wrong bytes are refused, not silently used.
39//! 3. Because the declaration *is* the hash, "did the input change" is a
40//! content question. mtime cannot make it lie in either direction.
41//!
42//! # What is deliberately still impossible
43//!
44//! A macro cannot enumerate inputs, cannot read a path, cannot fetch a URL, and
45//! cannot see an input the program did not declare. Adding any of those would
46//! return the ambient authority this exists to remove.
47
48use std::collections::BTreeMap;
49
50use tatara_lisp::{Atom, Sexp};
51use tatara_lisp_eval::ffi::Arity;
52use tatara_lisp_eval::{Interpreter, Value};
53
54/// The hash prefix a declaration must carry. Explicit so the algorithm is part
55/// of the contract rather than an assumption — a bare hex string would silently
56/// become un-migratable the day a second algorithm is wanted.
57pub const HASH_PREFIX: &str = "b3:";
58
59#[derive(Debug, thiserror::Error, PartialEq, Eq)]
60pub enum InputError {
61 #[error(
62 "input `{name}`: expected hash `{expected}`, but the supplied bytes hash to `{actual}`"
63 )]
64 HashMismatch {
65 name: String,
66 expected: String,
67 actual: String,
68 },
69 #[error("input `{name}`: hash must start with `{HASH_PREFIX}` (got `{got}`)")]
70 UnknownAlgorithm { name: String, got: String },
71 #[error("input `{name}` is declared but no bytes were supplied for it")]
72 Unsupplied { name: String },
73 #[error("`{0}` was supplied but never declared — declare it with definput before use")]
74 Undeclared(String),
75}
76
77/// One declared input: a name bound to a content hash.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Declaration {
80 pub name: String,
81 /// `b3:<hex>`.
82 pub hash: String,
83}
84
85/// The verified inputs a macro phase may read.
86///
87/// Construction is the verification: an `Inputs` cannot hold bytes that do not
88/// match their declared hash, because [`Inputs::bind`] is the only way in and it
89/// checks. That is why the reading primitive has no error path for a bad hash —
90/// the state is unrepresentable rather than guarded against.
91#[derive(Clone, Debug, Default)]
92pub struct Inputs {
93 verified: BTreeMap<String, Vec<u8>>,
94}
95
96impl Inputs {
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 /// The BLAKE3 content hash of `bytes`, in declaration form.
102 #[must_use]
103 pub fn hash_of(bytes: &[u8]) -> String {
104 let mut out = String::with_capacity(HASH_PREFIX.len() + 64);
105 out.push_str(HASH_PREFIX);
106 out.push_str(&blake3::hash(bytes).to_hex());
107 out
108 }
109
110 /// Bind bytes to a declaration, verifying the hash.
111 ///
112 /// **Refuses on mismatch.** Accepting the bytes and warning would defeat the
113 /// point: the declaration is a claim about *which* bytes, and honouring a
114 /// different set makes the build irreproducible in exactly the way content
115 /// addressing exists to prevent.
116 pub fn bind(&mut self, decl: &Declaration, bytes: Vec<u8>) -> Result<(), InputError> {
117 if !decl.hash.starts_with(HASH_PREFIX) {
118 return Err(InputError::UnknownAlgorithm {
119 name: decl.name.clone(),
120 got: decl.hash.clone(),
121 });
122 }
123 let actual = Self::hash_of(&bytes);
124 if actual != decl.hash {
125 return Err(InputError::HashMismatch {
126 name: decl.name.clone(),
127 expected: decl.hash.clone(),
128 actual,
129 });
130 }
131 self.verified.insert(decl.name.clone(), bytes);
132 Ok(())
133 }
134
135 pub fn get(&self, name: &str) -> Option<&[u8]> {
136 self.verified.get(name).map(Vec::as_slice)
137 }
138
139 pub fn len(&self) -> usize {
140 self.verified.len()
141 }
142
143 pub fn is_empty(&self) -> bool {
144 self.verified.is_empty()
145 }
146}
147
148/// Collect `definput("name", "b3:…")` declarations from a program.
149///
150/// Scanned rather than evaluated in order, so a `definput` may appear anywhere
151/// in the file. Evaluating them in sequence would make a macro's access depend
152/// on whether its declaration happened to be written above it — a positional
153/// rule nobody would remember and the compiler would not enforce.
154#[must_use]
155pub fn declarations(forms: &[Sexp]) -> Vec<Declaration> {
156 forms.iter().filter_map(as_declaration).collect()
157}
158
159fn as_declaration(form: &Sexp) -> Option<Declaration> {
160 let Sexp::List(items) = form else { return None };
161 if items.len() != 3 {
162 return None;
163 }
164 match (&items[0], &items[1], &items[2]) {
165 (
166 Sexp::Atom(Atom::Symbol(head)),
167 Sexp::Atom(Atom::Str(name)),
168 Sexp::Atom(Atom::Str(hash)),
169 ) if &**head == "definput" => Some(Declaration {
170 name: name.to_string(),
171 hash: hash.to_string(),
172 }),
173 _ => None,
174 }
175}
176
177/// Install the reading primitives against `inputs`.
178///
179/// Two, and no more:
180///
181/// - `input(name)` — the declared bytes as a string.
182/// - `definput(name, hash)` — a no-op at run time. The declaration is consumed
183/// by [`declarations`] *before* evaluation; this exists so the form is not an
184/// unbound symbol, and returns the name so it reads as a value.
185///
186/// There is deliberately no `inputs()`, no `input_path()`, and no
187/// `read_file()`. Each would hand back the ambient authority this removes.
188/// The names [`install_input_primitives`] binds. Named once, so the check
189/// stage can ask an interpreter about them: installed on a FORK they live in
190/// the fork's private frame, which `Interpreter::reserved_head_names` does
191/// not enumerate (it reads the root frame only).
192pub const INPUT_PRIMITIVES: [&str; 2] = ["input", "definput"];
193
194pub fn install_input_primitives<H: 'static>(interp: &mut Interpreter<H>, inputs: Inputs) {
195 let table = std::sync::Arc::new(inputs);
196
197 let read = table.clone();
198 interp.register_fn(
199 INPUT_PRIMITIVES[0],
200 Arity::Exact(1),
201 move |args: &[Value], _h: &mut H, span| {
202 let name = match &args[0] {
203 Value::Str(s) => s.to_string(),
204 other => {
205 return Err(tatara_lisp_eval::EvalError::type_mismatch(
206 "an input name (string)",
207 other.type_name(),
208 span,
209 )
210 .into())
211 }
212 };
213 match read.get(&name) {
214 // Lossy is correct here: an input is bytes, and a macro that
215 // wants to *read* it wants text. A schema with invalid UTF-8 is
216 // a schema the macro could not have parsed anyway.
217 Some(bytes) => Ok(Value::Str(String::from_utf8_lossy(bytes).into())),
218 // NOT a file read, and not nil. An undeclared name is a program
219 // error: silently returning nil is how a macro generates an
220 // empty table and nobody notices until runtime.
221 None => Err(tatara_lisp_eval::EvalError::native_fn(
222 "input",
223 "no input named `".to_string()
224 + &name
225 + "` is declared. A macro may only read inputs the program \
226 declared with definput — there is no path-based read.",
227 span,
228 )
229 .into()),
230 }
231 },
232 );
233
234 interp.register_fn(
235 INPUT_PRIMITIVES[1],
236 Arity::Exact(2),
237 move |args: &[Value], _h: &mut H, _span| Ok(args[0].clone()),
238 );
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 const BYTES: &[u8] = b"id,name,email\n";
246
247 fn decl(name: &str, hash: &str) -> Declaration {
248 Declaration {
249 name: name.to_string(),
250 hash: hash.to_string(),
251 }
252 }
253
254 #[test]
255 fn correct_bytes_bind() {
256 let mut i = Inputs::new();
257 i.bind(&decl("schema", &Inputs::hash_of(BYTES)), BYTES.to_vec())
258 .expect("hash matches");
259 assert_eq!(i.get("schema"), Some(BYTES));
260 }
261
262 /// **Wrong bytes are refused, not warned about.** The declaration is a claim
263 /// about *which* bytes; honouring a different set makes the build
264 /// irreproducible in exactly the way content addressing prevents.
265 #[test]
266 fn bytes_that_do_not_match_the_declared_hash_are_refused() {
267 let mut i = Inputs::new();
268 let err = i
269 .bind(
270 &decl("schema", &Inputs::hash_of(BYTES)),
271 b"tampered".to_vec(),
272 )
273 .expect_err("must refuse");
274 assert!(matches!(err, InputError::HashMismatch { .. }), "{err}");
275 assert_eq!(i.get("schema"), None, "and nothing may be bound");
276 }
277
278 /// The error names both hashes, so the author can tell "I edited the file"
279 /// from "I pasted the wrong hash".
280 #[test]
281 fn a_mismatch_names_both_hashes() {
282 let mut i = Inputs::new();
283 let expected = Inputs::hash_of(BYTES);
284 let err = i
285 .bind(&decl("schema", &expected), b"other".to_vec())
286 .expect_err("refuse");
287 let msg = err.to_string();
288 assert!(msg.contains(&expected), "must name the expected: {msg}");
289 assert!(
290 msg.contains(&Inputs::hash_of(b"other")),
291 "and the actual: {msg}"
292 );
293 }
294
295 /// The algorithm is part of the contract. A bare hex string is refused
296 /// rather than assumed to be BLAKE3.
297 #[test]
298 fn a_hash_without_the_algorithm_prefix_is_refused() {
299 let mut i = Inputs::new();
300 let bare = blake3::hash(BYTES).to_hex().to_string();
301 let err = i
302 .bind(&decl("schema", &bare), BYTES.to_vec())
303 .expect_err("refuse");
304 assert!(matches!(err, InputError::UnknownAlgorithm { .. }), "{err}");
305 }
306
307 /// Hashing is content-only — the same bytes always hash the same, and one
308 /// changed byte changes it. This is what mtime cannot do.
309 #[test]
310 fn the_hash_is_a_function_of_content_alone() {
311 assert_eq!(Inputs::hash_of(BYTES), Inputs::hash_of(&BYTES.to_vec()));
312 assert_ne!(Inputs::hash_of(BYTES), Inputs::hash_of(b"id,name,emaiL\n"));
313 assert!(Inputs::hash_of(BYTES).starts_with(HASH_PREFIX));
314 }
315
316 // ---- declarations ---------------------------------------------------
317
318 #[test]
319 fn declarations_are_scanned_from_anywhere_in_the_program() {
320 let src = format!(
321 "1 + 1\ndefinput(\"schema\", \"{}\")\n2 + 2",
322 Inputs::hash_of(BYTES)
323 );
324 let forms = blue_lang_syntax::parse_program(&src).expect("parse");
325 let decls = declarations(&forms);
326 assert_eq!(decls.len(), 1);
327 assert_eq!(decls[0].name, "schema");
328 }
329
330 /// Position must not matter. Evaluating declarations in order would make a
331 /// macro's access depend on whether its `definput` was written above it.
332 #[test]
333 fn a_declaration_below_its_use_is_still_found() {
334 let src = format!(
335 "defmacro m()\n quote\n input(\"late\")\n end\nend\ndefinput(\"late\", \"{}\")",
336 Inputs::hash_of(BYTES)
337 );
338 let forms = blue_lang_syntax::parse_program(&src).expect("parse");
339 assert_eq!(declarations(&forms).len(), 1);
340 }
341
342 #[test]
343 fn a_program_with_no_declarations_yields_none() {
344 let forms = blue_lang_syntax::parse_program("1 + 1").expect("parse");
345 assert!(declarations(&forms).is_empty());
346 }
347}