use std::path::{Component, Path};
use thiserror::Error;
use crate::metrics::SecurityMetrics;
pub const ENV_DOTFILE_ALLOWLIST: &str = "DOTFILE_ALLOWLIST";
pub const DEFAULT_ALLOWED: &[&str] = &[".acl", ".meta", ".account"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Error)]
pub enum DotfileError {
#[error("dotfile path component is not on the allowlist")]
NotAllowed,
}
#[derive(Debug, Clone)]
pub struct DotfileAllowlist {
allowed: Vec<String>,
metrics: Option<SecurityMetrics>,
}
impl DotfileAllowlist {
pub fn from_env() -> Self {
match std::env::var(ENV_DOTFILE_ALLOWLIST) {
Ok(raw) => {
let parsed = parse_csv(&raw);
if parsed.is_empty() {
Self::with_defaults()
} else {
Self {
allowed: parsed,
metrics: None,
}
}
}
Err(_) => Self::with_defaults(),
}
}
pub fn with_defaults() -> Self {
Self {
allowed: DEFAULT_ALLOWED.iter().map(|s| (*s).to_string()).collect(),
metrics: None,
}
}
pub fn new(entries: Vec<String>) -> Self {
let allowed = entries
.into_iter()
.map(|e| normalise_entry(&e))
.filter(|e| !e.is_empty() && e != ".")
.collect();
Self {
allowed,
metrics: None,
}
}
pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
self.metrics = Some(metrics);
self
}
pub fn entries(&self) -> &[String] {
&self.allowed
}
pub fn is_allowed(&self, path: &Path) -> bool {
for component in path.components() {
match component {
Component::Normal(os) => {
let s = match os.to_str() {
Some(s) => s,
None => {
self.record_deny();
return false;
}
};
if s.starts_with('.') && !self.allowed.iter().any(|a| a == s) {
self.record_deny();
return false;
}
}
Component::CurDir | Component::ParentDir => {
self.record_deny();
return false;
}
Component::Prefix(_) | Component::RootDir => {
}
}
}
true
}
fn record_deny(&self) {
if let Some(m) = &self.metrics {
m.record_dotfile_deny();
}
}
}
impl Default for DotfileAllowlist {
fn default() -> Self {
Self::with_defaults()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
pub enum DotfilePathError {
#[error("dotfile segment '{segment}' not allowed in path '{path}'")]
NotAllowed { segment: String, path: String },
#[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
ParentTraversal(String),
#[error("malformed path segment in '{0}'")]
Malformed(String),
}
const STATIC_ALLOWED_DOTFILES: &[&str] = &[
".acl",
".meta",
".well-known",
".quota.json",
".acl.meta",
".account",
];
pub fn is_path_allowed(path: &str) -> Result<(), DotfilePathError> {
for segment in path.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return Err(DotfilePathError::ParentTraversal(path.to_string()));
}
if !segment.starts_with('.') {
continue;
}
if STATIC_ALLOWED_DOTFILES.contains(&segment) {
continue;
}
return Err(DotfilePathError::NotAllowed {
segment: segment.to_string(),
path: path.to_string(),
});
}
Ok(())
}
fn parse_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(normalise_entry)
.filter(|s| !s.is_empty() && s != ".")
.collect()
}
fn normalise_entry(entry: &str) -> String {
let trimmed = entry.trim().trim_start_matches('/');
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with('.') {
trimmed.to_string()
} else {
format!(".{trimmed}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn default_permits_acl_and_meta() {
let al = DotfileAllowlist::default();
assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
}
#[test]
fn default_blocks_env() {
let al = DotfileAllowlist::default();
assert!(!al.is_allowed(&PathBuf::from("/.env")));
assert!(!al.is_allowed(&PathBuf::from("/x/y/.env")));
}
#[test]
fn explicit_allowlist_accepts_listed_entries() {
let al = DotfileAllowlist::new(vec![".env".into(), ".config".into()]);
assert!(al.is_allowed(&PathBuf::from("/.env")));
assert!(al.is_allowed(&PathBuf::from("/.config")));
assert!(!al.is_allowed(&PathBuf::from("/.secret")));
}
#[test]
fn entry_without_dot_prefix_is_normalised() {
let al = DotfileAllowlist::new(vec!["notifications".into()]);
assert!(al.is_allowed(&PathBuf::from("/.notifications")));
}
#[test]
fn nested_dotfile_rejected() {
let al = DotfileAllowlist::default();
assert!(!al.is_allowed(&PathBuf::from("foo/.secret/bar")));
}
#[test]
fn path_without_dotfiles_accepted() {
let al = DotfileAllowlist::default();
assert!(al.is_allowed(&PathBuf::from("/a/b/c/file.ttl")));
}
#[test]
fn parent_dir_rejected() {
let al = DotfileAllowlist::default();
assert!(!al.is_allowed(&PathBuf::from("foo/..")));
}
#[test]
fn allows_acl_file() {
assert!(is_path_allowed("/.acl").is_ok());
assert!(is_path_allowed("/pod/.acl").is_ok());
assert!(is_path_allowed("/pod/container/.acl").is_ok());
}
#[test]
fn allows_meta_file() {
assert!(is_path_allowed("/.meta").is_ok());
assert!(is_path_allowed("/pod/.meta").is_ok());
assert!(is_path_allowed("/pod/container/.meta").is_ok());
}
#[test]
fn allows_well_known_subtree() {
assert!(is_path_allowed("/.well-known").is_ok());
assert!(is_path_allowed("/.well-known/openid-configuration").is_ok());
assert!(is_path_allowed("/.well-known/solid").is_ok());
assert!(is_path_allowed("/pod/.well-known/nested").is_ok());
}
#[test]
fn allows_quota_sidecar() {
assert!(is_path_allowed("/.quota.json").is_ok());
assert!(is_path_allowed("/pod/.quota.json").is_ok());
assert!(is_path_allowed("/pod/container/.quota.json").is_ok());
}
#[test]
fn allows_resource_specific_acl() {
assert!(is_path_allowed("/foo.acl").is_ok());
assert!(is_path_allowed("/foo.meta").is_ok());
assert!(is_path_allowed("/pod/data.ttl.acl").is_ok());
assert!(is_path_allowed("/pod/image.jpg.meta").is_ok());
}
#[test]
fn allows_normal_path() {
assert!(is_path_allowed("/foo/bar.ttl").is_ok());
assert!(is_path_allowed("/").is_ok());
assert!(is_path_allowed("/pod/data/doc.ttl").is_ok());
assert!(is_path_allowed("").is_ok());
}
#[test]
fn blocks_env_file() {
match is_path_allowed("/.env") {
Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".env"),
other => panic!("expected NotAllowed for /.env, got {other:?}"),
}
assert!(is_path_allowed("/pod/.env").is_err());
assert!(is_path_allowed("/deep/path/.env").is_err());
}
#[test]
fn blocks_git_dir() {
match is_path_allowed("/pod/.git/config") {
Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".git"),
other => panic!("expected NotAllowed for /pod/.git/config, got {other:?}"),
}
assert!(is_path_allowed("/.git").is_err());
assert!(is_path_allowed("/.git/HEAD").is_err());
assert!(is_path_allowed("/.ssh/id_rsa").is_err());
}
#[test]
fn blocks_hidden_file_anywhere() {
assert!(is_path_allowed("/foo/.hidden/bar.ttl").is_err());
assert!(is_path_allowed("/a/b/c/.secret").is_err());
assert!(is_path_allowed("/.DS_Store").is_err());
assert!(is_path_allowed("/pod/.npmrc").is_err());
}
#[test]
fn blocks_double_dot() {
match is_path_allowed("/pod/../etc/passwd") {
Err(DotfilePathError::ParentTraversal(_)) => {}
other => panic!("expected ParentTraversal for /pod/../etc/passwd, got {other:?}"),
}
assert!(matches!(
is_path_allowed(".."),
Err(DotfilePathError::ParentTraversal(_))
));
assert!(matches!(
is_path_allowed("/a/../b"),
Err(DotfilePathError::ParentTraversal(_))
));
}
#[test]
fn default_permits_account() {
let al = DotfileAllowlist::default();
assert!(
al.is_allowed(&PathBuf::from("/.account")),
".account must be on default allowlist"
);
assert!(
al.is_allowed(&PathBuf::from("/pod/.account")),
".account nested under pod must pass"
);
}
#[test]
fn allows_account_path_free_function() {
assert!(
is_path_allowed("/.account").is_ok(),
".account must pass the free-function check"
);
assert!(
is_path_allowed("/.account/login").is_ok(),
".account subtree must pass"
);
assert!(
is_path_allowed("/pod/.account/register").is_ok(),
".account under pod must pass"
);
}
#[test]
fn account_in_default_allowed_constant() {
assert!(
DEFAULT_ALLOWED.contains(&".account"),
"DEFAULT_ALLOWED must include .account"
);
}
}