Skip to content

Commit 7a084b0

Browse files
committed
test: add comprehensive test coverage for filter module
- Add tests for FilterPolicy.from_config() with glob patterns and prefixes - Add tests for FilterPolicy accessor methods (rule_count, default_action) - Add tests for FilterRule.matches() with all conditions - Add tests for SharedFilterPolicy (check_with_dest, From impl) - Add tests for Operation.all() and additional parse/display tests - Add tests for matcher accessor methods (prefix, path, component, extension) - Add tests for GlobMatchMode enum and CombinedMatcher len/is_empty - Fix normalize_path tests placement (move inside test module) - Update ARCHITECTURE.md with File Transfer Filter module documentation
1 parent 8583162 commit 7a084b0

5 files changed

Lines changed: 591 additions & 48 deletions

File tree

ARCHITECTURE.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,107 @@ Security features for the SSH server (`src/server/security/`):
213213
- Thread-safe with fail-closed behavior on lock contention
214214
- Configuration via `allowed_ips` and `blocked_ips` in server config
215215

216+
### File Transfer Filter Module
217+
218+
Policy-based filtering infrastructure for SFTP and SCP file transfer operations (`src/server/filter/`):
219+
220+
**Structure**:
221+
- `mod.rs` - `TransferFilter` trait, `Operation` enum, `FilterResult` enum, `NoOpFilter`
222+
- `policy.rs` - `FilterPolicy` engine, `FilterRule`, `Matcher` trait, `SharedFilterPolicy`
223+
- `path.rs` - Path-based matchers: `PrefixMatcher`, `ExactMatcher`, `ComponentMatcher`, `ExtensionMatcher`
224+
- `pattern.rs` - Pattern-based matchers: `GlobMatcher`, `RegexMatcher`, `CombinedMatcher`, `NotMatcher`
225+
226+
**Key Components**:
227+
228+
- **Operation**: Enum representing file operations
229+
- `Upload`, `Download`, `Delete`, `Rename`
230+
- `CreateDir`, `ListDir`, `Stat`, `SetStat`
231+
- `Symlink`, `ReadLink`
232+
233+
- **FilterResult**: Actions to take on matched operations
234+
- `Allow` - Permit the operation (default)
235+
- `Deny` - Block the operation
236+
- `Log` - Allow but log for auditing
237+
238+
- **TransferFilter Trait**: Interface for custom filter implementations
239+
- `check(path, operation, user)` - Check single path operations
240+
- `check_with_dest(src, dest, operation, user)` - Check two-path operations (rename, symlink)
241+
- `is_enabled()` - Check if filtering is active
242+
243+
- **FilterPolicy**: First-match-wins rule evaluation engine
244+
- Ordered rule evaluation
245+
- Configurable default action
246+
- Enable/disable filtering
247+
- Create from YAML configuration via `from_config()`
248+
249+
- **FilterRule**: Combines matcher, action, and optional constraints
250+
- Path pattern matcher
251+
- Per-operation restrictions
252+
- Per-user restrictions
253+
- Named rules for debugging
254+
255+
**Built-in Matchers**:
256+
257+
| Matcher | Purpose | Example |
258+
|---------|---------|---------|
259+
| `GlobMatcher` | Wildcard patterns | `*.key`, `*.pem` |
260+
| `RegexMatcher` | Full regex support | `(?i)\.exe$` |
261+
| `PrefixMatcher` | Directory tree matching | `/etc/` |
262+
| `ExactMatcher` | Specific file matching | `/etc/shadow` |
263+
| `ComponentMatcher` | Path component matching | `.git`, `.ssh` |
264+
| `ExtensionMatcher` | File extension matching | `exe`, `key` |
265+
| `CombinedMatcher` | OR-combine matchers | Multiple patterns |
266+
| `NotMatcher` | Invert matcher results | Exclude patterns |
267+
268+
**Security Features**:
269+
- `normalize_path()` function for path traversal prevention
270+
- ReDoS protection via regex size limits
271+
- Case-insensitive extension matching
272+
273+
**Usage Example**:
274+
```rust
275+
use bssh::server::filter::{FilterPolicy, FilterResult, Operation};
276+
use bssh::server::filter::pattern::GlobMatcher;
277+
use bssh::server::filter::policy::FilterRule;
278+
use std::path::Path;
279+
280+
// Create policy that blocks *.key files
281+
let policy = FilterPolicy::new()
282+
.with_default(FilterResult::Allow)
283+
.add_rule(FilterRule::new(
284+
Box::new(GlobMatcher::new("*.key").unwrap()),
285+
FilterResult::Deny,
286+
));
287+
288+
// Check if operation is allowed
289+
let result = policy.check(
290+
Path::new("/etc/secret.key"),
291+
Operation::Download,
292+
"alice"
293+
);
294+
assert_eq!(result, FilterResult::Deny);
295+
```
296+
297+
**Configuration** (YAML):
298+
```yaml
299+
filter:
300+
enabled: true
301+
default_action: allow
302+
rules:
303+
- name: block-sensitive-keys
304+
pattern: "*.{key,pem}"
305+
action: deny
306+
operations:
307+
- download
308+
- upload
309+
- name: block-hidden-dirs
310+
path_prefix: "/home"
311+
pattern: ".*"
312+
action: deny
313+
users:
314+
- guest
315+
```
316+
216317
### Audit Logging Module
217318
218319
Comprehensive audit logging infrastructure for the SSH server (`src/server/audit/`):

