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.
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}