use std::sync::Arc;
use sha2::{Digest, Sha256};
use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
use crate::script_ctx::ScriptCtx;
use crate::stdlib::env::str_arg;
pub fn install(interp: &mut Interpreter<ScriptCtx>) {
interp.register_fn(
"sha256",
Arity::Exact(1),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let s = str_arg(&args[0], "sha256", sp)?;
let digest = Sha256::digest(s.as_bytes());
Ok(Value::Str(Arc::from(hex::encode(digest))))
},
);
interp.register_fn(
"sha256-file",
Arity::Exact(1),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let path = str_arg(&args[0], "sha256-file", sp)?;
let mut hasher = Sha256::new();
stream_into(&path, &mut hasher)
.map_err(|e| EvalError::native_fn("sha256-file", e, sp))?;
Ok(Value::Str(Arc::from(hex::encode(hasher.finalize()))))
},
);
interp.register_fn(
"blake3-file",
Arity::Exact(1),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let path = str_arg(&args[0], "blake3-file", sp)?;
let mut hasher = blake3::Hasher::new();
stream_into(&path, &mut hasher)
.map_err(|e| EvalError::native_fn("blake3-file", e, sp))?;
Ok(Value::Str(Arc::from(
hasher.finalize().to_hex().to_string(),
)))
},
);
interp.register_fn(
"slugify",
Arity::Exact(2),
|args: &[Value], _ctx: &mut ScriptCtx, sp| {
let name = str_arg(&args[0], "slugify", sp)?;
let kind = str_arg(&args[1], "slugify", sp)?;
Ok(Value::Str(Arc::from(derive_slug(&name, &kind))))
},
);
}
fn stream_into<W: std::io::Write>(path: &str, hasher: &mut W) -> Result<(), String> {
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
std::io::copy(&mut file, hasher).map_err(|e| format!("{path}: {e}"))?;
Ok(())
}
pub fn derive_slug(name: &str, kind: &str) -> String {
let normalized = if name == "@" || name.is_empty() {
"root".to_string()
} else {
let mut s = name.replace('.', "_");
if let Some(stripped) = s.strip_prefix("*_") {
s = format!("wildcard_{stripped}");
} else if let Some(stripped) = s.strip_prefix("*") {
s = format!("wildcard{stripped}");
}
while s.contains("__") {
s = s.replace("__", "_");
}
s
};
format!("{}_{}", normalized, kind.to_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apex_normalizes_to_root() {
assert_eq!(derive_slug("@", "CNAME"), "root_cname");
}
#[test]
fn dots_become_underscores() {
assert_eq!(derive_slug("api.staging", "CNAME"), "api_staging_cname");
}
#[test]
fn wildcard_expands() {
assert_eq!(derive_slug("*.staging", "CNAME"), "wildcard_staging_cname");
}
#[test]
fn underscore_prefixed_dkim_preserved() {
assert_eq!(
derive_slug("resend._domainkey", "TXT"),
"resend_domainkey_txt"
);
}
#[test]
fn plain_name_lowercases_type() {
assert_eq!(derive_slug("www", "CNAME"), "www_cname");
assert_eq!(derive_slug("send", "MX"), "send_mx");
}
}
#[cfg(test)]
mod file_hash_tests {
use super::*;
use std::io::Write;
#[test]
fn empty_file_matches_published_constants() {
let dir = std::env::temp_dir().join("tl-hash-empty");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("empty");
std::fs::write(&p, b"").unwrap();
let path = p.to_str().unwrap();
let mut s = Sha256::new();
stream_into(path, &mut s).unwrap();
assert_eq!(
hex::encode(s.finalize()),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
let mut b = blake3::Hasher::new();
stream_into(path, &mut b).unwrap();
assert_eq!(
b.finalize().to_hex().to_string(),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
#[test]
fn hashes_non_utf8_bytes_that_read_file_would_reject() {
let dir = std::env::temp_dir().join("tl-hash-binary");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("elf-ish");
std::fs::write(&p, [0x7f, b'E', b'L', b'F', 0x80, 0x00, 0xff]).unwrap();
let path = p.to_str().unwrap();
assert!(
std::fs::read_to_string(path).is_err(),
"read_to_string accepted invalid UTF-8 — the premise of this test is gone"
);
let mut s = Sha256::new();
stream_into(path, &mut s).unwrap();
assert_eq!(hex::encode(s.finalize()).len(), 64);
}
#[test]
fn streamed_digest_equals_whole_buffer_digest() {
let dir = std::env::temp_dir().join("tl-hash-large");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("blob");
let blob: Vec<u8> = (0..300_000u32).map(|i| (i % 251) as u8).collect();
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(&blob).unwrap();
drop(f);
let mut streamed = Sha256::new();
stream_into(p.to_str().unwrap(), &mut streamed).unwrap();
assert_eq!(
hex::encode(streamed.finalize()),
hex::encode(Sha256::digest(&blob))
);
}
#[test]
fn missing_file_is_an_error_naming_the_path() {
let mut s = Sha256::new();
let err = stream_into("/nonexistent/tl-hash/missing", &mut s).unwrap_err();
assert!(err.contains("/nonexistent/tl-hash/missing"), "got: {err}");
}
}