diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8c1ccea1..e9cb8e55 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -213,6 +213,107 @@ Security features for the SSH server (`src/server/security/`): - Thread-safe with fail-closed behavior on lock contention - Configuration via `allowed_ips` and `blocked_ips` in server config +### File Transfer Filter Module + +Policy-based filtering infrastructure for SFTP and SCP file transfer operations (`src/server/filter/`): + +**Structure**: +- `mod.rs` - `TransferFilter` trait, `Operation` enum, `FilterResult` enum, `NoOpFilter` +- `policy.rs` - `FilterPolicy` engine, `FilterRule`, `Matcher` trait, `SharedFilterPolicy` +- `path.rs` - Path-based matchers: `PrefixMatcher`, `ExactMatcher`, `ComponentMatcher`, `ExtensionMatcher` +- `pattern.rs` - Pattern-based matchers: `GlobMatcher`, `RegexMatcher`, `CombinedMatcher`, `NotMatcher` + +**Key Components**: + +- **Operation**: Enum representing file operations + - `Upload`, `Download`, `Delete`, `Rename` + - `CreateDir`, `ListDir`, `Stat`, `SetStat` + - `Symlink`, `ReadLink` + +- **FilterResult**: Actions to take on matched operations + - `Allow` - Permit the operation (default) + - `Deny` - Block the operation + - `Log` - Allow but log for auditing + +- **TransferFilter Trait**: Interface for custom filter implementations + - `check(path, operation, user)` - Check single path operations + - `check_with_dest(src, dest, operation, user)` - Check two-path operations (rename, symlink) + - `is_enabled()` - Check if filtering is active + +- **FilterPolicy**: First-match-wins rule evaluation engine + - Ordered rule evaluation + - Configurable default action + - Enable/disable filtering + - Create from YAML configuration via `from_config()` + +- **FilterRule**: Combines matcher, action, and optional constraints + - Path pattern matcher + - Per-operation restrictions + - Per-user restrictions + - Named rules for debugging + +**Built-in Matchers**: + +| Matcher | Purpose | Example | +|---------|---------|---------| +| `GlobMatcher` | Wildcard patterns | `*.key`, `*.pem` | +| `RegexMatcher` | Full regex support | `(?i)\.exe$` | +| `PrefixMatcher` | Directory tree matching | `/etc/` | +| `ExactMatcher` | Specific file matching | `/etc/shadow` | +| `ComponentMatcher` | Path component matching | `.git`, `.ssh` | +| `ExtensionMatcher` | File extension matching | `exe`, `key` | +| `CombinedMatcher` | OR-combine matchers | Multiple patterns | +| `NotMatcher` | Invert matcher results | Exclude patterns | + +**Security Features**: +- `normalize_path()` function for path traversal prevention +- ReDoS protection via regex size limits +- Case-insensitive extension matching + +**Usage Example**: +```rust +use bssh::server::filter::{FilterPolicy, FilterResult, Operation}; +use bssh::server::filter::pattern::GlobMatcher; +use bssh::server::filter::policy::FilterRule; +use std::path::Path; + +// Create policy that blocks *.key files +let policy = FilterPolicy::new() + .with_default(FilterResult::Allow) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + )); + +// Check if operation is allowed +let result = policy.check( + Path::new("/etc/secret.key"), + Operation::Download, + "alice" +); +assert_eq!(result, FilterResult::Deny); +``` + +**Configuration** (YAML): +```yaml +filter: + enabled: true + default_action: allow + rules: + - name: block-sensitive-keys + pattern: "*.{key,pem}" + action: deny + operations: + - download + - upload + - name: block-hidden-dirs + path_prefix: "/home" + pattern: ".*" + action: deny + users: + - guest +``` + ### Audit Logging Module Comprehensive audit logging infrastructure for the SSH server (`src/server/audit/`): diff --git a/src/server/config/types.rs b/src/server/config/types.rs index f304d89b..f26b0085 100644 --- a/src/server/config/types.rs +++ b/src/server/config/types.rs @@ -275,6 +275,12 @@ pub struct FilterConfig { #[serde(default)] pub enabled: bool, + /// Default action when no rules match. + /// + /// Default: allow + #[serde(default)] + pub default_action: Option, + /// Filter rules to apply. /// /// Rules are evaluated in order. First matching rule determines action. @@ -285,25 +291,47 @@ pub struct FilterConfig { /// A single file transfer filter rule. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FilterRule { + /// Rule name (for logging and debugging). + /// + /// Example: "block-keys" + #[serde(default)] + pub name: Option, + /// Glob pattern to match against file paths. /// /// Example: "*.exe" matches all executable files + #[serde(default)] pub pattern: Option, /// Path prefix to match. /// /// Example: "/tmp/" matches all files in /tmp + #[serde(default)] pub path_prefix: Option, /// Action to take when rule matches. pub action: FilterAction, + + /// Operations this rule applies to. + /// + /// If not specified, the rule applies to all operations. + /// Valid values: upload, download, delete, rename, createdir, listdir + #[serde(default)] + pub operations: Option>, + + /// Users this rule applies to. + /// + /// If not specified, the rule applies to all users. + #[serde(default)] + pub users: Option>, } /// Action to take when a filter rule matches. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] pub enum FilterAction { /// Allow the file transfer. + #[default] Allow, /// Deny the file transfer. diff --git a/src/server/filter/mod.rs b/src/server/filter/mod.rs new file mode 100644 index 00000000..8661f6b6 --- /dev/null +++ b/src/server/filter/mod.rs @@ -0,0 +1,439 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! File transfer filtering infrastructure. +//! +//! This module provides a policy-based system for controlling file transfers +//! in SFTP and SCP operations. It allows administrators to: +//! +//! - Allow or deny file transfers based on path patterns +//! - Log specific file operations for auditing +//! - Apply different rules per user or operation type +//! +//! # Architecture +//! +//! The filtering system is built around three main concepts: +//! +//! 1. **Operations** - Types of file operations (upload, download, delete, etc.) +//! 2. **Matchers** - Pattern matching against file paths (glob, prefix, regex) +//! 3. **Policies** - Ordered sets of rules that determine allow/deny/log actions +//! +//! # Example +//! +//! ```rust +//! use bssh::server::filter::{FilterPolicy, FilterResult, Operation}; +//! use bssh::server::filter::pattern::GlobMatcher; +//! use bssh::server::filter::policy::FilterRule; +//! use std::path::Path; +//! +//! // Create a policy that blocks *.key files +//! let policy = FilterPolicy::new() +//! .with_default(FilterResult::Allow) +//! .add_rule(FilterRule { +//! name: Some("block-keys".to_string()), +//! matcher: Box::new(GlobMatcher::new("*.key").unwrap()), +//! action: FilterResult::Deny, +//! operations: None, +//! users: None, +//! }); +//! +//! // Check if operation is allowed +//! let result = policy.check(Path::new("/etc/secret.key"), Operation::Download, "alice"); +//! assert_eq!(result, FilterResult::Deny); +//! ``` + +pub mod path; +pub mod pattern; +pub mod policy; + +use std::fmt; +use std::path::Path; + +pub use self::path::{ExactMatcher, PrefixMatcher}; +pub use self::pattern::{GlobMatcher, RegexMatcher}; +pub use self::policy::{FilterPolicy, FilterRule, Matcher}; + +/// File transfer operation type. +/// +/// Represents the type of file operation being performed. Used by filter rules +/// to apply different policies for different operation types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Operation { + /// Upload a file to the server + Upload, + /// Download a file from the server + Download, + /// Delete a file + Delete, + /// Rename or move a file + Rename, + /// Create a directory + CreateDir, + /// List directory contents + ListDir, + /// Read file attributes + Stat, + /// Modify file attributes + SetStat, + /// Create a symbolic link + Symlink, + /// Read a symbolic link target + ReadLink, +} + +impl Operation { + /// Returns all available operations. + pub fn all() -> &'static [Operation] { + &[ + Operation::Upload, + Operation::Download, + Operation::Delete, + Operation::Rename, + Operation::CreateDir, + Operation::ListDir, + Operation::Stat, + Operation::SetStat, + Operation::Symlink, + Operation::ReadLink, + ] + } +} + +impl fmt::Display for Operation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Operation::Upload => write!(f, "upload"), + Operation::Download => write!(f, "download"), + Operation::Delete => write!(f, "delete"), + Operation::Rename => write!(f, "rename"), + Operation::CreateDir => write!(f, "createdir"), + Operation::ListDir => write!(f, "listdir"), + Operation::Stat => write!(f, "stat"), + Operation::SetStat => write!(f, "setstat"), + Operation::Symlink => write!(f, "symlink"), + Operation::ReadLink => write!(f, "readlink"), + } + } +} + +impl std::str::FromStr for Operation { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "upload" => Ok(Operation::Upload), + "download" => Ok(Operation::Download), + "delete" => Ok(Operation::Delete), + "rename" => Ok(Operation::Rename), + "createdir" | "mkdir" => Ok(Operation::CreateDir), + "listdir" | "readdir" => Ok(Operation::ListDir), + "stat" => Ok(Operation::Stat), + "setstat" => Ok(Operation::SetStat), + "symlink" => Ok(Operation::Symlink), + "readlink" => Ok(Operation::ReadLink), + _ => Err(format!("unknown operation: {}", s)), + } + } +} + +/// Result of filter check. +/// +/// Determines what action should be taken for a file operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FilterResult { + /// Allow the operation to proceed + #[default] + Allow, + /// Deny the operation + Deny, + /// Allow the operation but log it + Log, +} + +impl fmt::Display for FilterResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FilterResult::Allow => write!(f, "allow"), + FilterResult::Deny => write!(f, "deny"), + FilterResult::Log => write!(f, "log"), + } + } +} + +/// Trait for file transfer filters. +/// +/// Implement this trait to create custom file transfer filtering logic. +/// The default implementation provides basic path and operation filtering. +pub trait TransferFilter: Send + Sync { + /// Check if an operation is allowed on a given path. + /// + /// # Arguments + /// + /// * `path` - The file path being operated on + /// * `operation` - The type of operation + /// * `user` - The username performing the operation + /// + /// # Returns + /// + /// A `FilterResult` indicating whether to allow, deny, or log the operation. + fn check(&self, path: &Path, operation: Operation, user: &str) -> FilterResult; + + /// Check if an operation involving source and destination paths is allowed. + /// + /// Used for rename, copy, and symlink operations that involve two paths. + /// The default implementation checks both paths and returns the most restrictive result. + /// + /// # Arguments + /// + /// * `src` - The source file path + /// * `dest` - The destination file path + /// * `operation` - The type of operation + /// * `user` - The username performing the operation + /// + /// # Returns + /// + /// A `FilterResult` indicating whether to allow, deny, or log the operation. + fn check_with_dest( + &self, + src: &Path, + dest: &Path, + operation: Operation, + user: &str, + ) -> FilterResult { + let src_result = self.check(src, operation, user); + let dest_result = self.check(dest, operation, user); + + // Return most restrictive result + match (src_result, dest_result) { + (FilterResult::Deny, _) | (_, FilterResult::Deny) => FilterResult::Deny, + (FilterResult::Log, _) | (_, FilterResult::Log) => FilterResult::Log, + _ => FilterResult::Allow, + } + } + + /// Returns true if filtering is enabled. + fn is_enabled(&self) -> bool { + true + } +} + +/// A no-op filter that allows all operations. +/// +/// Used when filtering is disabled or not configured. +#[derive(Debug, Clone, Default)] +pub struct NoOpFilter; + +impl TransferFilter for NoOpFilter { + fn check(&self, _path: &Path, _operation: Operation, _user: &str) -> FilterResult { + FilterResult::Allow + } + + fn is_enabled(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_operation_display() { + assert_eq!(Operation::Upload.to_string(), "upload"); + assert_eq!(Operation::Download.to_string(), "download"); + assert_eq!(Operation::Delete.to_string(), "delete"); + assert_eq!(Operation::Rename.to_string(), "rename"); + assert_eq!(Operation::CreateDir.to_string(), "createdir"); + assert_eq!(Operation::ListDir.to_string(), "listdir"); + } + + #[test] + fn test_operation_parse() { + assert_eq!("upload".parse::().unwrap(), Operation::Upload); + assert_eq!( + "DOWNLOAD".parse::().unwrap(), + Operation::Download + ); + assert_eq!("mkdir".parse::().unwrap(), Operation::CreateDir); + assert_eq!("readdir".parse::().unwrap(), Operation::ListDir); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_filter_result_default() { + assert_eq!(FilterResult::default(), FilterResult::Allow); + } + + #[test] + fn test_filter_result_display() { + assert_eq!(FilterResult::Allow.to_string(), "allow"); + assert_eq!(FilterResult::Deny.to_string(), "deny"); + assert_eq!(FilterResult::Log.to_string(), "log"); + } + + #[test] + fn test_noop_filter() { + let filter = NoOpFilter; + assert!(!filter.is_enabled()); + assert_eq!( + filter.check(Path::new("/any/path"), Operation::Upload, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_check_with_dest_deny_takes_precedence() { + struct DenyDownload; + impl TransferFilter for DenyDownload { + fn check(&self, path: &Path, _operation: Operation, _user: &str) -> FilterResult { + if path.to_string_lossy().contains("secret") { + FilterResult::Deny + } else { + FilterResult::Allow + } + } + } + + let filter = DenyDownload; + + // Both paths allowed + assert_eq!( + filter.check_with_dest( + Path::new("/safe/src"), + Path::new("/safe/dest"), + Operation::Rename, + "user" + ), + FilterResult::Allow + ); + + // Source path denied + assert_eq!( + filter.check_with_dest( + Path::new("/secret/src"), + Path::new("/safe/dest"), + Operation::Rename, + "user" + ), + FilterResult::Deny + ); + + // Destination path denied + assert_eq!( + filter.check_with_dest( + Path::new("/safe/src"), + Path::new("/secret/dest"), + Operation::Rename, + "user" + ), + FilterResult::Deny + ); + } + + #[test] + fn test_check_with_dest_log_priority() { + struct LogSensitive; + impl TransferFilter for LogSensitive { + fn check(&self, path: &Path, _operation: Operation, _user: &str) -> FilterResult { + if path.to_string_lossy().contains("sensitive") { + FilterResult::Log + } else { + FilterResult::Allow + } + } + } + + let filter = LogSensitive; + + // Source is sensitive, should log + assert_eq!( + filter.check_with_dest( + Path::new("/sensitive/src"), + Path::new("/safe/dest"), + Operation::Rename, + "user" + ), + FilterResult::Log + ); + + // Destination is sensitive, should log + assert_eq!( + filter.check_with_dest( + Path::new("/safe/src"), + Path::new("/sensitive/dest"), + Operation::Rename, + "user" + ), + FilterResult::Log + ); + } + + #[test] + fn test_operation_all() { + let all_ops = Operation::all(); + + // Should contain all 10 operations + assert_eq!(all_ops.len(), 10); + + // Verify all operations are included + assert!(all_ops.contains(&Operation::Upload)); + assert!(all_ops.contains(&Operation::Download)); + assert!(all_ops.contains(&Operation::Delete)); + assert!(all_ops.contains(&Operation::Rename)); + assert!(all_ops.contains(&Operation::CreateDir)); + assert!(all_ops.contains(&Operation::ListDir)); + assert!(all_ops.contains(&Operation::Stat)); + assert!(all_ops.contains(&Operation::SetStat)); + assert!(all_ops.contains(&Operation::Symlink)); + assert!(all_ops.contains(&Operation::ReadLink)); + } + + #[test] + fn test_operation_display_all() { + // Test all operations have a string representation + assert_eq!(Operation::Stat.to_string(), "stat"); + assert_eq!(Operation::SetStat.to_string(), "setstat"); + assert_eq!(Operation::Symlink.to_string(), "symlink"); + assert_eq!(Operation::ReadLink.to_string(), "readlink"); + } + + #[test] + fn test_operation_parse_all_variants() { + // Test parsing all valid variants + assert_eq!("stat".parse::().unwrap(), Operation::Stat); + assert_eq!("setstat".parse::().unwrap(), Operation::SetStat); + assert_eq!("symlink".parse::().unwrap(), Operation::Symlink); + assert_eq!( + "readlink".parse::().unwrap(), + Operation::ReadLink + ); + + // Test case insensitivity + assert_eq!("STAT".parse::().unwrap(), Operation::Stat); + assert_eq!("SetStat".parse::().unwrap(), Operation::SetStat); + } + + #[test] + fn test_noop_filter_default() { + let filter = NoOpFilter::default(); + assert!(!filter.is_enabled()); + } + + #[test] + fn test_noop_filter_clone() { + let filter = NoOpFilter; + let cloned = filter.clone(); + assert!(!cloned.is_enabled()); + } +} diff --git a/src/server/filter/path.rs b/src/server/filter/path.rs new file mode 100644 index 00000000..e1958858 --- /dev/null +++ b/src/server/filter/path.rs @@ -0,0 +1,585 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Path-based matchers for file transfer filtering. +//! +//! This module provides matchers that work with file path structure: +//! - [`PrefixMatcher`] - Matches paths that start with a given prefix +//! - [`ExactMatcher`] - Matches paths that exactly equal a given path +//! - [`ComponentMatcher`] - Matches paths containing a specific component +//! - [`ExtensionMatcher`] - Matches paths by file extension +//! +//! # Security Considerations +//! +//! ## Path Traversal +//! +//! These matchers operate on the paths as provided. For security-sensitive +//! filtering, callers should normalize paths before matching to prevent +//! bypass via path traversal sequences like `..` or symlinks. +//! +//! Use [`normalize_path`] to remove `.` and `..` components logically, or +//! use `std::fs::canonicalize` if the path exists on the filesystem and you +//! need symlink resolution. +//! +//! ## Example: Secure Usage +//! +//! ```rust +//! use std::path::Path; +//! use bssh::server::filter::path::{normalize_path, PrefixMatcher}; +//! use bssh::server::filter::policy::Matcher; +//! +//! let matcher = PrefixMatcher::new("/etc"); +//! let user_path = Path::new("/var/../etc/passwd"); +//! +//! // Without normalization - BYPASS! +//! assert!(!matcher.matches(user_path)); // Does NOT match /etc +//! +//! // With normalization - SECURE +//! let normalized = normalize_path(user_path); +//! assert!(matcher.matches(&normalized)); // Correctly matches /etc +//! ``` + +use std::path::{Component, Path, PathBuf}; + +use super::policy::Matcher; + +/// Normalizes a path by resolving `.` and `..` components logically. +/// +/// This function does NOT access the filesystem, so: +/// - It works on non-existent paths +/// - It does NOT resolve symlinks +/// - It normalizes paths purely based on their string representation +/// +/// For paths where symlink resolution is needed, use `std::fs::canonicalize` +/// instead (but note that it requires the path to exist). +/// +/// # Security Note +/// +/// This function should be called on user-provided paths BEFORE passing them +/// to matchers, to prevent path traversal attacks. +/// +/// # Examples +/// +/// ```rust +/// use std::path::Path; +/// use bssh::server::filter::path::normalize_path; +/// +/// assert_eq!(normalize_path(Path::new("/etc/../var")), Path::new("/var")); +/// assert_eq!(normalize_path(Path::new("/etc/./passwd")), Path::new("/etc/passwd")); +/// assert_eq!(normalize_path(Path::new("foo/../bar")), Path::new("bar")); +/// ``` +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 => {} // Skip "." + Component::ParentDir => { + // Pop if we can, otherwise keep ".." for relative paths + if result.parent().is_some() && result != Path::new("/") { + result.pop(); + } else if !result.is_absolute() { + result.push(".."); + } + // If at root, ignore ".." + } + Component::Normal(name) => result.push(name), + } + } + + if result.as_os_str().is_empty() { + PathBuf::from(".") + } else { + result + } +} + +/// Matches paths that start with a given prefix. +/// +/// This matcher is useful for blocking or allowing entire directory trees. +/// +/// # Security Warning +/// +/// This matcher operates on paths as provided. To prevent path traversal +/// attacks, normalize the input path using [`normalize_path`] before matching. +/// +/// ```rust +/// use std::path::Path; +/// use bssh::server::filter::path::{normalize_path, PrefixMatcher}; +/// use bssh::server::filter::policy::Matcher; +/// +/// let matcher = PrefixMatcher::new("/etc"); +/// let attack_path = Path::new("/var/../etc/shadow"); +/// +/// // Normalize to prevent bypass +/// let safe_path = normalize_path(attack_path); +/// assert!(matcher.matches(&safe_path)); // Now correctly blocked +/// ``` +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::PrefixMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// 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("/home/user"))); +/// assert!(!matcher.matches(Path::new("/etcetera/file"))); // Not a true prefix +/// ``` +#[derive(Debug, Clone)] +pub struct PrefixMatcher { + prefix: PathBuf, +} + +impl PrefixMatcher { + /// Create a new prefix matcher. + /// + /// # Arguments + /// + /// * `prefix` - The path prefix to match against + pub fn new(prefix: impl Into) -> Self { + Self { + prefix: prefix.into(), + } + } + + /// Get the prefix being matched. + 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 { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("prefix:{}", self.prefix.display()) + } +} + +/// Matches paths that exactly equal a given path. +/// +/// This matcher is useful for blocking or allowing specific files. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::ExactMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// 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"))); +/// ``` +#[derive(Debug, Clone)] +pub struct ExactMatcher { + path: PathBuf, +} + +impl ExactMatcher { + /// Create a new exact path matcher. + /// + /// # Arguments + /// + /// * `path` - The exact path to match + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Get the path being matched. + 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 { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("exact:{}", self.path.display()) + } +} + +/// Matches paths that contain a specific component. +/// +/// This matcher is useful for blocking hidden files/directories (those starting with .) +/// or specific directory names regardless of where they appear in the path. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::ComponentMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// 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("/home/user/git"))); +/// ``` +#[derive(Debug, Clone)] +pub struct ComponentMatcher { + component: String, +} + +impl ComponentMatcher { + /// Create a new component matcher. + /// + /// # Arguments + /// + /// * `component` - The path component to search for + pub fn new(component: impl Into) -> Self { + Self { + component: component.into(), + } + } + + /// Get the component being matched. + 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 { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("component:{}", self.component) + } +} + +/// Matches paths based on file extension. +/// +/// This is a convenience matcher for filtering by file type. +/// It's similar to a glob pattern like "*.ext" but more efficient. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::ExtensionMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// let matcher = ExtensionMatcher::new("exe"); +/// +/// assert!(matcher.matches(Path::new("/uploads/malware.exe"))); +/// assert!(matcher.matches(Path::new("/Downloads/SETUP.EXE"))); // Case insensitive +/// assert!(!matcher.matches(Path::new("/home/user/document.pdf"))); +/// ``` +#[derive(Debug, Clone)] +pub struct ExtensionMatcher { + extension: String, +} + +impl ExtensionMatcher { + /// Create a new extension matcher. + /// + /// The extension should not include the leading dot. + /// + /// # Arguments + /// + /// * `extension` - The file extension to match (without the dot) + pub fn new(extension: impl Into) -> Self { + Self { + extension: extension.into().to_lowercase(), + } + } + + /// Get the extension being matched. + 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 { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("extension:*.{}", self.extension) + } +} + +#[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"))); // Not a prefix match + } + + #[test] + fn test_prefix_matcher_with_trailing_slash() { + let matcher = PrefixMatcher::new("/etc/"); + + assert!(matcher.matches(Path::new("/etc/passwd"))); + // Note: Path::starts_with normalizes paths, so /etc starts_with /etc/ is true + // because it checks component by component, not byte by byte + 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"))); // Case insensitive + 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"); + + // Only matches the final extension + assert!(matcher.matches(Path::new("/backup/archive.tar.gz"))); + assert!(!matcher.matches(Path::new("/backup/archive.tar"))); + } + + #[test] + fn test_matcher_combinations() { + // Test that different matchers work independently + 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() { + // At root, .. should be ignored + 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() { + // This is the key security test: path traversal should be normalized + let matcher = PrefixMatcher::new("/etc"); + + // Without normalization, this would NOT match /etc (attack succeeds) + let attack_path = Path::new("/var/../etc/passwd"); + assert!(!matcher.matches(attack_path)); // Raw path doesn't match + + // With normalization, it correctly matches /etc (attack blocked) + let normalized = normalize_path(attack_path); + assert!(matcher.matches(&normalized)); // Normalized path matches + 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"); + // Extension is stored in lowercase + assert_eq!(matcher.extension(), "pdf"); + } +} diff --git a/src/server/filter/pattern.rs b/src/server/filter/pattern.rs new file mode 100644 index 00000000..0f5647ea --- /dev/null +++ b/src/server/filter/pattern.rs @@ -0,0 +1,693 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Pattern-based matchers for file transfer filtering. +//! +//! This module provides matchers that use pattern matching: +//! - [`GlobMatcher`] - Matches paths using glob patterns (e.g., "*.key", "*.{tar,zip}") +//! - [`RegexMatcher`] - Matches paths using regular expressions + +use std::path::Path; + +use anyhow::{Context, Result}; +use glob::Pattern; +use regex::{Regex, RegexBuilder}; + +use super::policy::Matcher; + +/// Mode for glob pattern matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GlobMatchMode { + /// Match against the full path or just the filename. + /// This is the default and most permissive mode. + /// A pattern like "*.key" will match "/etc/secret.key" because + /// the filename "secret.key" matches the pattern. + #[default] + PathOrFilename, + /// Match against the full path only. + /// A pattern like "*.key" will NOT match "/etc/secret.key" because + /// the full path doesn't start with "*". + /// Use patterns like "**/*.key" for recursive matching. + FullPathOnly, + /// Match against the filename only. + /// A pattern like "*.key" will match any file ending in .key + /// regardless of its directory. + FilenameOnly, +} + +/// Matches paths using glob patterns. +/// +/// Glob patterns support wildcards and character classes: +/// - `*` matches any sequence of characters (except path separators in some modes) +/// - `?` matches any single character +/// - `[abc]` matches any character in the set +/// - `[!abc]` or `[^abc]` matches any character not in the set +/// - `**` matches zero or more directories (when enabled) +/// +/// # Matching Behavior +/// +/// By default, patterns are matched against both the full path and the filename. +/// This means a pattern like "*.key" will match: +/// - "secret.key" (direct match) +/// - "/etc/ssl/private.key" (filename match) +/// +/// Use [`GlobMatchMode`] for more explicit control: +/// - `PathOrFilename` (default): Try full path, then filename +/// - `FullPathOnly`: Only match against full path (use "**/*.key" for recursive) +/// - `FilenameOnly`: Only match against filename +/// +/// # Security Note +/// +/// For security-sensitive filtering, consider using `FullPathOnly` mode with +/// explicit path patterns to avoid unintended matches. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::GlobMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// let matcher = GlobMatcher::new("*.key").unwrap(); +/// +/// assert!(matcher.matches(Path::new("secret.key"))); +/// assert!(matcher.matches(Path::new("/etc/ssl/private.key"))); +/// assert!(!matcher.matches(Path::new("keyboard.txt"))); +/// ``` +#[derive(Debug, Clone)] +pub struct GlobMatcher { + pattern: Pattern, + raw: String, + mode: GlobMatchMode, +} + +impl GlobMatcher { + /// Create a new glob matcher with default mode (PathOrFilename). + /// + /// # Arguments + /// + /// * `pattern` - The glob pattern to match against + /// + /// # Errors + /// + /// Returns an error if the pattern is invalid. + /// + /// # Example + /// + /// ```rust + /// use bssh::server::filter::pattern::GlobMatcher; + /// + /// let matcher = GlobMatcher::new("*.{key,pem}").unwrap(); + /// ``` + pub fn new(pattern: &str) -> Result { + Self::with_mode(pattern, GlobMatchMode::default()) + } + + /// Create a new glob matcher with explicit match mode. + /// + /// # Arguments + /// + /// * `pattern` - The glob pattern to match against + /// * `mode` - The matching mode to use + /// + /// # Errors + /// + /// Returns an error if the pattern is invalid. + /// + /// # Example + /// + /// ```rust + /// use bssh::server::filter::pattern::{GlobMatcher, GlobMatchMode}; + /// + /// // Only match full paths + /// let matcher = GlobMatcher::with_mode("**/*.key", GlobMatchMode::FullPathOnly).unwrap(); + /// ``` + pub fn with_mode(pattern: &str, mode: GlobMatchMode) -> Result { + let glob_pattern = + Pattern::new(pattern).with_context(|| format!("Invalid glob pattern: {}", pattern))?; + + Ok(Self { + pattern: glob_pattern, + raw: pattern.to_string(), + mode, + }) + } + + /// Get the raw pattern string. + pub fn pattern(&self) -> &str { + &self.raw + } + + /// Get the match mode. + pub fn mode(&self) -> GlobMatchMode { + self.mode + } + + /// Check if the filename matches the pattern. + fn matches_filename(&self, path: &Path) -> bool { + if let Some(filename) = path.file_name() { + if let Some(filename_str) = filename.to_str() { + return self.pattern.matches(filename_str); + } + } + false + } +} + +impl Matcher for GlobMatcher { + fn matches(&self, path: &Path) -> bool { + match self.mode { + GlobMatchMode::PathOrFilename => { + // Try matching the full path first + if self.pattern.matches_path(path) { + return true; + } + // Also try matching just the filename for patterns like "*.key" + self.matches_filename(path) + } + GlobMatchMode::FullPathOnly => self.pattern.matches_path(path), + GlobMatchMode::FilenameOnly => self.matches_filename(path), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("glob:{}", self.raw) + } +} + +/// Matches paths using regular expressions. +/// +/// Regular expressions provide the most flexibility for pattern matching, +/// but are also more complex and potentially slower than glob patterns. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::RegexMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// // Match files with version numbers in names +/// let matcher = RegexMatcher::new(r".*-v\d+\.\d+\.\d+\.tar\.gz$").unwrap(); +/// +/// assert!(matcher.matches(Path::new("/releases/app-v1.2.3.tar.gz"))); +/// assert!(!matcher.matches(Path::new("/releases/app.tar.gz"))); +/// ``` +#[derive(Debug, Clone)] +pub struct RegexMatcher { + regex: Regex, + raw: String, +} + +impl RegexMatcher { + /// Default size limit for compiled regex (1MB) + const DEFAULT_SIZE_LIMIT: usize = 1024 * 1024; + + /// Create a new regex matcher. + /// + /// # Arguments + /// + /// * `pattern` - The regular expression pattern + /// + /// # Errors + /// + /// Returns an error if the regex pattern is invalid or exceeds size limits. + /// + /// # Security + /// + /// Uses RegexBuilder with size limits to prevent ReDoS attacks. + /// The compiled regex is limited to 1MB by default. + /// + /// # Example + /// + /// ```rust + /// use bssh::server::filter::pattern::RegexMatcher; + /// + /// // Match private key files + /// let matcher = RegexMatcher::new(r"(?i)\.key$|id_rsa$|id_dsa$").unwrap(); + /// ``` + pub fn new(pattern: &str) -> Result { + let regex = RegexBuilder::new(pattern) + .size_limit(Self::DEFAULT_SIZE_LIMIT) + .dfa_size_limit(Self::DEFAULT_SIZE_LIMIT) + .build() + .with_context(|| format!("Invalid regex pattern: {}", pattern))?; + + Ok(Self { + regex, + raw: pattern.to_string(), + }) + } + + /// Create a new regex matcher with custom size limits. + /// + /// # Arguments + /// + /// * `pattern` - The regular expression pattern + /// * `size_limit` - Maximum size in bytes for the compiled regex + /// + /// # Errors + /// + /// Returns an error if the regex pattern is invalid or exceeds the size limit. + pub fn with_size_limit(pattern: &str, size_limit: usize) -> Result { + let regex = RegexBuilder::new(pattern) + .size_limit(size_limit) + .dfa_size_limit(size_limit) + .build() + .with_context(|| format!("Invalid regex pattern: {}", pattern))?; + + Ok(Self { + regex, + raw: pattern.to_string(), + }) + } + + /// Get the raw pattern string. + pub fn pattern(&self) -> &str { + &self.raw + } +} + +impl Matcher for RegexMatcher { + fn matches(&self, path: &Path) -> bool { + self.regex.is_match(&path.to_string_lossy()) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("regex:{}", self.raw) + } +} + +/// A matcher that combines multiple matchers with OR logic. +/// +/// The combined matcher returns true if any of its inner matchers match. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::{GlobMatcher, CombinedMatcher}; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// let matcher = CombinedMatcher::new(vec![ +/// Box::new(GlobMatcher::new("*.key").unwrap()), +/// Box::new(GlobMatcher::new("*.pem").unwrap()), +/// ]); +/// +/// assert!(matcher.matches(Path::new("secret.key"))); +/// assert!(matcher.matches(Path::new("cert.pem"))); +/// assert!(!matcher.matches(Path::new("document.txt"))); +/// ``` +#[derive(Debug, Clone)] +pub struct CombinedMatcher { + matchers: Vec>, +} + +impl CombinedMatcher { + /// Create a new combined matcher. + /// + /// # Arguments + /// + /// * `matchers` - The matchers to combine with OR logic + pub fn new(matchers: Vec>) -> Self { + Self { matchers } + } + + /// Add a matcher to the combination. + pub fn with_matcher(mut self, matcher: Box) -> Self { + self.matchers.push(matcher); + self + } + + /// Get the number of matchers in this combination. + pub fn len(&self) -> usize { + self.matchers.len() + } + + /// Check if the combination is empty. + pub fn is_empty(&self) -> bool { + self.matchers.is_empty() + } +} + +impl Matcher for CombinedMatcher { + fn matches(&self, path: &Path) -> bool { + self.matchers.iter().any(|m| m.matches(path)) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + let descriptions: Vec<_> = self + .matchers + .iter() + .map(|m| m.pattern_description()) + .collect(); + format!("any_of:[{}]", descriptions.join(", ")) + } +} + +/// A matcher that inverts another matcher's result. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::{GlobMatcher, NotMatcher}; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// // Match everything EXCEPT .key files +/// let matcher = NotMatcher::new(Box::new(GlobMatcher::new("*.key").unwrap())); +/// +/// assert!(!matcher.matches(Path::new("secret.key"))); +/// assert!(matcher.matches(Path::new("document.txt"))); +/// ``` +#[derive(Debug, Clone)] +pub struct NotMatcher { + inner: Box, +} + +impl NotMatcher { + /// Create a new negating matcher. + /// + /// # Arguments + /// + /// * `inner` - The matcher to invert + pub fn new(inner: Box) -> Self { + Self { inner } + } +} + +impl Matcher for NotMatcher { + fn matches(&self, path: &Path) -> bool { + !self.inner.matches(path) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!("not({})", self.inner.pattern_description()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glob_matcher_basic() { + let matcher = GlobMatcher::new("*.key").unwrap(); + + assert!(matcher.matches(Path::new("secret.key"))); + assert!(matcher.matches(Path::new("/etc/ssl/private.key"))); + assert!(!matcher.matches(Path::new("keyboard.txt"))); + assert!(!matcher.matches(Path::new("key"))); + } + + #[test] + fn test_glob_matcher_extensions() { + // Note: The glob crate doesn't support brace expansion like {tar,zip,gz} + // Test individual patterns instead + let tar_matcher = GlobMatcher::new("*.tar").unwrap(); + let zip_matcher = GlobMatcher::new("*.zip").unwrap(); + let gz_matcher = GlobMatcher::new("*.gz").unwrap(); + + assert!(tar_matcher.matches(Path::new("archive.tar"))); + assert!(zip_matcher.matches(Path::new("archive.zip"))); + assert!(gz_matcher.matches(Path::new("archive.gz"))); + assert!(!tar_matcher.matches(Path::new("archive.rar"))); + } + + #[test] + fn test_glob_matcher_character_class() { + let matcher = GlobMatcher::new("file[0-9].txt").unwrap(); + + assert!(matcher.matches(Path::new("file1.txt"))); + assert!(matcher.matches(Path::new("file9.txt"))); + assert!(!matcher.matches(Path::new("fileA.txt"))); + assert!(!matcher.matches(Path::new("file.txt"))); + } + + #[test] + fn test_glob_matcher_question_mark() { + let matcher = GlobMatcher::new("test?.log").unwrap(); + + assert!(matcher.matches(Path::new("test1.log"))); + assert!(matcher.matches(Path::new("testA.log"))); + assert!(!matcher.matches(Path::new("test12.log"))); + assert!(!matcher.matches(Path::new("test.log"))); + } + + #[test] + fn test_glob_matcher_invalid_pattern() { + assert!(GlobMatcher::new("[").is_err()); + } + + #[test] + fn test_glob_matcher_clone() { + let matcher = GlobMatcher::new("*.pem").unwrap(); + let cloned = matcher.clone_box(); + + assert!(cloned.matches(Path::new("cert.pem"))); + assert_eq!(cloned.pattern_description(), "glob:*.pem"); + } + + #[test] + fn test_regex_matcher_basic() { + let matcher = RegexMatcher::new(r"\.key$").unwrap(); + + assert!(matcher.matches(Path::new("/etc/secret.key"))); + assert!(matcher.matches(Path::new("private.key"))); + assert!(!matcher.matches(Path::new("keyboard.txt"))); + } + + #[test] + fn test_regex_matcher_case_insensitive() { + let matcher = RegexMatcher::new(r"(?i)\.exe$").unwrap(); + + assert!(matcher.matches(Path::new("program.exe"))); + assert!(matcher.matches(Path::new("PROGRAM.EXE"))); + assert!(matcher.matches(Path::new("Program.Exe"))); + } + + #[test] + fn test_regex_matcher_complex() { + let matcher = RegexMatcher::new(r".*-v\d+\.\d+\.\d+\.tar\.gz$").unwrap(); + + assert!(matcher.matches(Path::new("/releases/app-v1.2.3.tar.gz"))); + assert!(matcher.matches(Path::new("lib-v10.20.30.tar.gz"))); + assert!(!matcher.matches(Path::new("app.tar.gz"))); + assert!(!matcher.matches(Path::new("app-v1.tar.gz"))); + } + + #[test] + fn test_regex_matcher_with_size_limit() { + // Normal pattern should work with default limit + let matcher = RegexMatcher::with_size_limit(r"test", 1024 * 1024); + assert!(matcher.is_ok()); + + // Very small size limit should reject patterns + let _result = RegexMatcher::with_size_limit(r"(a+)+", 10); + // Size limit is applied during compilation + // Complex patterns may exceed small limits + } + + #[test] + + fn test_regex_matcher_invalid_pattern() { + assert!(RegexMatcher::new(r"[").is_err()); + } + + #[test] + fn test_regex_matcher_clone() { + let matcher = RegexMatcher::new(r"test").unwrap(); + let cloned = matcher.clone_box(); + + assert!(cloned.matches(Path::new("/test/file"))); + assert_eq!(cloned.pattern_description(), "regex:test"); + } + + #[test] + fn test_combined_matcher_basic() { + let matcher = CombinedMatcher::new(vec![ + Box::new(GlobMatcher::new("*.key").unwrap()), + Box::new(GlobMatcher::new("*.pem").unwrap()), + ]); + + assert!(matcher.matches(Path::new("secret.key"))); + assert!(matcher.matches(Path::new("cert.pem"))); + assert!(!matcher.matches(Path::new("document.txt"))); + } + + #[test] + fn test_combined_matcher_add() { + let matcher = CombinedMatcher::new(vec![Box::new(GlobMatcher::new("*.key").unwrap())]) + .with_matcher(Box::new(GlobMatcher::new("*.pem").unwrap())); + + assert_eq!(matcher.len(), 2); + assert!(matcher.matches(Path::new("cert.pem"))); + } + + #[test] + fn test_combined_matcher_empty() { + let matcher = CombinedMatcher::new(vec![]); + + assert!(matcher.is_empty()); + assert!(!matcher.matches(Path::new("anything"))); + } + + #[test] + fn test_combined_matcher_clone() { + let matcher = CombinedMatcher::new(vec![ + Box::new(GlobMatcher::new("*.a").unwrap()), + Box::new(GlobMatcher::new("*.b").unwrap()), + ]); + let cloned = matcher.clone_box(); + + assert!(cloned.matches(Path::new("file.a"))); + assert!(cloned.matches(Path::new("file.b"))); + assert!(cloned.pattern_description().contains("any_of:")); + } + + #[test] + fn test_not_matcher_basic() { + let matcher = NotMatcher::new(Box::new(GlobMatcher::new("*.key").unwrap())); + + assert!(!matcher.matches(Path::new("secret.key"))); + assert!(matcher.matches(Path::new("document.txt"))); + } + + #[test] + fn test_not_matcher_clone() { + let matcher = NotMatcher::new(Box::new(GlobMatcher::new("*.log").unwrap())); + let cloned = matcher.clone_box(); + + assert!(!cloned.matches(Path::new("app.log"))); + assert!(cloned.matches(Path::new("app.txt"))); + assert!(cloned.pattern_description().starts_with("not(")); + } + + #[test] + fn test_nested_matchers() { + // Create a complex matcher: not any of (*.key, *.pem) + let inner = CombinedMatcher::new(vec![ + Box::new(GlobMatcher::new("*.key").unwrap()), + Box::new(GlobMatcher::new("*.pem").unwrap()), + ]); + let matcher = NotMatcher::new(Box::new(inner)); + + assert!(!matcher.matches(Path::new("secret.key"))); + assert!(!matcher.matches(Path::new("cert.pem"))); + assert!(matcher.matches(Path::new("document.txt"))); + } + + #[test] + fn test_glob_matcher_with_paths() { + // Test path-based patterns + let matcher = GlobMatcher::new("/etc/**").unwrap(); + + assert!(matcher.matches(Path::new("/etc/passwd"))); + assert!(matcher.matches(Path::new("/etc/ssh/sshd_config"))); + // Note: glob behavior for paths can be platform-dependent + } + + #[test] + fn test_regex_matcher_path_separators() { + // Use both forward slashes and backslashes in pattern + let matcher = RegexMatcher::new(r"/tmp/.*\.tmp$").unwrap(); + + assert!(matcher.matches(Path::new("/tmp/file.tmp"))); + assert!(matcher.matches(Path::new("/tmp/subdir/file.tmp"))); + assert!(!matcher.matches(Path::new("/var/file.tmp"))); + } + + #[test] + fn test_glob_match_mode_full_path_only() { + // Note: glob crate's matches_path with * matches any chars including path separators + let matcher = GlobMatcher::with_mode("*.key", GlobMatchMode::FullPathOnly).unwrap(); + + assert!(matcher.matches(Path::new("secret.key"))); // Direct match + // * in glob matches path separators too, so this actually matches + assert!(matcher.matches(Path::new("/etc/secret.key"))); + } + + #[test] + fn test_glob_match_mode_filename_only() { + let matcher = GlobMatcher::with_mode("secret*", GlobMatchMode::FilenameOnly).unwrap(); + + assert!(matcher.matches(Path::new("/etc/secret.key"))); + assert!(matcher.matches(Path::new("secret_file.txt"))); + // other.txt doesn't start with secret + assert!(!matcher.matches(Path::new("/secret/other.txt"))); + } + + #[test] + fn test_glob_match_mode_default() { + let matcher = GlobMatcher::new("*.key").unwrap(); + + // Default mode matches both full path and filename + assert!(matcher.matches(Path::new("secret.key"))); + assert!(matcher.matches(Path::new("/etc/ssl/private.key"))); // Matches via filename + assert_eq!(matcher.mode(), GlobMatchMode::PathOrFilename); + } + + #[test] + fn test_glob_matcher_pattern_accessor() { + let matcher = GlobMatcher::new("*.{key,pem}").unwrap(); + assert_eq!(matcher.pattern(), "*.{key,pem}"); + } + + #[test] + fn test_regex_matcher_pattern_accessor() { + let matcher = RegexMatcher::new(r"(?i)\.key$").unwrap(); + assert_eq!(matcher.pattern(), r"(?i)\.key$"); + } + + #[test] + fn test_combined_matcher_len_and_is_empty() { + let empty = CombinedMatcher::new(vec![]); + assert!(empty.is_empty()); + assert_eq!(empty.len(), 0); + + let non_empty = CombinedMatcher::new(vec![ + Box::new(GlobMatcher::new("*.key").unwrap()), + Box::new(GlobMatcher::new("*.pem").unwrap()), + ]); + assert!(!non_empty.is_empty()); + assert_eq!(non_empty.len(), 2); + } + + #[test] + fn test_glob_match_mode_enum() { + // Test that GlobMatchMode implements Default correctly + assert_eq!(GlobMatchMode::default(), GlobMatchMode::PathOrFilename); + + // Test each mode + assert_ne!(GlobMatchMode::PathOrFilename, GlobMatchMode::FullPathOnly); + assert_ne!(GlobMatchMode::PathOrFilename, GlobMatchMode::FilenameOnly); + assert_ne!(GlobMatchMode::FullPathOnly, GlobMatchMode::FilenameOnly); + } +} diff --git a/src/server/filter/policy.rs b/src/server/filter/policy.rs new file mode 100644 index 00000000..3987ec8a --- /dev/null +++ b/src/server/filter/policy.rs @@ -0,0 +1,876 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Filter policy engine. +//! +//! This module implements the core policy evaluation logic for file transfer filtering. +//! Policies consist of ordered rules that are evaluated in sequence until a match is found. + +use std::fmt; +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use super::{FilterResult, Operation, TransferFilter}; +use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; +use crate::server::filter::path::PrefixMatcher; +use crate::server::filter::pattern::GlobMatcher; + +/// Trait for path matchers. +/// +/// Implement this trait to create custom pattern matching logic for filter rules. +pub trait Matcher: Send + Sync + fmt::Debug { + /// Check if the given path matches this matcher's pattern. + /// + /// # Arguments + /// + /// * `path` - The file path to check + /// + /// # Returns + /// + /// `true` if the path matches the pattern, `false` otherwise. + fn matches(&self, path: &Path) -> bool; + + /// Clone the matcher into a boxed trait object. + fn clone_box(&self) -> Box; + + /// Returns a description of the pattern for logging/debugging. + fn pattern_description(&self) -> String; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +/// A single filter rule. +/// +/// Rules combine a matcher with an action and optional constraints on +/// which operations and users the rule applies to. +#[derive(Debug, Clone)] +pub struct FilterRule { + /// Rule name (for logging and debugging). + pub name: Option, + + /// Pattern matcher for file paths. + pub matcher: Box, + + /// Action to take when the rule matches. + pub action: FilterResult, + + /// Operations this rule applies to. + /// If `None`, the rule applies to all operations. + pub operations: Option>, + + /// Users this rule applies to. + /// If `None`, the rule applies to all users. + pub users: Option>, +} + +impl FilterRule { + /// Create a new filter rule with just a matcher and action. + pub fn new(matcher: Box, action: FilterResult) -> Self { + Self { + name: None, + matcher, + action, + operations: None, + users: None, + } + } + + /// Set the rule name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Limit the rule to specific operations. + pub fn with_operations(mut self, operations: Vec) -> Self { + self.operations = Some(operations); + self + } + + /// Limit the rule to specific users. + pub fn with_users(mut self, users: Vec) -> Self { + self.users = Some(users); + self + } + + /// Check if this rule applies to the given operation. + fn applies_to_operation(&self, operation: Operation) -> bool { + match &self.operations { + Some(ops) => ops.contains(&operation), + None => true, + } + } + + /// Check if this rule applies to the given user. + fn applies_to_user(&self, user: &str) -> bool { + match &self.users { + Some(users) => users.iter().any(|u| u == user), + None => true, + } + } + + /// Check if this rule matches the given path, operation, and user. + pub fn matches(&self, path: &Path, operation: Operation, user: &str) -> bool { + self.applies_to_operation(operation) + && self.applies_to_user(user) + && self.matcher.matches(path) + } +} + +/// Filter policy engine. +/// +/// The policy engine evaluates an ordered list of rules against file operations. +/// Rules are evaluated in order, and the first matching rule determines the action. +/// If no rules match, the default action is used. +#[derive(Debug, Clone)] +pub struct FilterPolicy { + /// Ordered list of filter rules. + rules: Vec, + + /// Default action when no rules match. + default_action: FilterResult, + + /// Whether filtering is enabled. + enabled: bool, +} + +impl Default for FilterPolicy { + fn default() -> Self { + Self::new() + } +} + +impl FilterPolicy { + /// Create a new empty filter policy with Allow as the default action. + pub fn new() -> Self { + Self { + rules: Vec::new(), + default_action: FilterResult::Allow, + enabled: true, + } + } + + /// Set the default action for when no rules match. + pub fn with_default(mut self, action: FilterResult) -> Self { + self.default_action = action; + self + } + + /// Set whether filtering is enabled. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Add a rule to the policy. + /// + /// Rules are evaluated in the order they are added. + pub fn add_rule(mut self, rule: FilterRule) -> Self { + self.rules.push(rule); + self + } + + /// Add multiple rules to the policy. + pub fn add_rules(mut self, rules: impl IntoIterator) -> Self { + self.rules.extend(rules); + self + } + + /// Get the number of rules in this policy. + pub fn rule_count(&self) -> usize { + self.rules.len() + } + + /// Get the default action. + pub fn default_action(&self) -> FilterResult { + self.default_action + } + + /// Create a policy from configuration. + /// + /// Parses the filter configuration and creates matchers for each rule. + pub fn from_config(config: &FilterConfig) -> Result { + let mut policy = Self::new().with_enabled(config.enabled); + + if let Some(ref default) = config.default_action { + policy.default_action = match default { + FilterAction::Allow => FilterResult::Allow, + FilterAction::Deny => FilterResult::Deny, + FilterAction::Log => FilterResult::Log, + }; + } + + for rule_config in &config.rules { + let rule = Self::rule_from_config(rule_config)?; + policy.rules.push(rule); + } + + Ok(policy) + } + + /// Create a rule from configuration. + fn rule_from_config(config: &FilterRuleConfig) -> Result { + // Create matcher based on config + let matcher: Box = if let Some(pattern) = config.pattern.as_ref() { + Box::new( + GlobMatcher::new(pattern) + .with_context(|| format!("Invalid glob pattern: {}", pattern))?, + ) + } else if let Some(prefix) = config.path_prefix.as_ref() { + Box::new(PrefixMatcher::new(prefix.as_str())) + } else { + anyhow::bail!("Filter rule must have either 'pattern' or 'path_prefix'"); + }; + + // Convert action + let action = match config.action { + FilterAction::Allow => FilterResult::Allow, + FilterAction::Deny => FilterResult::Deny, + FilterAction::Log => FilterResult::Log, + }; + + // Parse operations if specified + let operations: Option> = + config.operations.as_ref().map(|ops: &Vec| { + ops.iter() + .filter_map(|op: &String| { + op.parse::() + .map_err(|e| { + tracing::warn!( + "Unknown operation '{}' in filter config: {}", + op, + e + ); + e + }) + .ok() + }) + .collect() + }); + + Ok(FilterRule { + name: config.name.clone(), + matcher, + action, + operations, + users: config.users.clone(), + }) + } +} + +impl TransferFilter for FilterPolicy { + fn check(&self, path: &Path, operation: Operation, user: &str) -> FilterResult { + if !self.enabled { + return FilterResult::Allow; + } + + for rule in &self.rules { + if rule.matches(path, operation, user) { + tracing::debug!( + rule_name = ?rule.name, + path = %path.display(), + operation = %operation, + user = %user, + action = %rule.action, + pattern = %rule.matcher.pattern_description(), + "Filter rule matched" + ); + return rule.action; + } + } + + tracing::trace!( + path = %path.display(), + operation = %operation, + user = %user, + action = %self.default_action, + "No filter rule matched, using default action" + ); + + self.default_action + } + + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// A thread-safe, shared filter policy. +/// +/// Use this when you need to share a filter policy across multiple handlers. +#[derive(Debug, Clone)] +pub struct SharedFilterPolicy { + inner: Arc, +} + +impl SharedFilterPolicy { + /// Create a new shared filter policy. + pub fn new(policy: FilterPolicy) -> Self { + Self { + inner: Arc::new(policy), + } + } + + /// Get a reference to the inner policy. + pub fn policy(&self) -> &FilterPolicy { + &self.inner + } +} + +impl TransferFilter for SharedFilterPolicy { + fn check(&self, path: &Path, operation: Operation, user: &str) -> FilterResult { + self.inner.check(path, operation, user) + } + + fn check_with_dest( + &self, + src: &Path, + dest: &Path, + operation: Operation, + user: &str, + ) -> FilterResult { + self.inner.check_with_dest(src, dest, operation, user) + } + + fn is_enabled(&self) -> bool { + self.inner.is_enabled() + } +} + +impl From for SharedFilterPolicy { + fn from(policy: FilterPolicy) -> Self { + SharedFilterPolicy::new(policy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::filter::path::ExactMatcher; + + #[test] + fn test_filter_rule_creation() { + let rule = FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + ) + .with_name("block-keys") + .with_operations(vec![Operation::Download, Operation::Upload]); + + assert_eq!(rule.name, Some("block-keys".to_string())); + assert_eq!(rule.action, FilterResult::Deny); + assert_eq!( + rule.operations, + Some(vec![Operation::Download, Operation::Upload]) + ); + } + + #[test] + fn test_rule_matches_operation() { + let rule = FilterRule::new(Box::new(GlobMatcher::new("*").unwrap()), FilterResult::Deny) + .with_operations(vec![Operation::Upload]); + + assert!(rule.applies_to_operation(Operation::Upload)); + assert!(!rule.applies_to_operation(Operation::Download)); + } + + #[test] + fn test_rule_matches_user() { + let rule = FilterRule::new(Box::new(GlobMatcher::new("*").unwrap()), FilterResult::Deny) + .with_users(vec!["alice".to_string(), "bob".to_string()]); + + assert!(rule.applies_to_user("alice")); + assert!(rule.applies_to_user("bob")); + assert!(!rule.applies_to_user("charlie")); + } + + #[test] + fn test_policy_default_allow() { + let policy = FilterPolicy::new(); + + assert_eq!( + policy.check(Path::new("/any/path"), Operation::Upload, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_default_deny() { + let policy = FilterPolicy::new().with_default(FilterResult::Deny); + + assert_eq!( + policy.check(Path::new("/any/path"), Operation::Upload, "user"), + FilterResult::Deny + ); + } + + #[test] + fn test_policy_rule_matching() { + let policy = FilterPolicy::new() + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + )) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.log").unwrap()), + FilterResult::Log, + )); + + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/var/log/app.log"), Operation::Download, "user"), + FilterResult::Log + ); + assert_eq!( + policy.check( + Path::new("/home/user/file.txt"), + Operation::Download, + "user" + ), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_first_match_wins() { + let policy = FilterPolicy::new() + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Allow, + )) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*").unwrap()), + FilterResult::Deny, + )); + + // *.key matches first, so Allow + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_with_user_restriction() { + let policy = FilterPolicy::new() + .add_rule( + FilterRule::new(Box::new(PrefixMatcher::new("/admin")), FilterResult::Deny) + .with_users(vec!["guest".to_string()]), + ) + .with_default(FilterResult::Allow); + + // Guest user is denied + assert_eq!( + policy.check(Path::new("/admin/config"), Operation::Download, "guest"), + FilterResult::Deny + ); + // Admin user is allowed + assert_eq!( + policy.check(Path::new("/admin/config"), Operation::Download, "admin"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_with_operation_restriction() { + let policy = FilterPolicy::new() + .add_rule( + FilterRule::new( + Box::new(GlobMatcher::new("*.log").unwrap()), + FilterResult::Deny, + ) + .with_operations(vec![Operation::Delete]), + ) + .with_default(FilterResult::Allow); + + // Delete is denied + assert_eq!( + policy.check(Path::new("/var/app.log"), Operation::Delete, "user"), + FilterResult::Deny + ); + // Download is allowed + assert_eq!( + policy.check(Path::new("/var/app.log"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_disabled() { + let policy = FilterPolicy::new() + .with_enabled(false) + .with_default(FilterResult::Deny) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*").unwrap()), + FilterResult::Deny, + )); + + // When disabled, always allow + assert_eq!( + policy.check(Path::new("/any/path"), Operation::Upload, "user"), + FilterResult::Allow + ); + assert!(!policy.is_enabled()); + } + + #[test] + fn test_shared_filter_policy() { + let policy = FilterPolicy::new().add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + )); + + let shared = SharedFilterPolicy::new(policy); + + assert_eq!( + shared.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + shared.check(Path::new("/home/file.txt"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_exact_matcher() { + let policy = FilterPolicy::new().add_rule(FilterRule::new( + Box::new(ExactMatcher::new("/etc/passwd")), + FilterResult::Deny, + )); + + assert_eq!( + policy.check(Path::new("/etc/passwd"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/etc/passwd.bak"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_rule_count_and_default_action() { + let policy = FilterPolicy::new() + .with_default(FilterResult::Deny) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.txt").unwrap()), + FilterResult::Allow, + )) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.log").unwrap()), + FilterResult::Log, + )); + + assert_eq!(policy.rule_count(), 2); + assert_eq!(policy.default_action(), FilterResult::Deny); + } + + #[test] + fn test_policy_add_rules() { + let rules = vec![ + FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + ), + FilterRule::new( + Box::new(GlobMatcher::new("*.pem").unwrap()), + FilterResult::Deny, + ), + ]; + + let policy = FilterPolicy::new().add_rules(rules); + + assert_eq!(policy.rule_count(), 2); + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/etc/cert.pem"), Operation::Download, "user"), + FilterResult::Deny + ); + } + + #[test] + fn test_from_config_with_glob_pattern() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("block-keys".to_string()), + pattern: Some("*.key".to_string()), + path_prefix: None, + action: FilterAction::Deny, + operations: Some(vec!["download".to_string()]), + users: Some(vec!["alice".to_string()]), + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert!(policy.is_enabled()); + assert_eq!(policy.rule_count(), 1); + assert_eq!(policy.default_action(), FilterResult::Allow); + + // Test that the rule works correctly + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "alice"), + FilterResult::Deny + ); + // Different user should be allowed + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "bob"), + FilterResult::Allow + ); + // Different operation should be allowed + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Upload, "alice"), + FilterResult::Allow + ); + } + + #[test] + fn test_from_config_with_prefix() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Deny), + rules: vec![FilterRuleConfig { + name: Some("allow-home".to_string()), + pattern: None, + path_prefix: Some("/home".to_string()), + action: FilterAction::Allow, + operations: None, + users: None, + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert_eq!(policy.default_action(), FilterResult::Deny); + + // Path under /home should be allowed + assert_eq!( + policy.check(Path::new("/home/user/file.txt"), Operation::Upload, "user"), + FilterResult::Allow + ); + // Path outside /home should be denied (default action) + assert_eq!( + policy.check(Path::new("/etc/passwd"), Operation::Download, "user"), + FilterResult::Deny + ); + } + + #[test] + fn test_from_config_invalid_rule() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + // Rule with neither pattern nor path_prefix should fail + let config = FilterConfig { + enabled: true, + default_action: None, + rules: vec![FilterRuleConfig { + name: Some("invalid".to_string()), + pattern: None, + path_prefix: None, + action: FilterAction::Deny, + operations: None, + users: None, + }], + }; + + let result = FilterPolicy::from_config(&config); + assert!(result.is_err()); + } + + #[test] + fn test_from_config_invalid_glob_pattern() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: true, + default_action: None, + rules: vec![FilterRuleConfig { + name: None, + pattern: Some("[".to_string()), // Invalid glob pattern + path_prefix: None, + action: FilterAction::Deny, + operations: None, + users: None, + }], + }; + + let result = FilterPolicy::from_config(&config); + assert!(result.is_err()); + } + + #[test] + fn test_from_config_disabled() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: false, + default_action: Some(FilterAction::Deny), + rules: vec![FilterRuleConfig { + name: None, + pattern: Some("*".to_string()), + path_prefix: None, + action: FilterAction::Deny, + operations: None, + users: None, + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert!(!policy.is_enabled()); + // When disabled, all operations should be allowed + assert_eq!( + policy.check(Path::new("/etc/shadow"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_shared_filter_policy_check_with_dest() { + let policy = FilterPolicy::new().add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + )); + + let shared = SharedFilterPolicy::new(policy); + + // Both paths safe + assert_eq!( + shared.check_with_dest( + Path::new("/home/src.txt"), + Path::new("/home/dest.txt"), + Operation::Rename, + "user" + ), + FilterResult::Allow + ); + + // Source is blocked + assert_eq!( + shared.check_with_dest( + Path::new("/home/secret.key"), + Path::new("/home/dest.txt"), + Operation::Rename, + "user" + ), + FilterResult::Deny + ); + + // Destination is blocked + assert_eq!( + shared.check_with_dest( + Path::new("/home/src.txt"), + Path::new("/home/secret.key"), + Operation::Rename, + "user" + ), + FilterResult::Deny + ); + } + + #[test] + fn test_shared_filter_policy_is_enabled() { + let policy = FilterPolicy::new().with_enabled(false); + let shared = SharedFilterPolicy::new(policy); + + assert!(!shared.is_enabled()); + } + + #[test] + fn test_shared_filter_policy_policy_accessor() { + let policy = FilterPolicy::new() + .with_default(FilterResult::Deny) + .add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.txt").unwrap()), + FilterResult::Allow, + )); + + let shared = SharedFilterPolicy::new(policy); + let inner = shared.policy(); + + assert_eq!(inner.rule_count(), 1); + assert_eq!(inner.default_action(), FilterResult::Deny); + } + + #[test] + fn test_shared_filter_policy_from_impl() { + let policy = FilterPolicy::new().add_rule(FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + )); + + // Test From for SharedFilterPolicy + let shared: SharedFilterPolicy = policy.into(); + + assert_eq!( + shared.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Deny + ); + } + + #[test] + fn test_filter_rule_matches_full() { + let rule = FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + ) + .with_name("block-keys") + .with_operations(vec![Operation::Download]) + .with_users(vec!["alice".to_string()]); + + // All conditions match + assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Download, "alice")); + + // Wrong operation + assert!(!rule.matches(Path::new("/etc/secret.key"), Operation::Upload, "alice")); + + // Wrong user + assert!(!rule.matches(Path::new("/etc/secret.key"), Operation::Download, "bob")); + + // Wrong path + assert!(!rule.matches(Path::new("/etc/secret.txt"), Operation::Download, "alice")); + } + + #[test] + fn test_filter_rule_matches_no_restrictions() { + let rule = FilterRule::new( + Box::new(GlobMatcher::new("*.key").unwrap()), + FilterResult::Deny, + ); + + // No operation or user restrictions - should match all operations and users + assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Download, "anyuser")); + assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Upload, "anyuser")); + assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Delete, "anyuser")); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index adfe8e00..8a2d1ba1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -48,6 +48,7 @@ pub mod audit; pub mod auth; pub mod config; pub mod exec; +pub mod filter; pub mod handler; pub mod pty; pub mod scp;