use std::collections::BTreeMap;
use tatara_lisp::{Atom, Sexp};
use tatara_lisp_eval::ffi::Arity;
use tatara_lisp_eval::{Interpreter, Value};
pub const HASH_PREFIX: &str = "b3:";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InputError {
#[error(
"input `{name}`: expected hash `{expected}`, but the supplied bytes hash to `{actual}`"
)]
HashMismatch {
name: String,
expected: String,
actual: String,
},
#[error("input `{name}`: hash must start with `{HASH_PREFIX}` (got `{got}`)")]
UnknownAlgorithm { name: String, got: String },
#[error("input `{name}` is declared but no bytes were supplied for it")]
Unsupplied { name: String },
#[error("`{0}` was supplied but never declared — declare it with definput before use")]
Undeclared(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Declaration {
pub name: String,
pub hash: String,
}
#[derive(Clone, Debug, Default)]
pub struct Inputs {
verified: BTreeMap<String, Vec<u8>>,
}
impl Inputs {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn hash_of(bytes: &[u8]) -> String {
let mut out = String::with_capacity(HASH_PREFIX.len() + 64);
out.push_str(HASH_PREFIX);
out.push_str(&blake3::hash(bytes).to_hex());
out
}
pub fn bind(&mut self, decl: &Declaration, bytes: Vec<u8>) -> Result<(), InputError> {
if !decl.hash.starts_with(HASH_PREFIX) {
return Err(InputError::UnknownAlgorithm {
name: decl.name.clone(),
got: decl.hash.clone(),
});
}
let actual = Self::hash_of(&bytes);
if actual != decl.hash {
return Err(InputError::HashMismatch {
name: decl.name.clone(),
expected: decl.hash.clone(),
actual,
});
}
self.verified.insert(decl.name.clone(), bytes);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&[u8]> {
self.verified.get(name).map(Vec::as_slice)
}
pub fn len(&self) -> usize {
self.verified.len()
}
pub fn is_empty(&self) -> bool {
self.verified.is_empty()
}
}
#[must_use]
pub fn declarations(forms: &[Sexp]) -> Vec<Declaration> {
forms.iter().filter_map(as_declaration).collect()
}
fn as_declaration(form: &Sexp) -> Option<Declaration> {
let Sexp::List(items) = form else { return None };
if items.len() != 3 {
return None;
}
match (&items[0], &items[1], &items[2]) {
(
Sexp::Atom(Atom::Symbol(head)),
Sexp::Atom(Atom::Str(name)),
Sexp::Atom(Atom::Str(hash)),
) if &**head == "definput" => Some(Declaration {
name: name.to_string(),
hash: hash.to_string(),
}),
_ => None,
}
}
pub fn install_input_primitives<H: 'static>(interp: &mut Interpreter<H>, inputs: Inputs) {
let table = std::sync::Arc::new(inputs);
let read = table.clone();
interp.register_fn(
"input",
Arity::Exact(1),
move |args: &[Value], _h: &mut H, span| {
let name = match &args[0] {
Value::Str(s) => s.to_string(),
other => {
return Err(tatara_lisp_eval::EvalError::type_mismatch(
"an input name (string)",
other.type_name(),
span,
)
.into())
}
};
match read.get(&name) {
Some(bytes) => Ok(Value::Str(String::from_utf8_lossy(bytes).into())),
None => Err(tatara_lisp_eval::EvalError::native_fn(
"input",
"no input named `".to_string()
+ &name
+ "` is declared. A macro may only read inputs the program \
declared with definput — there is no path-based read.",
span,
)
.into()),
}
},
);
interp.register_fn(
"definput",
Arity::Exact(2),
move |args: &[Value], _h: &mut H, _span| Ok(args[0].clone()),
);
}
#[cfg(test)]
mod tests {
use super::*;
const BYTES: &[u8] = b"id,name,email\n";
fn decl(name: &str, hash: &str) -> Declaration {
Declaration {
name: name.to_string(),
hash: hash.to_string(),
}
}
#[test]
fn correct_bytes_bind() {
let mut i = Inputs::new();
i.bind(&decl("schema", &Inputs::hash_of(BYTES)), BYTES.to_vec())
.expect("hash matches");
assert_eq!(i.get("schema"), Some(BYTES));
}
#[test]
fn bytes_that_do_not_match_the_declared_hash_are_refused() {
let mut i = Inputs::new();
let err = i
.bind(
&decl("schema", &Inputs::hash_of(BYTES)),
b"tampered".to_vec(),
)
.expect_err("must refuse");
assert!(matches!(err, InputError::HashMismatch { .. }), "{err}");
assert_eq!(i.get("schema"), None, "and nothing may be bound");
}
#[test]
fn a_mismatch_names_both_hashes() {
let mut i = Inputs::new();
let expected = Inputs::hash_of(BYTES);
let err = i
.bind(&decl("schema", &expected), b"other".to_vec())
.expect_err("refuse");
let msg = err.to_string();
assert!(msg.contains(&expected), "must name the expected: {msg}");
assert!(
msg.contains(&Inputs::hash_of(b"other")),
"and the actual: {msg}"
);
}
#[test]
fn a_hash_without_the_algorithm_prefix_is_refused() {
let mut i = Inputs::new();
let bare = blake3::hash(BYTES).to_hex().to_string();
let err = i
.bind(&decl("schema", &bare), BYTES.to_vec())
.expect_err("refuse");
assert!(matches!(err, InputError::UnknownAlgorithm { .. }), "{err}");
}
#[test]
fn the_hash_is_a_function_of_content_alone() {
assert_eq!(Inputs::hash_of(BYTES), Inputs::hash_of(&BYTES.to_vec()));
assert_ne!(Inputs::hash_of(BYTES), Inputs::hash_of(b"id,name,emaiL\n"));
assert!(Inputs::hash_of(BYTES).starts_with(HASH_PREFIX));
}
#[test]
fn declarations_are_scanned_from_anywhere_in_the_program() {
let src = format!(
"1 + 1\ndefinput(\"schema\", \"{}\")\n2 + 2",
Inputs::hash_of(BYTES)
);
let forms = blue_lang_syntax::parse_program(&src).expect("parse");
let decls = declarations(&forms);
assert_eq!(decls.len(), 1);
assert_eq!(decls[0].name, "schema");
}
#[test]
fn a_declaration_below_its_use_is_still_found() {
let src = format!(
"defmacro m()\n quote\n input(\"late\")\n end\nend\ndefinput(\"late\", \"{}\")",
Inputs::hash_of(BYTES)
);
let forms = blue_lang_syntax::parse_program(&src).expect("parse");
assert_eq!(declarations(&forms).len(), 1);
}
#[test]
fn a_program_with_no_declarations_yields_none() {
let forms = blue_lang_syntax::parse_program("1 + 1").expect("parse");
assert!(declarations(&forms).is_empty());
}
}