use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FsGrant {
read: Vec<PathBuf>,
write: Vec<PathBuf>,
}
impl FsGrant {
#[must_use]
pub const fn none() -> Self {
Self {
read: Vec::new(),
write: Vec::new(),
}
}
#[must_use]
pub fn read(mut self, root: impl Into<PathBuf>) -> Self {
self.read.push(resolve_root(root.into()));
self
}
#[must_use]
pub fn write(mut self, root: impl Into<PathBuf>) -> Self {
self.write.push(resolve_root(root.into()));
self
}
#[must_use]
pub fn allows_read(&self, path: &Path) -> bool {
contains_any(&self.read, path)
}
#[must_use]
pub fn allows_write(&self, path: &Path) -> bool {
contains_any(&self.write, path)
}
#[must_use]
pub fn read_roots(&self) -> &[PathBuf] {
&self.read
}
#[must_use]
pub fn write_roots(&self) -> &[PathBuf] {
&self.write
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.read.is_empty() && self.write.is_empty()
}
}
fn resolve_root(root: PathBuf) -> PathBuf {
root.canonicalize()
.or_else(|_| std::path::absolute(&root))
.unwrap_or(root)
}
fn contains_any(roots: &[PathBuf], path: &Path) -> bool {
roots.iter().any(|root| path.starts_with(root))
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EnvGrant {
names: BTreeSet<String>,
}
impl EnvGrant {
#[must_use]
pub const fn none() -> Self {
Self {
names: BTreeSet::new(),
}
}
#[must_use]
pub fn read<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.names.extend(names.into_iter().map(Into::into));
self
}
#[must_use]
pub fn allows(&self, name: &str) -> bool {
self.names.contains(name)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.names.iter().map(String::as_str)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProcGrant {
executables: BTreeSet<String>,
}
impl ProcGrant {
#[must_use]
pub const fn none() -> Self {
Self {
executables: BTreeSet::new(),
}
}
#[must_use]
pub fn allow<I, S>(mut self, executables: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.executables
.extend(executables.into_iter().map(Into::into));
self
}
#[must_use]
pub fn allows(&self, program: &str) -> bool {
self.executables.contains(program)
}
pub fn executables(&self) -> impl Iterator<Item = &str> {
self.executables.iter().map(String::as_str)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.executables.is_empty()
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::{EnvGrant, FsGrant, ProcGrant};
use std::path::Path;
#[test]
fn an_empty_fs_grant_allows_nothing() {
let grant = FsGrant::none();
assert!(!grant.allows_read(Path::new("/anything")));
assert!(!grant.allows_write(Path::new("/anything")));
assert!(grant.is_empty());
}
#[test]
fn a_read_root_covers_itself_and_its_descendants() {
let grant = FsGrant::none().read("/repo");
assert!(grant.allows_read(Path::new("/repo")));
assert!(grant.allows_read(Path::new("/repo/crates/airsl")));
}
#[test]
fn a_read_root_does_not_cover_its_parent_or_a_sibling() {
let grant = FsGrant::none().read("/repo");
assert!(!grant.allows_read(Path::new("/")));
assert!(!grant.allows_read(Path::new("/elsewhere")));
}
#[test]
fn a_sibling_sharing_a_name_prefix_is_not_inside_the_root() {
let grant = FsGrant::none().read("/repo");
assert!(!grant.allows_read(Path::new("/repo-extra")));
assert!(!grant.allows_read(Path::new("/repo-extra/src")));
}
#[test]
fn read_and_write_are_independent_authorities() {
let grant = FsGrant::none().read("/repo").write("/repo/.index");
assert!(grant.allows_read(Path::new("/repo/src")));
assert!(!grant.allows_write(Path::new("/repo/src")));
assert!(grant.allows_write(Path::new("/repo/.index/a.json")));
assert!(grant.allows_read(Path::new("/repo/.index/a.json")));
}
#[test]
fn a_write_root_outside_every_read_root_is_not_readable() {
let grant = FsGrant::none().read("/repo").write("/var/state");
assert!(grant.allows_write(Path::new("/var/state/a")));
assert!(!grant.allows_read(Path::new("/var/state/a")));
}
#[test]
fn a_relative_root_resolves_against_the_working_directory() {
let here = std::env::current_dir().unwrap().canonicalize().unwrap();
let grant = FsGrant::none().read(".");
assert_eq!(grant.read_roots(), std::slice::from_ref(&here));
assert!(grant.allows_read(&here.join("Cargo.toml")));
}
#[test]
fn a_symlinked_root_resolves_to_what_it_points_at() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::create_dir(base.join("real")).unwrap();
std::os::unix::fs::symlink(base.join("real"), base.join("link")).unwrap();
let grant = FsGrant::none().read(base.join("link"));
assert_eq!(grant.read_roots(), [base.join("real")]);
assert!(grant.allows_read(&base.join("real/a.txt")));
}
#[test]
fn a_root_that_does_not_exist_yet_is_made_absolute_but_kept() {
let grant = FsGrant::none().write("/definitely/not/here");
assert_eq!(
grant.write_roots(),
[std::path::PathBuf::from("/definitely/not/here")]
);
}
#[test]
fn several_roots_are_all_honoured() {
let grant = FsGrant::none().read("/a").read("/b");
assert!(grant.allows_read(Path::new("/a/x")));
assert!(grant.allows_read(Path::new("/b/x")));
assert!(!grant.allows_read(Path::new("/c/x")));
}
#[test]
fn an_env_grant_admits_only_the_names_it_lists() {
let grant = EnvGrant::none().read(["HOME", "AIRSSTACK_HOME"]);
assert!(grant.allows("HOME"));
assert!(grant.allows("AIRSSTACK_HOME"));
assert!(!grant.allows("AWS_SECRET_ACCESS_KEY"));
}
#[test]
fn env_names_are_matched_exactly_rather_than_by_prefix() {
let grant = EnvGrant::none().read(["HOME"]);
assert!(!grant.allows("HOMEBREW_PREFIX"));
assert!(!grant.allows("home"));
}
#[test]
fn env_names_enumerate_in_sorted_order() {
let grant = EnvGrant::none().read(["ZED", "ALPHA", "MID"]);
let names: Vec<_> = grant.names().collect();
assert_eq!(names, ["ALPHA", "MID", "ZED"]);
}
#[test]
fn a_proc_grant_admits_only_the_executables_it_lists() {
let grant = ProcGrant::none().allow(["git", "tar"]);
assert!(grant.allows("git"));
assert!(grant.allows("tar"));
assert!(!grant.allows("curl"));
}
#[test]
fn a_proc_grant_does_not_admit_a_path_to_a_granted_name() {
let grant = ProcGrant::none().allow(["git"]);
assert!(!grant.allows("/usr/bin/git"));
assert!(!grant.allows("./git"));
}
#[test]
fn every_grant_reports_emptiness() {
assert!(EnvGrant::none().is_empty());
assert!(ProcGrant::none().is_empty());
assert!(!EnvGrant::none().read(["A"]).is_empty());
assert!(!ProcGrant::none().allow(["a"]).is_empty());
}
}