use std::sync::Arc;
use crate::error::{Error, Result};
use crate::modules::guard::PathGuard;
use crate::modules::{HostModule, InstallContext};
use crate::types::ModuleName;
#[derive(Debug)]
pub struct Glob {
name: ModuleName,
}
impl Glob {
#[must_use]
pub fn new() -> Self {
Self {
name: ModuleName::new("glob")
.unwrap_or_else(|_| unreachable!("`glob` is a valid module name")),
}
}
}
impl Default for Glob {
fn default() -> Self {
Self::new()
}
}
fn matcher(pattern: &str) -> Result<globset::GlobMatcher> {
globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map(|glob| glob.compile_matcher())
.map_err(|source| Error::Denied {
module: "glob",
operation: "compile",
detail: format!("`{pattern}` is not a valid glob: {source}"),
})
}
impl HostModule for Glob {
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("glob"),
reason: e.to_string(),
};
let guard = PathGuard::new(Arc::new(context.grants().clone()), "glob");
let matches = lua
.create_function(|_, (pattern, path): (mlua::LuaString, mlua::LuaString)| {
Ok(matcher(&pattern.to_str()?)?.is_match(path.to_str()?.as_ref()))
})
.map_err(fail)?;
table.set("match", matches).map_err(fail)?;
let g = guard;
let walk = lua
.create_function(
move |lua, (root, pattern): (mlua::LuaString, mlua::LuaString)| {
let base = g.read("walk", &root.to_str()?)?;
let selected = matcher(&pattern.to_str()?)?;
let mut found = Vec::new();
for entry in walkdir::WalkDir::new(&base).sort_by_file_name() {
let entry = entry.map_err(|source| Error::Io {
operation: "walk",
path: base.display().to_string(),
source: source.into(),
})?;
let Ok(relative) = entry.path().strip_prefix(&base) else {
continue;
};
if !relative.as_os_str().is_empty() && selected.is_match(relative) {
found.push(relative.to_string_lossy().into_owned());
}
}
lua.create_sequence_from(found)
},
)
.map_err(fail)?;
table.set("walk", walk).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::{Glob, matcher};
use crate::{Engine, GrantSet, HostModule as _, Policy, Script};
use std::path::Path;
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_glob() {
assert_eq!(Glob::new().name().as_str(), "glob");
}
#[test]
fn a_double_star_matches_zero_segments_as_well_as_many() {
let m = matcher("**/Cargo.toml").unwrap();
assert!(m.is_match(Path::new("Cargo.toml")), "zero segments");
assert!(
m.is_match(Path::new("crates/airsl/Cargo.toml")),
"many segments"
);
}
#[test]
fn a_star_stops_at_a_directory_boundary() {
let m = matcher("*.rs").unwrap();
assert!(
m.is_match(Path::new("main.rs")),
"a root-level file matches"
);
assert!(
!m.is_match(Path::new("src/main.rs")),
"`*` must not cross a separator"
);
let scoped = matcher("src/*.rs").unwrap();
assert!(scoped.is_match(Path::new("src/main.rs")));
assert!(
!scoped.is_match(Path::new("src/a/b.rs")),
"one segment, not a subtree"
);
}
#[test]
fn a_question_mark_stops_at_a_directory_boundary_too() {
let m = matcher("a?c").unwrap();
assert!(m.is_match(Path::new("abc")));
assert!(
!m.is_match(Path::new("a/c")),
"`?` must not match a separator"
);
}
#[test]
fn a_double_star_stays_recursive_under_the_strict_reading() {
let m = matcher("**/*.rs").unwrap();
assert!(m.is_match(Path::new("main.rs")), "zero segments");
assert!(m.is_match(Path::new("a/b/c/main.rs")), "many segments");
let bare = matcher("**").unwrap();
assert!(bare.is_match(Path::new("a/b/c")));
}
#[test]
fn match_answers_from_lua_too() {
assert_eq!(
eval("return tostring(airsstack.glob.match('**/Cargo.toml', 'Cargo.toml'))"),
"true"
);
assert_eq!(
eval("return tostring(airsstack.glob.match('*.lua', 'a.rs'))"),
"false"
);
}
#[test]
fn a_character_class_works() {
assert_eq!(
eval("return tostring(airsstack.glob.match('*.{lua,rs}', 'a.rs'))"),
"true"
);
}
#[test]
fn an_invalid_pattern_raises_a_catchable_error() {
assert_eq!(
eval("return tostring(pcall(airsstack.glob.match, '[unclosed', 'x'))"),
"false"
);
}
#[test]
fn walk_returns_sorted_matches_relative_to_the_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("Cargo.toml"), "").unwrap();
std::fs::write(root.join("sub/Cargo.toml"), "").unwrap();
std::fs::write(root.join("sub/other.txt"), "").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 table.concat(airsstack.glob.walk(arg[1], '**/Cargo.toml'), ',')",
"t",
)
.unwrap()
.with_args([root.to_string_lossy().into_owned()]);
assert_eq!(
engine.eval_to::<String>(&script).unwrap(),
"Cargo.toml,sub/Cargo.toml"
);
}
#[test]
fn walk_needs_the_read_grant_that_listing_the_directory_would() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let script = Script::from_source("return airsstack.glob.walk('/etc', '*')", "t").unwrap();
let err = engine.eval_to::<mlua::Value>(&script).unwrap_err();
assert!(err.to_string().contains("glob.walk denied"), "{err}");
}
#[test]
fn match_needs_no_grant_at_all() {
assert_eq!(eval("return type(airsstack.glob.match)"), "function");
}
}