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.
188pub fn install_input_primitives<H: 'static>(interp: &mut Interpreter<H>, inputs: Inputs) {
189 let table = std::sync::Arc::new(inputs);
190
191 let read = table.clone();
192 interp.register_fn(
193 "input",
194 Arity::Exact(1),
195 move |args: &[Value], _h: &mut H, span| {
196 let name = match &args[0] {
197 Value::Str(s) => s.to_string(),
198 other => {
199 return Err(tatara_lisp_eval::EvalError::type_mismatch(
200 "an input name (string)",
201 other.type_name(),
202 span,
203 )
204 .into())
205 }
206 };
207 match read.get(&name) {
208 // Lossy is correct here: an input is bytes, and a macro that
209 // wants to *read* it wants text. A schema with invalid UTF-8 is
210 // a schema the macro could not have parsed anyway.
211 Some(bytes) => Ok(Value::Str(String::from_utf8_lossy(bytes).into())),
212 // NOT a file read, and not nil. An undeclared name is a program
213 // error: silently returning nil is how a macro generates an
214 // empty table and nobody notices until runtime.
215 None => Err(tatara_lisp_eval::EvalError::native_fn(
216 "input",
217 "no input named `".to_string()
218 + &name
219 + "` is declared. A macro may only read inputs the program \
220 declared with definput — there is no path-based read.",
221 span,
222 )
223 .into()),
224 }
225 },
226 );
227
228 interp.register_fn(
229 "definput",
230 Arity::Exact(2),
231 move |args: &[Value], _h: &mut H, _span| Ok(args[0].clone()),
232 );
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 const BYTES: &[u8] = b"id,name,email\n";
240
241 fn decl(name: &str, hash: &str) -> Declaration {
242 Declaration {
243 name: name.to_string(),
244 hash: hash.to_string(),
245 }
246 }
247
248 #[test]
249 fn correct_bytes_bind() {
250 let mut i = Inputs::new();
251 i.bind(&decl("schema", &Inputs::hash_of(BYTES)), BYTES.to_vec())
252 .expect("hash matches");
253 assert_eq!(i.get("schema"), Some(BYTES));
254 }
255
256 /// **Wrong bytes are refused, not warned about.** The declaration is a claim
257 /// about *which* bytes; honouring a different set makes the build
258 /// irreproducible in exactly the way content addressing prevents.
259 #[test]
260 fn bytes_that_do_not_match_the_declared_hash_are_refused() {
261 let mut i = Inputs::new();
262 let err = i
263 .bind(
264 &decl("schema", &Inputs::hash_of(BYTES)),
265 b"tampered".to_vec(),
266 )
267 .expect_err("must refuse");
268 assert!(matches!(err, InputError::HashMismatch { .. }), "{err}");
269 assert_eq!(i.get("schema"), None, "and nothing may be bound");
270 }
271
272 /// The error names both hashes, so the author can tell "I edited the file"
273 /// from "I pasted the wrong hash".
274 #[test]
275 fn a_mismatch_names_both_hashes() {
276 let mut i = Inputs::new();
277 let expected = Inputs::hash_of(BYTES);
278 let err = i
279 .bind(&decl("schema", &expected), b"other".to_vec())
280 .expect_err("refuse");
281 let msg = err.to_string();
282 assert!(msg.contains(&expected), "must name the expected: {msg}");
283 assert!(
284 msg.contains(&Inputs::hash_of(b"other")),
285 "and the actual: {msg}"
286 );
287 }
288
289 /// The algorithm is part of the contract. A bare hex string is refused
290 /// rather than assumed to be BLAKE3.
291 #[test]
292 fn a_hash_without_the_algorithm_prefix_is_refused() {
293 let mut i = Inputs::new();
294 let bare = blake3::hash(BYTES).to_hex().to_string();
295 let err = i
296 .bind(&decl("schema", &bare), BYTES.to_vec())
297 .expect_err("refuse");
298 assert!(matches!(err, InputError::UnknownAlgorithm { .. }), "{err}");
299 }
300
301 /// Hashing is content-only — the same bytes always hash the same, and one
302 /// changed byte changes it. This is what mtime cannot do.
303 #[test]
304 fn the_hash_is_a_function_of_content_alone() {
305 assert_eq!(Inputs::hash_of(BYTES), Inputs::hash_of(&BYTES.to_vec()));
306 assert_ne!(Inputs::hash_of(BYTES), Inputs::hash_of(b"id,name,emaiL\n"));
307 assert!(Inputs::hash_of(BYTES).starts_with(HASH_PREFIX));
308 }
309
310 // ---- declarations ---------------------------------------------------
311
312 #[test]
313 fn declarations_are_scanned_from_anywhere_in_the_program() {
314 let src = format!(
315 "1 + 1\ndefinput(\"schema\", \"{}\")\n2 + 2",
316 Inputs::hash_of(BYTES)
317 );
318 let forms = blue_lang_syntax::parse_program(&src).expect("parse");
319 let decls = declarations(&forms);
320 assert_eq!(decls.len(), 1);
321 assert_eq!(decls[0].name, "schema");
322 }
323
324 /// Position must not matter. Evaluating declarations in order would make a
325 /// macro's access depend on whether its `definput` was written above it.
326 #[test]
327 fn a_declaration_below_its_use_is_still_found() {
328 let src = format!(
329 "defmacro m()\n quote\n input(\"late\")\n end\nend\ndefinput(\"late\", \"{}\")",
330 Inputs::hash_of(BYTES)
331 );
332 let forms = blue_lang_syntax::parse_program(&src).expect("parse");
333 assert_eq!(declarations(&forms).len(), 1);
334 }
335
336 #[test]
337 fn a_program_with_no_declarations_yields_none() {
338 let forms = blue_lang_syntax::parse_program("1 + 1").expect("parse");
339 assert!(declarations(&forms).is_empty());
340 }
341}