src/server/filter/mod.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ pub enum FilterResult {
161161
Log,
162162
}
163163

164-
165164
impl fmt::Display for FilterResult {
166165
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167166
match self {
@@ -262,7 +261,10 @@ mod tests {
262261
#[test]
263262
fn test_operation_parse() {
264263
assert_eq!("upload".parse::<Operation>().unwrap(), Operation::Upload);
265-
assert_eq!("DOWNLOAD".parse::<Operation>().unwrap(), Operation::Download);
264+
assert_eq!(
265+
"DOWNLOAD".parse::<Operation>().unwrap(),
266+
Operation::Download
267+
);
266268
assert_eq!("mkdir".parse::<Operation>().unwrap(), Operation::CreateDir);
267269
assert_eq!("readdir".parse::<Operation>().unwrap(), Operation::ListDir);
268270
assert!("invalid".parse::<Operation>().is_err());
@@ -376,4 +378,62 @@ mod tests {
376378
FilterResult::Log
377379
);
378380
}
381+
382+
#[test]
383+
fn test_operation_all() {
384+
let all_ops = Operation::all();
385+
386+
// Should contain all 10 operations
387+
assert_eq!(all_ops.len(), 10);
388+
389+
// Verify all operations are included
390+
assert!(all_ops.contains(&Operation::Upload));
391+
assert!(all_ops.contains(&Operation::Download));
392+
assert!(all_ops.contains(&Operation::Delete));
393+
assert!(all_ops.contains(&Operation::Rename));
394+
assert!(all_ops.contains(&Operation::CreateDir));
395+
assert!(all_ops.contains(&Operation::ListDir));
396+
assert!(all_ops.contains(&Operation::Stat));
397+
assert!(all_ops.contains(&Operation::SetStat));
398+
assert!(all_ops.contains(&Operation::Symlink));
399+
assert!(all_ops.contains(&Operation::ReadLink));
400+
}
401+
402+
#[test]
403+
fn test_operation_display_all() {
404+
// Test all operations have a string representation
405+
assert_eq!(Operation::Stat.to_string(), "stat");
406+
assert_eq!(Operation::SetStat.to_string(), "setstat");
407+
assert_eq!(Operation::Symlink.to_string(), "symlink");
408+
assert_eq!(Operation::ReadLink.to_string(), "readlink");
409+
}
410+
411+
#[test]
412+
fn test_operation_parse_all_variants() {
413+
// Test parsing all valid variants
414+
assert_eq!("stat".parse::<Operation>().unwrap(), Operation::Stat);
415+
assert_eq!("setstat".parse::<Operation>().unwrap(), Operation::SetStat);
416+
assert_eq!("symlink".parse::<Operation>().unwrap(), Operation::Symlink);
417+
assert_eq!(
418+
"readlink".parse::<Operation>().unwrap(),
419+
Operation::ReadLink
420+
);
421+
422+
// Test case insensitivity
423+
assert_eq!("STAT".parse::<Operation>().unwrap(), Operation::Stat);
424+
assert_eq!("SetStat".parse::<Operation>().unwrap(), Operation::SetStat);
425+
}
426+
427+
#[test]
428+
fn test_noop_filter_default() {
429+
let filter = NoOpFilter::default();
430+
assert!(!filter.is_enabled());
431+
}
432+
433+
#[test]
434+
fn test_noop_filter_clone() {
435+
let filter = NoOpFilter;
436+
let cloned = filter.clone();
437+
assert!(!cloned.is_enabled());
438+
}
379439
}

src/server/filter/path.rs

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ use super::policy::Matcher;
8181
/// ```
8282
pub fn normalize_path(path: &Path) -> PathBuf {
8383
let mut result = PathBuf::new();
84-
84+
8585
for component in path.components() {
8686
match component {
8787
Component::Prefix(p) => result.push(p.as_os_str()),
@@ -99,7 +99,7 @@ pub fn normalize_path(path: &Path) -> PathBuf {
9999
Component::Normal(name) => result.push(name),
100100
}
101101
}
102-
102+
103103
if result.as_os_str().is_empty() {
104104
PathBuf::from(".")
105105
} else {
@@ -495,25 +495,39 @@ mod tests {
495495
assert!(component.matches(test_path));
496496
assert!(extension.matches(test_path));
497497
}
498-
}
499498

500499
#[test]
501500
fn test_normalize_path_removes_dot() {
502-
assert_eq!(normalize_path(Path::new("/etc/./passwd")), Path::new("/etc/passwd"));
503-
assert_eq!(normalize_path(Path::new("./foo/./bar")), Path::new("foo/bar"));
501+
assert_eq!(
502+
normalize_path(Path::new("/etc/./passwd")),
503+
Path::new("/etc/passwd")
504+
);
505+
assert_eq!(
506+
normalize_path(Path::new("./foo/./bar")),
507+
Path::new("foo/bar")
508+
);
504509
}
505510

506511
#[test]
507512
fn test_normalize_path_resolves_parent() {
508513
assert_eq!(normalize_path(Path::new("/etc/../var")), Path::new("/var"));
509-
assert_eq!(normalize_path(Path::new("/etc/ssh/../passwd")), Path::new("/etc/passwd"));
510-
assert_eq!(normalize_path(Path::new("/a/b/c/../../d")), Path::new("/a/d"));
514+
assert_eq!(
515+
normalize_path(Path::new("/etc/ssh/../passwd")),
516+
Path::new("/etc/passwd")
517+
);
518+
assert_eq!(
519+
normalize_path(Path::new("/a/b/c/../../d")),
520+
Path::new("/a/d")
521+
);
511522
}
512523

513524
#[test]
514525
fn test_normalize_path_traversal_at_root() {
515526
// At root, .. should be ignored
516-
assert_eq!(normalize_path(Path::new("/../etc/passwd")), Path::new("/etc/passwd"));
527+
assert_eq!(
528+
normalize_path(Path::new("/../etc/passwd")),
529+
Path::new("/etc/passwd")
530+
);
517531
assert_eq!(normalize_path(Path::new("/../../etc")), Path::new("/etc"));
518532
}
519533

@@ -533,13 +547,39 @@ mod tests {
533547
fn test_normalize_path_security() {
534548
// This is the key security test: path traversal should be normalized
535549
let matcher = PrefixMatcher::new("/etc");
536-
550+
537551
// Without normalization, this would NOT match /etc (attack succeeds)
538552
let attack_path = Path::new("/var/../etc/passwd");
539553
assert!(!matcher.matches(attack_path)); // Raw path doesn't match
540-
554+
541555
// With normalization, it correctly matches /etc (attack blocked)
542556
let normalized = normalize_path(attack_path);
543557
assert!(matcher.matches(&normalized)); // Normalized path matches
544558
assert_eq!(normalized, Path::new("/etc/passwd"));
545559
}
560+
561+
#[test]
562+
fn test_prefix_matcher_accessor() {
563+
let matcher = PrefixMatcher::new("/home/user");
564+
assert_eq!(matcher.prefix(), Path::new("/home/user"));
565+
}
566+
567+
#[test]
568+
fn test_exact_matcher_accessor() {
569+
let matcher = ExactMatcher::new("/etc/shadow");
570+
assert_eq!(matcher.path(), Path::new("/etc/shadow"));
571+
}
572+
573+
#[test]
574+
fn test_component_matcher_accessor() {
575+
let matcher = ComponentMatcher::new(".git");
576+
assert_eq!(matcher.component(), ".git");
577+
}
578+
579+
#[test]
580+
fn test_extension_matcher_accessor() {
581+
let matcher = ExtensionMatcher::new("PDF");
582+
// Extension is stored in lowercase
583+
assert_eq!(matcher.extension(), "pdf");
584+
}
585+
}

src/server/filter/pattern.rs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,8 @@ impl Matcher for GlobMatcher {
176176
// Also try matching just the filename for patterns like "*.key"
177177
self.matches_filename(path)
178178
}
179-
GlobMatchMode::FullPathOnly => {
180-
self.pattern.matches_path(path)
181-
}
182-
GlobMatchMode::FilenameOnly => {
183-
self.matches_filename(path)
184-
}
179+
GlobMatchMode::FullPathOnly => self.pattern.matches_path(path),
180+
GlobMatchMode::FilenameOnly => self.matches_filename(path),
185181
}
186182
}
187183

@@ -363,7 +359,11 @@ impl Matcher for CombinedMatcher {
363359
}
364360

365361
fn pattern_description(&self) -> String {
366-
let descriptions: Vec<_> = self.matchers.iter().map(|m| m.pattern_description()).collect();
362+
let descriptions: Vec<_> = self
363+
.matchers
364+
.iter()
365+
.map(|m| m.pattern_description())
366+
.collect();
367367
format!("any_of:[{}]", descriptions.join(", "))
368368
}
369369
}
@@ -630,7 +630,7 @@ mod tests {
630630
let matcher = GlobMatcher::with_mode("*.key", GlobMatchMode::FullPathOnly).unwrap();
631631

632632
assert!(matcher.matches(Path::new("secret.key"))); // Direct match
633-
// * in glob matches path separators too, so this actually matches
633+
// * in glob matches path separators too, so this actually matches
634634
assert!(matcher.matches(Path::new("/etc/secret.key")));
635635
}
636636

@@ -653,4 +653,41 @@ mod tests {
653653
assert!(matcher.matches(Path::new("/etc/ssl/private.key"))); // Matches via filename
654654
assert_eq!(matcher.mode(), GlobMatchMode::PathOrFilename);
655655
}
656+
657+
#[test]
658+
fn test_glob_matcher_pattern_accessor() {
659+
let matcher = GlobMatcher::new("*.{key,pem}").unwrap();
660+
assert_eq!(matcher.pattern(), "*.{key,pem}");
661+
}
662+
663+
#[test]
664+
fn test_regex_matcher_pattern_accessor() {
665+
let matcher = RegexMatcher::new(r"(?i)\.key$").unwrap();
666+
assert_eq!(matcher.pattern(), r"(?i)\.key$");
667+
}
668+
669+
#[test]
670+
fn test_combined_matcher_len_and_is_empty() {
671+
let empty = CombinedMatcher::new(vec![]);
672+
assert!(empty.is_empty());
673+
assert_eq!(empty.len(), 0);
674+
675+
let non_empty = CombinedMatcher::new(vec![
676+
Box::new(GlobMatcher::new("*.key").unwrap()),
677+
Box::new(GlobMatcher::new("*.pem").unwrap()),
678+
]);
679+
assert!(!non_empty.is_empty());
680+
assert_eq!(non_empty.len(), 2);
681+
}
682+
683+
#[test]
684+
fn test_glob_match_mode_enum() {
685+
// Test that GlobMatchMode implements Default correctly
686+
assert_eq!(GlobMatchMode::default(), GlobMatchMode::PathOrFilename);
687+
688+
// Test each mode
689+
assert_ne!(GlobMatchMode::PathOrFilename, GlobMatchMode::FullPathOnly);
690+
assert_ne!(GlobMatchMode::PathOrFilename, GlobMatchMode::FilenameOnly);
691+
assert_ne!(GlobMatchMode::FullPathOnly, GlobMatchMode::FilenameOnly);
692+
}
656693
}

0 commit comments

Comments
 (0)