use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use apcore::{ErrorCode, ModuleError};
const BASELINE_SYSTEM_PATHS: &[&str] = &[
"/bin",
"/boot",
"/dev",
"/etc",
"/lib",
"/lib32",
"/lib64",
"/proc",
"/run",
"/sbin",
"/sys",
"/usr",
"/var",
"/System",
"/Library",
"/Applications",
"/private/etc",
"/private/var",
];
const BASELINE_CREDENTIAL_HOME_SUBPATHS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".kube",
".docker",
".apexe",
".config/gh",
".config/gcloud",
".git-credentials",
".netrc",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AccessMode {
ReadOnly,
Write,
}
impl AccessMode {
pub fn from_readonly(readonly: bool) -> Self {
if readonly {
Self::ReadOnly
} else {
Self::Write
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GuardConfig<'a> {
pub denied: &'a [PathBuf],
pub allowed: &'a [PathBuf],
}
fn baseline_exemptions() -> Vec<PathBuf> {
vec![std::env::temp_dir()]
}
fn accept_exemption(candidate: &Path, system: &[PathBuf]) -> bool {
!system.iter().any(|entry| entry.starts_with(candidate))
}
fn deepest_containing<'a>(
target: &Path,
candidates: impl Iterator<Item = &'a PathBuf>,
) -> Option<&'a PathBuf> {
candidates
.filter(|entry| target.starts_with(entry))
.max_by_key(|entry| specificity(entry))
}
fn specificity(path: &Path) -> usize {
path.components().count()
}
static ACTIVE: OnceLock<PathGuard> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct PathGuard {
root: PathBuf,
system: Vec<PathBuf>,
credential: Vec<PathBuf>,
credential_baseline: Vec<PathBuf>,
credential_configured: Vec<PathBuf>,
exempt: Vec<PathBuf>,
allowed: Vec<PathBuf>,
}
impl PathGuard {
pub fn new(root: PathBuf, config: GuardConfig<'_>) -> Self {
let system: Vec<PathBuf> = BASELINE_SYSTEM_PATHS
.iter()
.map(|entry| resolve(Path::new(entry), &root))
.collect();
let mut credential: Vec<PathBuf> = Vec::new();
if let Some(home) = dirs::home_dir() {
for entry in BASELINE_CREDENTIAL_HOME_SUBPATHS {
credential.push(resolve(&home.join(entry), &root));
}
} else {
tracing::warn!(
"No home directory: the credential directories in the path-guard \
baseline (~/.ssh, ~/.aws, …) are not protected in this process"
);
}
let exempt: Vec<PathBuf> = baseline_exemptions()
.iter()
.map(|candidate| resolve(candidate, &root))
.filter(|candidate| {
let accepted = accept_exemption(candidate, &system);
if !accepted {
tracing::warn!(
candidate = %candidate.display(),
"Discarding a temp-directory exemption that would expose a \
system location; check TMPDIR"
);
}
accepted
})
.collect();
let credential_baseline = credential.clone();
let credential_configured: Vec<PathBuf> = config
.denied
.iter()
.map(|entry| resolve(entry, &root))
.collect();
credential.extend(credential_configured.iter().cloned());
credential.sort();
credential.dedup();
let configured: Vec<PathBuf> = config
.allowed
.iter()
.map(|entry| resolve(entry, &root))
.collect();
warn_about_risky_carve_outs(&configured, &system, &credential);
let mut allowed = configured;
allowed.sort();
allowed.dedup();
Self {
root,
system,
credential,
credential_baseline,
credential_configured,
exempt,
allowed,
}
}
pub fn from_env(config: GuardConfig<'_>) -> Self {
let root = std::env::current_dir().unwrap_or_else(|error| {
tracing::warn!(
%error,
"Cannot read the working directory; relative paths will resolve \
against /"
);
PathBuf::from("/")
});
Self::new(root, config)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn system_baseline(&self) -> &[PathBuf] {
&self.system
}
pub fn credential_baseline(&self) -> &[PathBuf] {
&self.credential_baseline
}
pub fn credential_configured(&self) -> &[PathBuf] {
&self.credential_configured
}
pub fn allowed_paths(&self) -> &[PathBuf] {
&self.allowed
}
pub fn exempt_paths(&self) -> &[PathBuf] {
&self.exempt
}
#[allow(clippy::result_large_err)] pub fn check(&self, subject: &str, value: &str, mode: AccessMode) -> Result<(), ModuleError> {
let resolved = resolve(Path::new(value), &self.root);
let Some(denied) = self.denial_reason(&resolved, mode) else {
return Ok(());
};
tracing::warn!(
subject = %subject,
requested = %value,
resolved = %resolved.display(),
denied = %denied.display(),
mode = ?mode,
"Path guard refused a protected location"
);
Err(protected_path_error(subject, value, &resolved, denied))
}
fn denial_reason(&self, target: &Path, mode: AccessMode) -> Option<&Path> {
if mode == AccessMode::Write {
let carved_out = deepest_containing(target, self.allowed.iter()).is_some();
if !carved_out {
if let Some(descendant) = self
.applicable(mode)
.find(|denied| denied.starts_with(target))
{
return Some(descendant.as_path());
}
}
}
let denied = deepest_containing(target, self.applicable(mode))?;
let depth = specificity(denied);
let configured_wins = deepest_containing(target, self.allowed.iter())
.is_some_and(|allowed| specificity(allowed) >= depth);
let derived_wins = deepest_containing(target, self.exempt.iter())
.is_some_and(|exempt| specificity(exempt) > depth);
if configured_wins || derived_wins {
return None;
}
Some(denied.as_path())
}
fn applicable(&self, mode: AccessMode) -> impl Iterator<Item = &PathBuf> {
let system = match mode {
AccessMode::Write => Some(self.system.iter()),
AccessMode::ReadOnly => None,
};
self.credential.iter().chain(system.into_iter().flatten())
}
}
pub fn install(guard: PathGuard) -> bool {
let installed = ACTIVE.set(guard).is_ok();
if !installed {
tracing::warn!("Path guard already installed; ignoring the later configuration");
}
installed
}
pub fn active() -> &'static PathGuard {
ACTIVE.get_or_init(|| PathGuard::from_env(GuardConfig::default()))
}
fn warn_about_risky_carve_outs(configured: &[PathBuf], system: &[PathBuf], credential: &[PathBuf]) {
for entry in configured {
if let Some(opened) = credential.iter().find(|c| c.starts_with(entry)) {
tracing::warn!(
allowed = %entry.display(),
opens = %opened.display(),
"Configured allowed_paths entry exposes a credential directory. \
Reading a private key leaves no trace; confirm this is intended."
);
} else if let Some(opened) = system.iter().find(|s| s.starts_with(entry)) {
tracing::warn!(
allowed = %entry.display(),
opens = %opened.display(),
"Configured allowed_paths entry opens an entire system location, \
not a subtree of one. Narrow it if a subdirectory would do."
);
} else {
tracing::info!(
allowed = %entry.display(),
"Path guard carve-out configured"
);
}
}
}
fn resolve(path: &Path, root: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
resolve_existing_prefix(&joined)
}
fn resolve_existing_prefix(path: &Path) -> PathBuf {
let components: Vec<Component<'_>> = path.components().collect();
for split in (1..=components.len()).rev() {
let prefix: PathBuf = components[..split].iter().collect();
if let Ok(real) = prefix.canonicalize() {
return fold_lexically(real, &components[split..]);
}
}
fold_lexically(PathBuf::from("/"), &components)
}
fn fold_lexically(base: PathBuf, rest: &[Component<'_>]) -> PathBuf {
let mut out = base;
for component in rest {
match component {
Component::Normal(name) => out.push(name),
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
Component::RootDir => out = PathBuf::from("/"),
Component::Prefix(prefix) => out = PathBuf::from(prefix.as_os_str()),
}
}
out
}
fn protected_path_error(
subject: &str,
requested: &str,
resolved: &Path,
denied: &Path,
) -> ModuleError {
let mut details: HashMap<String, serde_json::Value> = HashMap::new();
details.insert("requested_path".to_string(), serde_json::json!(requested));
details.insert(
"resolved_path".to_string(),
serde_json::json!(resolved.display().to_string()),
);
details.insert(
"protected_path".to_string(),
serde_json::json!(denied.display().to_string()),
);
ModuleError::new(
ErrorCode::ACLDenied,
format!(
"{subject} resolves to '{}', which is protected by '{}'",
resolved.display(),
denied.display()
),
)
.with_details(details)
.with_ai_guidance(format!(
"'{requested}' resolves to '{}', inside or above the protected location \
'{}'. This is a compiled-in boundary that no configuration removes. \
Choose a path outside the protected system and credential directories.",
resolved.display(),
denied.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
use AccessMode::{ReadOnly, Write};
pub(super) fn guard_at(root: &Path) -> PathGuard {
PathGuard::new(root.to_path_buf(), GuardConfig::default())
}
fn guard_denying(root: &Path, denied: &[PathBuf]) -> PathGuard {
PathGuard::new(
root.to_path_buf(),
GuardConfig {
denied,
..Default::default()
},
)
}
fn guard_allowing(root: &Path, allowed: &[PathBuf]) -> PathGuard {
PathGuard::new(
root.to_path_buf(),
GuardConfig {
allowed,
..Default::default()
},
)
}
fn home_path(sub: &str) -> String {
dirs::home_dir()
.expect("home directory")
.join(sub)
.to_string_lossy()
.into_owned()
}
#[test]
fn test_a_reader_may_name_a_system_directory() {
let guard = guard_at(Path::new("/"));
assert!(guard
.check("Parameter 'file'", "/etc/hosts", ReadOnly)
.is_ok());
assert!(guard
.check("Parameter 'file'", "/usr/bin", ReadOnly)
.is_ok());
assert!(guard.check("Parameter 'file'", "/System", ReadOnly).is_ok());
}
#[test]
fn test_a_writer_may_not_name_a_system_directory() {
let guard = guard_at(Path::new("/"));
for probe in ["/etc/hosts", "/usr/bin/env", "/System/Library"] {
let error = guard.check("Parameter 'file'", probe, Write).unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied, "{probe} must be refused");
}
}
#[test]
fn test_a_reader_may_not_name_a_credential_directory() {
let guard = guard_at(Path::new("/"));
for sub in BASELINE_CREDENTIAL_HOME_SUBPATHS {
let probe = home_path(sub);
let error = guard
.check("Parameter 'file'", &probe, ReadOnly)
.unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied, "{probe} must be refused");
}
}
#[test]
fn test_config_stays_readable_while_its_credential_stores_do_not() {
let guard = guard_at(Path::new("/"));
for readable in [".config", ".config/nvim", ".config/starship.toml"] {
let probe = home_path(readable);
assert!(
guard.check("Parameter 'file'", &probe, ReadOnly).is_ok(),
"{probe} is ordinary application settings and must stay legible"
);
}
for refused in [".config/gh", ".config/gh/hosts.yml", ".config/gcloud"] {
let probe = home_path(refused);
let error = guard
.check("Parameter 'file'", &probe, ReadOnly)
.unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied, "{probe} must be refused");
}
}
#[test]
fn test_plaintext_credential_files_at_the_home_root_are_refused() {
let guard = guard_at(Path::new("/"));
for refused in [".git-credentials", ".netrc"] {
let probe = home_path(refused);
let error = guard
.check("Parameter 'file'", &probe, ReadOnly)
.unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied, "{probe} must be refused");
}
}
#[test]
fn test_a_credential_file_is_refused_to_both_modes() {
let guard = guard_at(Path::new("/"));
let key = home_path(".ssh/id_rsa");
assert!(guard.check("Parameter 'file'", &key, ReadOnly).is_err());
assert!(guard.check("Parameter 'file'", &key, Write).is_err());
}
#[test]
fn test_a_reader_may_list_a_directory_that_merely_contains_credentials() {
let guard = guard_at(Path::new("/"));
let home = home_path("");
assert!(guard.check("Parameter 'file'", &home, ReadOnly).is_ok());
assert!(guard.check("Parameter 'file'", "/", ReadOnly).is_ok());
}
#[test]
fn test_a_writer_may_not_target_a_directory_that_contains_credentials() {
let guard = guard_at(Path::new("/"));
assert!(guard
.check("Parameter 'file'", &home_path(""), Write)
.is_err());
assert!(guard.check("Parameter 'file'", "/", Write).is_err());
}
#[test]
fn test_check_resolves_a_relative_path_against_the_root_before_judging() {
let system = guard_at(Path::new("/usr"));
assert!(
system.check("Parameter 'file'", "share", Write).is_err(),
"'share' under /usr is /usr/share and must be refused to a writer"
);
let workspace = tempfile::tempdir().expect("temp dir");
let scratch = guard_at(workspace.path());
assert!(
scratch.check("Parameter 'file'", "share", Write).is_ok(),
"the same relative value is ordinary work under a scratch root"
);
}
#[test]
fn test_check_refuses_a_relative_path_that_climbs_out_with_parent_dirs() {
let guard = guard_at(Path::new("/usr/local/share"));
let error = guard
.check("Parameter 'file'", "../../../etc/passwd", Write)
.unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied);
let resolved = error.details.get("resolved_path").expect("resolved_path");
assert!(
resolved
.as_str()
.is_some_and(|p| p.ends_with("/etc/passwd")),
"the climb must resolve to the real target: {resolved:?}"
);
}
#[test]
fn test_check_follows_a_symlink_that_points_into_a_system_directory() {
let workspace = tempfile::tempdir().expect("temp dir");
let link = workspace.path().join("innocent");
std::os::unix::fs::symlink("/etc", &link).expect("symlink");
let guard = guard_at(workspace.path());
let error = guard
.check("Parameter 'file'", &link.to_string_lossy(), Write)
.unwrap_err();
assert_eq!(error.code, ErrorCode::ACLDenied);
assert!(
error.message.contains("etc"),
"a symlink to /etc is a request to touch /etc: {}",
error.message
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_check_sees_through_a_macos_firmlink_into_the_system_volume() {
let guard = guard_at(Path::new("/"));
assert!(
guard
.check("Parameter 'file'", "/home/someone/work.txt", Write)
.is_err(),
"/home resolves into the system volume on macOS and must be refused"
);
assert!(
guard
.check("Parameter 'file'", "/Users/someone/work.txt", Write)
.is_ok(),
"/Users is not remapped and must stay usable"
);
}
#[test]
fn test_check_compares_whole_components_not_string_prefixes() {
let guard = guard_at(Path::new("/"));
assert!(guard
.check("Parameter 'file'", "/etcetera/notes", Write)
.is_ok());
}
#[test]
fn test_check_judges_a_path_that_does_not_exist_yet() {
let guard = guard_at(Path::new("/"));
assert!(guard
.check("Parameter 'target'", "/etc/nonexistent/deeper/file", Write)
.is_err());
}
#[test]
fn test_check_allows_an_ordinary_workspace_path() {
let workspace = tempfile::tempdir().expect("temp dir");
let guard = guard_at(workspace.path());
assert!(guard
.check("Parameter 'file'", "src/main.rs", Write)
.is_ok());
assert!(guard
.check(
"Parameter 'file'",
&workspace.path().join("out.txt").to_string_lossy(),
Write
)
.is_ok());
}
#[test]
fn test_resolve_joins_normalizes_and_follows_in_that_order() {
let workspace = tempfile::tempdir().expect("temp dir");
let real = workspace.path().canonicalize().expect("canonical temp dir");
std::fs::create_dir(real.join("deep")).expect("mkdir");
assert_eq!(
resolve(Path::new("deep/../deep/x"), &real),
real.join("deep/x")
);
assert_eq!(resolve(Path::new("./deep/x"), &real), real.join("deep/x"));
assert_eq!(
resolve(&real.join("deep/x"), Path::new("/unused")),
real.join("deep/x")
);
}
#[test]
fn test_new_denies_the_additional_paths_an_operator_configured() {
let workspace = tempfile::tempdir().expect("temp dir");
let protected = workspace.path().join("golden");
std::fs::create_dir(&protected).expect("mkdir");
let guard = guard_denying(workspace.path(), std::slice::from_ref(&protected));
assert!(
guard
.check("Parameter 'file'", "golden/data.db", Write)
.is_err(),
"a configured entry must be enforced like a baseline one"
);
assert!(guard
.check("Parameter 'file'", "other/data.db", Write)
.is_ok());
}
#[test]
fn test_a_configured_path_binds_readers_too() {
let workspace = tempfile::tempdir().expect("temp dir");
let protected = workspace.path().join("golden");
std::fs::create_dir(&protected).expect("mkdir");
let guard = guard_denying(workspace.path(), std::slice::from_ref(&protected));
assert!(guard
.check("Parameter 'file'", "golden/data.db", ReadOnly)
.is_err());
}
#[test]
fn test_new_keeps_the_baseline_when_the_configuration_names_something_else() {
let workspace = tempfile::tempdir().expect("temp dir");
let guard = guard_denying(workspace.path(), &[workspace.path().join("golden")]);
assert!(guard
.check("Parameter 'file'", "/etc/passwd", Write)
.is_err());
assert!(guard
.check("Parameter 'file'", &home_path(".ssh/id_rsa"), ReadOnly)
.is_err());
}
#[test]
fn test_a_configured_carve_out_reopens_a_subtree_of_a_system_path() {
let guard = guard_allowing(Path::new("/"), &[PathBuf::from("/etc/nginx/conf.d")]);
assert!(
guard
.check("Parameter 'file'", "/etc/nginx/conf.d/site.conf", Write)
.is_ok(),
"the carve-out must make its own subtree writable"
);
assert!(guard
.check("Parameter 'file'", "/etc/nginx/nginx.conf", Write)
.is_err());
assert!(guard
.check("Parameter 'file'", "/etc/passwd", Write)
.is_err());
}
#[test]
fn test_a_carve_out_is_empty_unless_configured() {
let guard = guard_at(Path::new("/"));
assert!(guard
.check("Parameter 'file'", "/etc/nginx/conf.d/site.conf", Write)
.is_err());
}
#[test]
fn test_a_carve_out_is_honoured_even_when_it_is_unwise() {
let guard = guard_allowing(Path::new("/"), &[PathBuf::from("/etc")]);
assert!(guard
.check("Parameter 'file'", "/etc/passwd", Write)
.is_ok());
}
#[test]
fn test_a_carve_out_loses_to_a_more_specific_denial() {
let guard = PathGuard::new(
PathBuf::from("/"),
GuardConfig {
denied: &[PathBuf::from("/etc/nginx/conf.d/secrets")],
allowed: &[PathBuf::from("/etc/nginx/conf.d")],
},
);
assert!(guard
.check("Parameter 'file'", "/etc/nginx/conf.d/site.conf", Write)
.is_ok());
assert!(guard
.check("Parameter 'file'", "/etc/nginx/conf.d/secrets/key", Write)
.is_err());
}
#[test]
fn test_a_configured_carve_out_may_reopen_a_credential_path() {
let key = dirs::home_dir().expect("home").join(".ssh");
let guard = guard_allowing(Path::new("/"), std::slice::from_ref(&key));
assert!(guard
.check(
"Parameter 'file'",
&key.join("id_rsa").to_string_lossy(),
ReadOnly
)
.is_ok());
}
#[test]
fn test_the_derived_exemption_still_loses_a_tie_to_a_credential_path() {
let guard = PathGuard {
root: PathBuf::from("/"),
system: Vec::new(),
credential: vec![PathBuf::from("/home/u/.ssh")],
exempt: vec![PathBuf::from("/home/u/.ssh")],
credential_baseline: Vec::new(),
credential_configured: Vec::new(),
allowed: Vec::new(),
};
assert!(guard
.denial_reason(Path::new("/home/u/.ssh/id_rsa"), ReadOnly)
.is_some());
}
#[test]
fn test_a_writer_may_recurse_into_a_directory_the_operator_carved_out() {
let guard = PathGuard::new(
PathBuf::from("/"),
GuardConfig {
denied: &[PathBuf::from("/etc/nginx/conf.d/secrets")],
allowed: &[PathBuf::from("/etc/nginx/conf.d")],
},
);
assert!(guard
.check("Parameter 'file'", "/etc/nginx/conf.d", Write)
.is_ok());
assert!(guard
.check("Parameter 'file'", "/etc/nginx/conf.d/secrets/key", Write)
.is_err());
}
#[test]
fn test_a_carve_out_does_not_disturb_the_temp_directory_exemption() {
let tmp = std::env::temp_dir();
let guard = guard_allowing(Path::new("/"), &[PathBuf::from("/etc/nginx/conf.d")]);
assert!(guard
.check(
"Parameter 'file'",
&tmp.join("build.log").to_string_lossy(),
Write
)
.is_ok());
}
#[test]
fn test_new_exempts_the_temp_directory_from_the_var_baseline() {
let workspace = tempfile::tempdir().expect("temp dir");
let guard = guard_at(workspace.path());
let scratch = std::env::temp_dir().join("apexe-guard-probe.txt");
assert!(
guard
.check("Parameter 'file'", &scratch.to_string_lossy(), Write)
.is_ok(),
"the temp directory must stay writable: {}",
scratch.display()
);
}
#[test]
fn test_accept_exemption_discards_a_candidate_that_would_expose_a_system_path() {
let system = vec![PathBuf::from("/etc"), PathBuf::from("/var")];
assert!(!accept_exemption(Path::new("/etc"), &system));
assert!(!accept_exemption(Path::new("/"), &system));
assert!(accept_exemption(Path::new("/var/folders/ab/cd/T"), &system));
}
#[test]
fn test_a_home_under_the_temp_directory_does_not_void_the_carve_out() {
let tmp = std::env::temp_dir();
let system = vec![PathBuf::from("/var"), PathBuf::from("/etc")];
assert!(
accept_exemption(&tmp, &system),
"the carve-out must survive a credential path nested inside it"
);
let guard = PathGuard {
root: PathBuf::from("/"),
system: vec![PathBuf::from("/var")],
credential: vec![tmp.join("fakehome/.ssh")],
exempt: vec![tmp.clone()],
credential_baseline: Vec::new(),
credential_configured: Vec::new(),
allowed: Vec::new(),
};
assert!(
guard
.denial_reason(&tmp.join("fakehome/.ssh/id_rsa"), ReadOnly)
.is_some(),
"specificity must still protect a credential path inside the carve-out"
);
assert!(
guard
.denial_reason(&tmp.join("build/artifact.tar"), Write)
.is_none(),
"an ordinary temp path must stay writable"
);
}
#[test]
fn test_a_carve_out_that_exactly_overlaps_a_credential_path_still_refuses() {
let guard = PathGuard {
root: PathBuf::from("/"),
system: Vec::new(),
credential: vec![PathBuf::from("/home/u/.ssh")],
exempt: vec![PathBuf::from("/home/u/.ssh")],
credential_baseline: Vec::new(),
credential_configured: Vec::new(),
allowed: Vec::new(),
};
assert!(guard
.denial_reason(Path::new("/home/u/.ssh/id_rsa"), ReadOnly)
.is_some());
}
#[test]
fn test_denial_reason_lets_the_more_specific_rule_decide() {
let guard = PathGuard {
root: PathBuf::from("/"),
system: vec![PathBuf::from("/var")],
credential: vec![PathBuf::from("/var/scratch/golden")],
exempt: vec![PathBuf::from("/var/scratch")],
credential_baseline: Vec::new(),
credential_configured: Vec::new(),
allowed: Vec::new(),
};
assert!(guard
.denial_reason(Path::new("/var/log/system.log"), Write)
.is_some());
assert!(guard
.denial_reason(Path::new("/var/scratch/work"), Write)
.is_none());
assert!(guard
.denial_reason(Path::new("/var/scratch/golden/data.db"), Write)
.is_some());
assert!(guard
.denial_reason(Path::new("/var/scratch/golden/data.db"), ReadOnly)
.is_some());
assert!(guard
.denial_reason(Path::new("/var/log/system.log"), ReadOnly)
.is_none());
}
#[test]
fn test_denial_reason_refuses_on_a_tie() {
let guard = PathGuard {
root: PathBuf::from("/"),
system: vec![PathBuf::from("/data")],
credential: Vec::new(),
exempt: vec![PathBuf::from("/data")],
credential_baseline: Vec::new(),
credential_configured: Vec::new(),
allowed: Vec::new(),
};
assert!(guard
.denial_reason(Path::new("/data/file"), Write)
.is_some());
}
fn read_doc(name: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("docs")
.join(name);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
#[test]
fn test_every_baseline_entry_is_documented() {
let manual = read_doc("user-manual.md");
let threat_model = read_doc("threat-model.md");
for entry in BASELINE_SYSTEM_PATHS {
assert!(
manual.contains(entry),
"user-manual.md does not list the system baseline entry {entry}"
);
assert!(
threat_model.contains(entry),
"threat-model.md does not list the system baseline entry {entry}"
);
}
for entry in BASELINE_CREDENTIAL_HOME_SUBPATHS {
let documented = format!("~/{entry}");
assert!(
manual.contains(&documented),
"user-manual.md does not list the credential baseline entry {documented}"
);
assert!(
threat_model.contains(&documented),
"threat-model.md does not list the credential baseline entry {documented}"
);
}
}
#[test]
fn test_root_is_not_a_baseline_entry() {
assert!(
!BASELINE_SYSTEM_PATHS.contains(&"/"),
"`/` as a baseline entry makes the containment test match everything"
);
let guard = guard_at(Path::new("/"));
assert!(
guard
.check("Parameter 'file'", "/srv/data/work.txt", Write)
.is_ok(),
"an ordinary absolute path outside the baseline must pass"
);
}
#[test]
fn test_access_mode_defaults_to_write_for_an_unclassified_module() {
assert_eq!(AccessMode::from_readonly(false), Write);
assert_eq!(AccessMode::from_readonly(true), ReadOnly);
}
#[test]
fn test_error_names_the_requested_and_the_resolved_path() {
let guard = guard_at(Path::new("/usr/share"));
let error = guard
.check("Parameter 'file'", "../../etc/hosts", Write)
.unwrap_err();
assert_eq!(
error.details.get("requested_path"),
Some(&serde_json::json!("../../etc/hosts"))
);
assert!(error.details.contains_key("resolved_path"));
assert!(error.details.contains_key("protected_path"));
}
#[test]
fn test_credential_baseline_and_configured_are_reported_separately() {
let extra = PathBuf::from("/srv/production-data");
let guard = guard_denying(Path::new("/"), std::slice::from_ref(&extra));
assert_eq!(
guard.credential_baseline().len(),
BASELINE_CREDENTIAL_HOME_SUBPATHS.len(),
"the baseline list must not include the operator's addition"
);
assert_eq!(
guard.credential_configured(),
&[resolve(&extra, Path::new("/"))]
);
assert!(guard
.credential_baseline()
.iter()
.chain(guard.credential_configured())
.all(|p| p != &PathBuf::new()),);
}
#[test]
fn test_allowed_and_exempt_paths_are_exposed() {
let carve_out = PathBuf::from("/etc/nginx/conf.d");
let guard = guard_allowing(Path::new("/"), std::slice::from_ref(&carve_out));
assert_eq!(
guard.allowed_paths(),
&[resolve(&carve_out, Path::new("/"))]
);
assert_eq!(
guard.exempt_paths(),
&[resolve(&std::env::temp_dir(), Path::new("/"))]
);
}
#[test]
fn test_system_baseline_matches_the_compiled_in_list() {
let guard = guard_at(Path::new("/"));
assert_eq!(guard.system_baseline().len(), BASELINE_SYSTEM_PATHS.len());
}
}