use std::path::{Component, Path, PathBuf};
use super::policy::Matcher;
pub fn normalize_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(p) => result.push(p.as_os_str()),
Component::RootDir => result.push(Component::RootDir.as_os_str()),
Component::CurDir => {} Component::ParentDir => {
if result.parent().is_some() && result != Path::new("/") {
result.pop();
} else if !result.is_absolute() {
result.push("..");
}
}
Component::Normal(name) => result.push(name),
}
}
if result.as_os_str().is_empty() {
PathBuf::from(".")
} else {
result
}
}
#[derive(Debug, Clone)]
pub struct PrefixMatcher {
prefix: PathBuf,
}
impl PrefixMatcher {
pub fn new(prefix: impl Into<PathBuf>) -> Self {
Self {
prefix: prefix.into(),
}
}
pub fn prefix(&self) -> &Path {
&self.prefix
}
}
impl Matcher for PrefixMatcher {
fn matches(&self, path: &Path) -> bool {
path.starts_with(&self.prefix)
}
fn clone_box(&self) -> Box<dyn Matcher> {
Box::new(self.clone())
}
fn pattern_description(&self) -> String {
format!("prefix:{}", self.prefix.display())
}
}
#[derive(Debug, Clone)]
pub struct ExactMatcher {
path: PathBuf,
}
impl ExactMatcher {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Matcher for ExactMatcher {
fn matches(&self, path: &Path) -> bool {
path == self.path
}
fn clone_box(&self) -> Box<dyn Matcher> {
Box::new(self.clone())
}
fn pattern_description(&self) -> String {
format!("exact:{}", self.path.display())
}
}
#[derive(Debug, Clone)]
pub struct ComponentMatcher {
component: String,
}
impl ComponentMatcher {
pub fn new(component: impl Into<String>) -> Self {
Self {
component: component.into(),
}
}
pub fn component(&self) -> &str {
&self.component
}
}
impl Matcher for ComponentMatcher {
fn matches(&self, path: &Path) -> bool {
path.components().any(|c| {
c.as_os_str()
.to_str()
.map(|s| s == self.component)
.unwrap_or(false)
})
}
fn clone_box(&self) -> Box<dyn Matcher> {
Box::new(self.clone())
}
fn pattern_description(&self) -> String {
format!("component:{}", self.component)
}
}
#[derive(Debug, Clone)]
pub struct ExtensionMatcher {
extension: String,
}
impl ExtensionMatcher {
pub fn new(extension: impl Into<String>) -> Self {
Self {
extension: extension.into().to_lowercase(),
}
}
pub fn extension(&self) -> &str {
&self.extension
}
}
impl Matcher for ExtensionMatcher {
fn matches(&self, path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase() == self.extension)
.unwrap_or(false)
}
fn clone_box(&self) -> Box<dyn Matcher> {
Box::new(self.clone())
}
fn pattern_description(&self) -> String {
format!("extension:*.{}", self.extension)
}
}
#[derive(Debug, Clone)]
pub struct MultiExtensionMatcher {
extensions: Vec<String>,
case_sensitive: bool,
}
impl MultiExtensionMatcher {
pub fn new<S: Into<String>>(extensions: Vec<S>, case_sensitive: bool) -> Self {
let extensions: Vec<String> = if case_sensitive {
extensions.into_iter().map(|e| e.into()).collect()
} else {
extensions
.into_iter()
.map(|e| e.into().to_lowercase())
.collect()
};
Self {
extensions,
case_sensitive,
}
}
pub fn case_insensitive<S: Into<String>>(extensions: Vec<S>) -> Self {
Self::new(extensions, false)
}
pub fn extensions(&self) -> &[String] {
&self.extensions
}
pub fn is_case_sensitive(&self) -> bool {
self.case_sensitive
}
}
impl Matcher for MultiExtensionMatcher {
fn matches(&self, path: &Path) -> bool {
if let Some(ext) = path.extension() {
if let Some(ext_str) = ext.to_str() {
let ext_cmp = if self.case_sensitive {
ext_str.to_string()
} else {
ext_str.to_lowercase()
};
return self.extensions.contains(&ext_cmp);
}
}
false
}
fn clone_box(&self) -> Box<dyn Matcher> {
Box::new(self.clone())
}
fn pattern_description(&self) -> String {
format!(
"extensions:[{}]{}",
self.extensions.join(", "),
if self.case_sensitive {
" (case-sensitive)"
} else {
""
}
)
}
}
#[derive(Debug, Clone)]
pub struct SizeMatcher {
min_size: Option<u64>,
max_size: Option<u64>,
}
impl SizeMatcher {
pub fn new(min: Option<u64>, max: Option<u64>) -> Self {
Self {
min_size: min,
max_size: max,
}
}
pub fn min(min_bytes: u64) -> Self {
Self::new(Some(min_bytes), None)
}
pub fn max(max_bytes: u64) -> Self {
Self::new(None, Some(max_bytes))
}
pub fn between(min_bytes: u64, max_bytes: u64) -> Self {
Self::new(Some(min_bytes), Some(max_bytes))
}
pub fn matches_size(&self, size: u64) -> bool {
if let Some(min) = self.min_size {
if size < min {
return false;
}
}
if let Some(max) = self.max_size {
if size > max {
return false;
}
}
true
}
pub fn min_size(&self) -> Option<u64> {
self.min_size
}
pub fn max_size(&self) -> Option<u64> {
self.max_size
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prefix_matcher_basic() {
let matcher = PrefixMatcher::new("/etc");
assert!(matcher.matches(Path::new("/etc/passwd")));
assert!(matcher.matches(Path::new("/etc/ssh/sshd_config")));
assert!(matcher.matches(Path::new("/etc")));
assert!(!matcher.matches(Path::new("/home/user")));
assert!(!matcher.matches(Path::new("/etcetera/file"))); }
#[test]
fn test_prefix_matcher_with_trailing_slash() {
let matcher = PrefixMatcher::new("/etc/");
assert!(matcher.matches(Path::new("/etc/passwd")));
assert!(matcher.matches(Path::new("/etc/")));
}
#[test]
fn test_prefix_matcher_clone() {
let matcher = PrefixMatcher::new("/tmp");
let cloned = matcher.clone_box();
assert!(cloned.matches(Path::new("/tmp/file")));
assert_eq!(cloned.pattern_description(), "prefix:/tmp");
}
#[test]
fn test_exact_matcher_basic() {
let matcher = ExactMatcher::new("/etc/shadow");
assert!(matcher.matches(Path::new("/etc/shadow")));
assert!(!matcher.matches(Path::new("/etc/shadow.bak")));
assert!(!matcher.matches(Path::new("/etc/passwd")));
assert!(!matcher.matches(Path::new("/etc")));
}
#[test]
fn test_exact_matcher_clone() {
let matcher = ExactMatcher::new("/etc/passwd");
let cloned = matcher.clone_box();
assert!(cloned.matches(Path::new("/etc/passwd")));
assert_eq!(cloned.pattern_description(), "exact:/etc/passwd");
}
#[test]
fn test_component_matcher_basic() {
let matcher = ComponentMatcher::new(".git");
assert!(matcher.matches(Path::new("/project/.git/config")));
assert!(matcher.matches(Path::new("/home/user/.git")));
assert!(matcher.matches(Path::new("/.git")));
assert!(!matcher.matches(Path::new("/home/user/git")));
assert!(!matcher.matches(Path::new("/home/user/.gitconfig")));
}
#[test]
fn test_component_matcher_hidden_files() {
let matcher = ComponentMatcher::new(".ssh");
assert!(matcher.matches(Path::new("/home/user/.ssh/authorized_keys")));
assert!(matcher.matches(Path::new("/.ssh")));
assert!(!matcher.matches(Path::new("/home/user/ssh")));
}
#[test]
fn test_component_matcher_clone() {
let matcher = ComponentMatcher::new(".svn");
let cloned = matcher.clone_box();
assert!(cloned.matches(Path::new("/project/.svn/entries")));
assert_eq!(cloned.pattern_description(), "component:.svn");
}
#[test]
fn test_extension_matcher_basic() {
let matcher = ExtensionMatcher::new("exe");
assert!(matcher.matches(Path::new("/uploads/malware.exe")));
assert!(matcher.matches(Path::new("/Downloads/SETUP.EXE"))); assert!(!matcher.matches(Path::new("/home/user/document.pdf")));
assert!(!matcher.matches(Path::new("/no/extension")));
}
#[test]
fn test_extension_matcher_common_types() {
let key_matcher = ExtensionMatcher::new("key");
let pem_matcher = ExtensionMatcher::new("pem");
assert!(key_matcher.matches(Path::new("/etc/secret.key")));
assert!(pem_matcher.matches(Path::new("/etc/ssl/cert.pem")));
assert!(!key_matcher.matches(Path::new("/keyboard.txt")));
}
#[test]
fn test_extension_matcher_no_extension() {
let matcher = ExtensionMatcher::new("txt");
assert!(!matcher.matches(Path::new("/bin/bash")));
assert!(!matcher.matches(Path::new("/etc/passwd")));
}
#[test]
fn test_extension_matcher_clone() {
let matcher = ExtensionMatcher::new("zip");
let cloned = matcher.clone_box();
assert!(cloned.matches(Path::new("/downloads/archive.zip")));
assert_eq!(cloned.pattern_description(), "extension:*.zip");
}
#[test]
fn test_extension_matcher_double_extension() {
let matcher = ExtensionMatcher::new("gz");
assert!(matcher.matches(Path::new("/backup/archive.tar.gz")));
assert!(!matcher.matches(Path::new("/backup/archive.tar")));
}
#[test]
fn test_matcher_combinations() {
let prefix = PrefixMatcher::new("/tmp");
let exact = ExactMatcher::new("/etc/passwd");
let component = ComponentMatcher::new(".cache");
let extension = ExtensionMatcher::new("log");
let test_path = Path::new("/tmp/app/.cache/debug.log");
assert!(prefix.matches(test_path));
assert!(!exact.matches(test_path));
assert!(component.matches(test_path));
assert!(extension.matches(test_path));
}
#[test]
fn test_normalize_path_removes_dot() {
assert_eq!(
normalize_path(Path::new("/etc/./passwd")),
Path::new("/etc/passwd")
);
assert_eq!(
normalize_path(Path::new("./foo/./bar")),
Path::new("foo/bar")
);
}
#[test]
fn test_normalize_path_resolves_parent() {
assert_eq!(normalize_path(Path::new("/etc/../var")), Path::new("/var"));
assert_eq!(
normalize_path(Path::new("/etc/ssh/../passwd")),
Path::new("/etc/passwd")
);
assert_eq!(
normalize_path(Path::new("/a/b/c/../../d")),
Path::new("/a/d")
);
}
#[test]
fn test_normalize_path_traversal_at_root() {
assert_eq!(
normalize_path(Path::new("/../etc/passwd")),
Path::new("/etc/passwd")
);
assert_eq!(normalize_path(Path::new("/../../etc")), Path::new("/etc"));
}
#[test]
fn test_normalize_path_relative() {
assert_eq!(normalize_path(Path::new("foo/../bar")), Path::new("bar"));
assert_eq!(normalize_path(Path::new("../foo")), Path::new("../foo"));
}
#[test]
fn test_normalize_path_empty() {
assert_eq!(normalize_path(Path::new("")), Path::new("."));
assert_eq!(normalize_path(Path::new(".")), Path::new("."));
}
#[test]
fn test_normalize_path_security() {
let matcher = PrefixMatcher::new("/etc");
let attack_path = Path::new("/var/../etc/passwd");
assert!(!matcher.matches(attack_path));
let normalized = normalize_path(attack_path);
assert!(matcher.matches(&normalized)); assert_eq!(normalized, Path::new("/etc/passwd"));
}
#[test]
fn test_prefix_matcher_accessor() {
let matcher = PrefixMatcher::new("/home/user");
assert_eq!(matcher.prefix(), Path::new("/home/user"));
}
#[test]
fn test_exact_matcher_accessor() {
let matcher = ExactMatcher::new("/etc/shadow");
assert_eq!(matcher.path(), Path::new("/etc/shadow"));
}
#[test]
fn test_component_matcher_accessor() {
let matcher = ComponentMatcher::new(".git");
assert_eq!(matcher.component(), ".git");
}
#[test]
fn test_extension_matcher_accessor() {
let matcher = ExtensionMatcher::new("PDF");
assert_eq!(matcher.extension(), "pdf");
}
#[test]
fn test_multi_extension_matcher_basic() {
let matcher = MultiExtensionMatcher::new(vec!["exe", "bat", "ps1"], false);
assert!(matcher.matches(Path::new("/uploads/malware.exe")));
assert!(matcher.matches(Path::new("/scripts/script.bat")));
assert!(matcher.matches(Path::new("/scripts/script.ps1")));
assert!(!matcher.matches(Path::new("/home/user/document.pdf")));
}
#[test]
fn test_multi_extension_matcher_case_insensitive() {
let matcher = MultiExtensionMatcher::case_insensitive(vec!["exe", "bat"]);
assert!(matcher.matches(Path::new("/uploads/MALWARE.EXE")));
assert!(matcher.matches(Path::new("/scripts/Script.Bat")));
assert!(!matcher.is_case_sensitive());
}
#[test]
fn test_multi_extension_matcher_case_sensitive() {
let matcher = MultiExtensionMatcher::new(vec!["EXE", "BAT"], true);
assert!(matcher.matches(Path::new("/uploads/malware.EXE")));
assert!(!matcher.matches(Path::new("/uploads/malware.exe"))); assert!(matcher.is_case_sensitive());
}
#[test]
fn test_multi_extension_matcher_accessors() {
let matcher = MultiExtensionMatcher::new(vec!["exe", "bat"], false);
assert_eq!(matcher.extensions(), &["exe", "bat"]);
assert!(!matcher.is_case_sensitive());
}
#[test]
fn test_multi_extension_matcher_no_extension() {
let matcher = MultiExtensionMatcher::new(vec!["txt"], false);
assert!(!matcher.matches(Path::new("/bin/bash")));
assert!(!matcher.matches(Path::new("/etc/passwd")));
}
#[test]
fn test_multi_extension_matcher_pattern_description() {
let matcher = MultiExtensionMatcher::new(vec!["exe", "bat"], false);
assert!(matcher.pattern_description().contains("exe"));
assert!(matcher.pattern_description().contains("bat"));
let case_sensitive = MultiExtensionMatcher::new(vec!["EXE"], true);
assert!(case_sensitive
.pattern_description()
.contains("case-sensitive"));
}
#[test]
fn test_size_matcher_min() {
let matcher = SizeMatcher::min(1024);
assert!(matcher.matches_size(1024)); assert!(matcher.matches_size(2048)); assert!(!matcher.matches_size(512)); }
#[test]
fn test_size_matcher_max() {
let matcher = SizeMatcher::max(1024);
assert!(matcher.matches_size(1024)); assert!(matcher.matches_size(512)); assert!(!matcher.matches_size(2048)); }
#[test]
fn test_size_matcher_between() {
let matcher = SizeMatcher::between(1024, 2048);
assert!(matcher.matches_size(1024)); assert!(matcher.matches_size(1536)); assert!(matcher.matches_size(2048)); assert!(!matcher.matches_size(512)); assert!(!matcher.matches_size(4096)); }
#[test]
fn test_size_matcher_no_limits() {
let matcher = SizeMatcher::new(None, None);
assert!(matcher.matches_size(0));
assert!(matcher.matches_size(u64::MAX));
}
#[test]
fn test_size_matcher_accessors() {
let matcher = SizeMatcher::between(100, 200);
assert_eq!(matcher.min_size(), Some(100));
assert_eq!(matcher.max_size(), Some(200));
let min_only = SizeMatcher::min(50);
assert_eq!(min_only.min_size(), Some(50));
assert_eq!(min_only.max_size(), None);
let max_only = SizeMatcher::max(150);
assert_eq!(max_only.min_size(), None);
assert_eq!(max_only.max_size(), Some(150));
}
}