use std::sync::Arc;
use sha1::Digest as _;
use crate::error::{Error, Result};
use crate::modules::guard::PathGuard;
use crate::modules::{HostModule, InstallContext};
use crate::types::ModuleName;
#[derive(Debug)]
pub struct Hash {
name: ModuleName,
}
impl Hash {
#[must_use]
pub fn new() -> Self {
Self {
name: ModuleName::new("hash")
.unwrap_or_else(|_| unreachable!("`hash` is a valid module name")),
}
}
}
impl Default for Hash {
fn default() -> Self {
Self::new()
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(String::new(), |mut out, byte| {
use core::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
out
})
}
impl HostModule for Hash {
fn name(&self) -> &ModuleName {
&self.name
}
fn install(
&self,
lua: &mlua::Lua,
table: &mlua::Table,
context: &InstallContext<'_>,
) -> Result<()> {
let fail = |e: mlua::Error| Error::ModuleInstall {
module: String::from("hash"),
reason: e.to_string(),
};
let guard = PathGuard::new(Arc::new(context.grants().clone()), "hash");
let sha256 = lua
.create_function(|_, body: mlua::LuaString| {
Ok(hex(&sha2::Sha256::digest(body.as_bytes())))
})
.map_err(fail)?;
table.set("sha256", sha256).map_err(fail)?;
let sha1 = lua
.create_function(|_, body: mlua::LuaString| {
Ok(hex(&sha1::Sha1::digest(body.as_bytes())))
})
.map_err(fail)?;
table.set("sha1", sha1).map_err(fail)?;
let g = guard;
let hash_file = lua
.create_function(
move |_, (path, algorithm): (mlua::LuaString, Option<mlua::LuaString>)| {
let target = g.read("hash_file", &path.to_str()?)?;
let body = std::fs::read(&target).map_err(|source| Error::Io {
operation: "hash_file",
path: target.display().to_string(),
source,
})?;
let algorithm = match algorithm.as_ref() {
Some(name) => name.to_str()?.to_owned(),
None => String::from("sha256"),
};
match algorithm.as_str() {
"sha256" => Ok(hex(&sha2::Sha256::digest(&body))),
"sha1" => Ok(hex(&sha1::Sha1::digest(&body))),
other => Err(mlua::Error::from(Error::Denied {
module: "hash",
operation: "hash_file",
detail: format!(
"`{other}` is not a known algorithm — use sha256 or sha1"
),
})),
}
},
)
.map_err(fail)?;
table.set("hash_file", hash_file).map_err(fail)?;
let hex_fn = lua
.create_function(|_, body: mlua::LuaString| Ok(hex(&body.as_bytes())))
.map_err(fail)?;
table.set("hex", hex_fn).map_err(fail)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::Hash;
use crate::{Engine, GrantSet, HostModule as _, Policy, Script};
fn eval(source: &str) -> String {
let engine = Engine::builder().policy(Policy::pure()).build().unwrap();
engine
.eval_to::<String>(&Script::from_source(source, "test").unwrap())
.unwrap()
}
#[test]
fn the_module_is_named_hash() {
assert_eq!(Hash::new().name().as_str(), "hash");
}
#[test]
fn sha256_matches_the_known_digest_of_abc() {
assert_eq!(
eval("return airsstack.hash.sha256('abc')"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn sha1_matches_the_known_digest_of_abc() {
assert_eq!(
eval("return airsstack.hash.sha1('abc')"),
"a9993e364706816aba3e25717850c26c9cd0d89d"
);
}
#[test]
fn the_empty_string_hashes_to_the_documented_value() {
assert_eq!(
eval("return airsstack.hash.sha256('')"),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_truncated_sha1_reproduces_the_plugin_project_key() {
assert_eq!(
eval("return airsstack.hash.sha1('abc'):sub(1, 8)"),
"a9993e36"
);
}
#[test]
fn digests_are_lowercase_hexadecimal() {
assert_eq!(
eval("return tostring(airsstack.hash.sha256('x'):match('^[0-9a-f]+$') ~= nil)"),
"true"
);
}
#[test]
fn hex_encodes_arbitrary_bytes() {
assert_eq!(eval("return airsstack.hash.hex('AB')"), "4142");
}
#[test]
fn hash_file_defaults_to_sha256_and_matches_the_string_form() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::write(root.join("a.txt"), "abc").unwrap();
let engine = Engine::builder()
.policy(
Policy::confined()
.with_grants(GrantSet::declared().with_fs(|fs| fs.read(root.clone()))),
)
.build()
.unwrap();
let script = Script::from_source("return airsstack.hash.hash_file(arg[1])", "t")
.unwrap()
.with_args([root.join("a.txt").to_string_lossy().into_owned()]);
assert_eq!(
engine.eval_to::<String>(&script).unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hash_file_can_be_asked_for_sha1() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::write(root.join("a.txt"), "abc").unwrap();
let engine = Engine::builder()
.policy(
Policy::confined()
.with_grants(GrantSet::declared().with_fs(|fs| fs.read(root.clone()))),
)
.build()
.unwrap();
let script = Script::from_source("return airsstack.hash.hash_file(arg[1], 'sha1')", "t")
.unwrap()
.with_args([root.join("a.txt").to_string_lossy().into_owned()]);
assert_eq!(
engine.eval_to::<String>(&script).unwrap(),
"a9993e364706816aba3e25717850c26c9cd0d89d"
);
}
#[test]
fn hash_file_needs_the_read_grant_that_reading_the_file_would() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let script =
Script::from_source("return airsstack.hash.hash_file('/etc/hostname')", "t").unwrap();
let err = engine.eval_to::<String>(&script).unwrap_err();
assert!(err.to_string().contains("hash.hash_file denied"), "{err}");
}
#[test]
fn an_unknown_algorithm_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::write(root.join("a.txt"), "abc").unwrap();
let engine = Engine::builder()
.policy(
Policy::confined()
.with_grants(GrantSet::declared().with_fs(|fs| fs.read(root.clone()))),
)
.build()
.unwrap();
let script = Script::from_source("return airsstack.hash.hash_file(arg[1], 'md5')", "t")
.unwrap()
.with_args([root.join("a.txt").to_string_lossy().into_owned()]);
let err = engine.eval_to::<String>(&script).unwrap_err();
assert!(err.to_string().contains("md5"), "{err}");
}
}