Skip to main content

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}