Skip to main content

exarch_core/security/
validator.rs

1//! Entry validation orchestrator.
2//!
3//! This module provides the main `EntryValidator` type that coordinates all
4//! security validations for archive entries.
5
6use std::path::Path;
7
8use crate::Result;
9use crate::SecurityConfig;
10use crate::formats::common::DirCache;
11use crate::security::context::ValidationContext;
12use crate::security::hardlink::HardlinkTracker;
13use crate::security::permissions::sanitize_permissions;
14use crate::security::quota::QuotaTracker;
15use crate::security::symlink::validate_symlink;
16use crate::security::zipbomb::validate_compression_ratio;
17use crate::types::DestDir;
18use crate::types::EntryType;
19use crate::types::SafePath;
20use crate::types::SafeSymlink;
21
22/// Result of entry validation.
23///
24/// Contains validated and sanitized entry information ready for extraction.
25#[derive(Debug)]
26pub struct ValidatedEntry {
27    /// Validated path within destination directory
28    pub safe_path: SafePath,
29
30    /// Validated entry type
31    pub entry_type: ValidatedEntryType,
32
33    /// Sanitized file permissions (if applicable)
34    pub mode: Option<u32>,
35}
36
37/// Validated entry type variants.
38#[derive(Debug)]
39pub enum ValidatedEntryType {
40    /// Regular file
41    File,
42
43    /// Directory
44    Directory,
45
46    /// Validated symlink
47    Symlink(SafeSymlink),
48
49    /// Hardlink (validated in tracker, target path stored for two-pass)
50    Hardlink {
51        /// Target path (already validated)
52        target: SafePath,
53    },
54}
55
56/// Orchestrates security validation for archive entries.
57///
58/// This type maintains state across entry validations:
59/// - Quota tracking (file count, total size)
60/// - Compression ratio monitoring (zip bomb detection)
61/// - Hardlink target tracking
62/// - Symlink-seen flag (for canonicalize optimization)
63///
64/// # Lifecycle
65///
66/// 1. Create with `EntryValidator::new(&config, &dest)`
67/// 2. For each entry, call `validate_entry()`
68/// 3. After all entries processed, call `finish()` for final report
69///
70/// # Examples
71///
72/// ```no_run
73/// use exarch_core::SecurityConfig;
74/// use exarch_core::security::EntryValidator;
75/// use exarch_core::types::DestDir;
76/// use exarch_core::types::EntryType;
77/// use std::path::Path;
78/// use std::path::PathBuf;
79///
80/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
81/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
82/// let config = SecurityConfig::default();
83///
84/// let mut validator = EntryValidator::new(&config, &dest);
85///
86/// // Validate a file entry
87/// let entry = validator.validate_entry(
88///     Path::new("foo/bar.txt"),
89///     &EntryType::File,
90///     1024,        // uncompressed size
91///     Some(512),   // compressed size
92///     Some(0o644), // mode
93///     None,        // dir_cache
94/// )?;
95///
96/// let report = validator.finish();
97/// println!("Validated {} files", report.files_validated);
98/// # Ok(())
99/// # }
100/// ```
101/// OPT-H004: Validator uses references to avoid cloning config and dest.
102/// This eliminates 1 clone per extraction (`SecurityConfig` + `DestDir`).
103pub struct EntryValidator<'a> {
104    config: &'a SecurityConfig,
105    dest: &'a DestDir,
106    quota_tracker: QuotaTracker,
107    hardlink_tracker: HardlinkTracker,
108    symlink_seen: bool,
109}
110
111impl<'a> EntryValidator<'a> {
112    /// Creates a new entry validator with the given security configuration.
113    #[must_use]
114    pub fn new(config: &'a SecurityConfig, dest: &'a DestDir) -> Self {
115        Self {
116            config,
117            dest,
118            quota_tracker: QuotaTracker::new(),
119            hardlink_tracker: HardlinkTracker::new(),
120            symlink_seen: false,
121        }
122    }
123
124    /// Validates an archive entry.
125    ///
126    /// This method orchestrates all security validations:
127    /// 1. Path validation (traversal, depth, banned components)
128    /// 2. Quota checking (file size, count, total size)
129    /// 3. Compression ratio validation (zip bomb detection)
130    /// 4. Type-specific validation (symlink, hardlink, permissions)
131    ///
132    /// When `dir_cache` is provided, path validation can skip expensive
133    /// `canonicalize()` syscalls for parents that were created by us.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if any validation fails. Common errors:
138    /// - `ArchiveError::PathTraversal` - Path escapes destination
139    /// - `ArchiveError::QuotaExceeded` - Size or count limits exceeded
140    /// - `ArchiveError::ZipBomb` - Compression ratio too high
141    /// - `ArchiveError::SymlinkEscape` - Symlink target escapes
142    /// - `ArchiveError::HardlinkEscape` - Hardlink target escapes
143    /// - `ArchiveError::InvalidPermissions` - Dangerous permissions
144    pub fn validate_entry(
145        &mut self,
146        path: &Path,
147        entry_type: &EntryType,
148        uncompressed_size: u64,
149        compressed_size: Option<u64>,
150        mode: Option<u32>,
151        dir_cache: Option<&DirCache>,
152    ) -> Result<ValidatedEntry> {
153        let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
154        if let Some(cache) = dir_cache {
155            ctx = ctx.with_dir_cache(cache);
156        }
157        if self.symlink_seen {
158            ctx.mark_symlink_seen();
159        }
160
161        let safe_path = SafePath::validate_with_context(path, self.dest, self.config, &ctx)?;
162
163        if matches!(entry_type, EntryType::File) {
164            self.quota_tracker
165                .record_file(uncompressed_size, self.config)?;
166        }
167
168        if let Some(compressed) = compressed_size {
169            validate_compression_ratio(compressed, uncompressed_size, self.config)?;
170        }
171
172        let (validated_type, sanitized_mode) = match entry_type {
173            EntryType::File => {
174                let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
175                (ValidatedEntryType::File, sanitized)
176            }
177
178            EntryType::Directory => (ValidatedEntryType::Directory, None),
179
180            EntryType::Symlink { target } => {
181                let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
182                self.symlink_seen = true;
183                (ValidatedEntryType::Symlink(safe_symlink), None)
184            }
185
186            EntryType::Hardlink { target } => {
187                // Hardlink tracker validates: absolute paths, traversal, normalization, escapes
188                self.hardlink_tracker.validate_hardlink(
189                    &safe_path,
190                    target,
191                    self.dest,
192                    self.config,
193                )?;
194
195                // SAFETY: validate_hardlink verified target is relative, normalized, within
196                // dest
197                let target_safe = SafePath::new_unchecked(target.clone());
198
199                (
200                    ValidatedEntryType::Hardlink {
201                        target: target_safe,
202                    },
203                    None,
204                )
205            }
206        };
207
208        Ok(ValidatedEntry {
209            safe_path,
210            entry_type: validated_type,
211            mode: sanitized_mode,
212        })
213    }
214
215    /// Finishes validation and returns a summary report.
216    ///
217    /// This consumes the validator and returns statistics about the
218    /// validation process.
219    #[must_use]
220    pub fn finish(self) -> ValidationReport {
221        ValidationReport {
222            files_validated: self.quota_tracker.files_extracted(),
223            total_bytes: self.quota_tracker.bytes_written(),
224            hardlinks_tracked: self.hardlink_tracker.count(),
225        }
226    }
227}
228
229/// Summary report of validation process.
230#[derive(Debug)]
231pub struct ValidationReport {
232    /// Number of files validated
233    pub files_validated: usize,
234
235    /// Total bytes processed
236    pub total_bytes: u64,
237
238    /// Number of hardlinks tracked
239    pub hardlinks_tracked: usize,
240}
241
242#[cfg(test)]
243#[allow(
244    clippy::unwrap_used,
245    clippy::expect_used,
246    clippy::field_reassign_with_default
247)]
248mod tests {
249    use super::*;
250    use std::path::PathBuf;
251    use tempfile::TempDir;
252
253    #[test]
254    fn test_entry_validator_new() {
255        let temp = TempDir::new().expect("failed to create temp dir");
256        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
257        let config = SecurityConfig::default();
258        let validator = EntryValidator::new(&config, &dest);
259        let report = validator.finish();
260        assert_eq!(report.files_validated, 0);
261        assert_eq!(report.total_bytes, 0);
262        assert_eq!(report.hardlinks_tracked, 0);
263    }
264
265    #[test]
266    fn test_validate_file_entry() {
267        let temp = TempDir::new().expect("failed to create temp dir");
268        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
269        let config = SecurityConfig::default();
270        let mut validator = EntryValidator::new(&config, &dest);
271
272        let result = validator.validate_entry(
273            Path::new("file.txt"),
274            &EntryType::File,
275            1024,
276            None,
277            Some(0o644),
278            None,
279        );
280
281        assert!(result.is_ok());
282        let entry = result.unwrap();
283        assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
284        assert!(matches!(entry.entry_type, ValidatedEntryType::File));
285        assert_eq!(entry.mode, Some(0o644));
286    }
287
288    #[test]
289    fn test_validate_directory_entry() {
290        let temp = TempDir::new().expect("failed to create temp dir");
291        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
292        let config = SecurityConfig::default();
293        let mut validator = EntryValidator::new(&config, &dest);
294
295        let result =
296            validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
297
298        assert!(result.is_ok());
299        let entry = result.unwrap();
300        assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
301        assert!(entry.mode.is_none());
302    }
303
304    #[test]
305    fn test_validate_path_traversal_rejected() {
306        let temp = TempDir::new().expect("failed to create temp dir");
307        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
308        let config = SecurityConfig::default();
309        let mut validator = EntryValidator::new(&config, &dest);
310
311        let result = validator.validate_entry(
312            Path::new("../etc/passwd"),
313            &EntryType::File,
314            1024,
315            None,
316            Some(0o644),
317            None,
318        );
319
320        assert!(result.is_err());
321    }
322
323    #[test]
324    fn test_quota_exceeded_file_size() {
325        let temp = TempDir::new().unwrap();
326        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
327        let mut config = SecurityConfig::default();
328        config.max_file_size = 100;
329        let mut validator = EntryValidator::new(&config, &dest);
330
331        let result = validator.validate_entry(
332            Path::new("large.txt"),
333            &EntryType::File,
334            1000,
335            None,
336            Some(0o644),
337            None,
338        );
339
340        assert!(result.is_err());
341    }
342
343    #[test]
344    fn test_quota_exceeded_file_count() {
345        let temp = TempDir::new().unwrap();
346        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
347        let mut config = SecurityConfig::default();
348        config.max_file_count = 2;
349        let mut validator = EntryValidator::new(&config, &dest);
350
351        assert!(
352            validator
353                .validate_entry(
354                    Path::new("file1.txt"),
355                    &EntryType::File,
356                    100,
357                    None,
358                    Some(0o644),
359                    None,
360                )
361                .is_ok()
362        );
363        assert!(
364            validator
365                .validate_entry(
366                    Path::new("file2.txt"),
367                    &EntryType::File,
368                    100,
369                    None,
370                    Some(0o644),
371                    None,
372                )
373                .is_ok()
374        );
375
376        let result = validator.validate_entry(
377            Path::new("file3.txt"),
378            &EntryType::File,
379            100,
380            None,
381            Some(0o644),
382            None,
383        );
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn test_zip_bomb_detected() {
389        let temp = TempDir::new().expect("failed to create temp dir");
390        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
391        let config = SecurityConfig::default();
392        let mut validator = EntryValidator::new(&config, &dest);
393
394        let result = validator.validate_entry(
395            Path::new("bomb.txt"),
396            &EntryType::File,
397            1_000_000,
398            Some(100),
399            Some(0o644),
400            None,
401        );
402
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_validation_report() {
408        let temp = TempDir::new().expect("failed to create temp dir");
409        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
410        let config = SecurityConfig::default();
411        let mut validator = EntryValidator::new(&config, &dest);
412
413        validator
414            .validate_entry(
415                Path::new("file1.txt"),
416                &EntryType::File,
417                1024,
418                None,
419                Some(0o644),
420                None,
421            )
422            .unwrap();
423
424        validator
425            .validate_entry(
426                Path::new("file2.txt"),
427                &EntryType::File,
428                2048,
429                None,
430                Some(0o644),
431                None,
432            )
433            .unwrap();
434
435        let report = validator.finish();
436        assert_eq!(report.files_validated, 2);
437        assert_eq!(report.total_bytes, 1024 + 2048);
438    }
439
440    #[test]
441    fn test_sanitize_permissions_setuid() {
442        let temp = TempDir::new().expect("failed to create temp dir");
443        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
444        let config = SecurityConfig::default();
445        let mut validator = EntryValidator::new(&config, &dest);
446
447        let result = validator.validate_entry(
448            Path::new("file.txt"),
449            &EntryType::File,
450            1024,
451            None,
452            Some(0o4755),
453            None,
454        );
455
456        assert!(result.is_ok());
457        let entry = result.unwrap();
458        assert_eq!(entry.mode, Some(0o755)); // setuid stripped
459    }
460
461    #[test]
462    fn test_symlink_validation() {
463        let temp = TempDir::new().unwrap();
464        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
465        let mut config = SecurityConfig::default();
466        config.allowed.symlinks = true;
467        let mut validator = EntryValidator::new(&config, &dest);
468
469        let result = validator.validate_entry(
470            Path::new("link"),
471            &EntryType::Symlink {
472                target: PathBuf::from("target.txt"),
473            },
474            0,
475            None,
476            None,
477            None,
478        );
479
480        assert!(result.is_ok());
481        let entry = result.unwrap();
482        assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
483        assert!(validator.symlink_seen);
484    }
485
486    #[test]
487    fn test_hardlink_validation() {
488        let temp = TempDir::new().unwrap();
489        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
490        let mut config = SecurityConfig::default();
491        config.allowed.hardlinks = true;
492        let mut validator = EntryValidator::new(&config, &dest);
493
494        let result = validator.validate_entry(
495            Path::new("link"),
496            &EntryType::Hardlink {
497                target: PathBuf::from("target.txt"),
498            },
499            0,
500            None,
501            None,
502            None,
503        );
504
505        assert!(result.is_ok());
506        let entry = result.unwrap();
507        assert!(matches!(
508            entry.entry_type,
509            ValidatedEntryType::Hardlink { .. }
510        ));
511    }
512
513    #[test]
514    fn test_multiple_entries_with_report() {
515        let temp = TempDir::new().unwrap();
516        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
517        let mut config = SecurityConfig::default();
518        config.allowed.hardlinks = true;
519        let mut validator = EntryValidator::new(&config, &dest);
520
521        // Validate multiple entry types
522        validator
523            .validate_entry(
524                Path::new("file1.txt"),
525                &EntryType::File,
526                1024,
527                None,
528                Some(0o644),
529                None,
530            )
531            .unwrap();
532
533        validator
534            .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
535            .unwrap();
536
537        validator
538            .validate_entry(
539                Path::new("hardlink"),
540                &EntryType::Hardlink {
541                    target: PathBuf::from("file1.txt"),
542                },
543                0,
544                None,
545                None,
546                None,
547            )
548            .unwrap();
549
550        let report = validator.finish();
551        assert_eq!(report.files_validated, 1); // Only files counted
552        assert_eq!(report.total_bytes, 1024);
553        assert_eq!(report.hardlinks_tracked, 1);
554    }
555
556    // M-TEST-1: Empty directory handling
557    #[test]
558    fn test_empty_directory_validation() {
559        let temp = TempDir::new().expect("failed to create temp dir");
560        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
561        let config = SecurityConfig::default();
562        let mut validator = EntryValidator::new(&config, &dest);
563
564        // Empty directory should be valid
565        let result = validator.validate_entry(
566            Path::new("empty_dir/"),
567            &EntryType::Directory,
568            0,
569            None,
570            None,
571            None,
572        );
573
574        assert!(result.is_ok(), "empty directory should be valid");
575        let entry = result.unwrap();
576        assert!(
577            matches!(entry.entry_type, ValidatedEntryType::Directory),
578            "should be directory type"
579        );
580        assert!(entry.mode.is_none(), "directory should not have mode set");
581    }
582
583    #[test]
584    fn test_nested_empty_directories() {
585        let temp = TempDir::new().expect("failed to create temp dir");
586        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
587        let config = SecurityConfig::default();
588        let mut validator = EntryValidator::new(&config, &dest);
589
590        // Multiple nested empty directories
591        let dirs = ["a/", "a/b/", "a/b/c/"];
592        for dir in &dirs {
593            let result = validator.validate_entry(
594                Path::new(dir),
595                &EntryType::Directory,
596                0,
597                None,
598                None,
599                None,
600            );
601            assert!(result.is_ok(), "nested directory {dir} should be valid");
602        }
603
604        let report = validator.finish();
605        assert_eq!(
606            report.files_validated, 0,
607            "directories are not counted as files"
608        );
609    }
610
611    // OPT-H004: Test validator uses references (no cloning)
612    #[test]
613    fn test_validator_uses_references() {
614        let temp = TempDir::new().unwrap();
615        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
616        let config = SecurityConfig::default();
617
618        // Create validator with references
619        let validator = EntryValidator::new(&config, &dest);
620
621        // Verify config and dest are still accessible (not moved)
622        assert_eq!(
623            config.max_file_size,
624            SecurityConfig::default().max_file_size
625        );
626        // Note: dest.as_path() may be canonicalized on macOS (/var vs /private/var)
627        // Just verify dest is still accessible
628        let _ = dest.as_path();
629
630        // Validator can still be used
631        drop(validator);
632    }
633
634    // OPT-H004: Test multiple validators can share same config
635    #[test]
636    fn test_multiple_validators_share_config() {
637        let temp1 = TempDir::new().unwrap();
638        let temp2 = TempDir::new().unwrap();
639        let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
640        let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
641        let config = SecurityConfig::default();
642
643        // Create two validators sharing the same config reference
644        let mut validator1 = EntryValidator::new(&config, &dest1);
645        let mut validator2 = EntryValidator::new(&config, &dest2);
646
647        // Both validators work independently
648        let result1 = validator1.validate_entry(
649            Path::new("file1.txt"),
650            &EntryType::File,
651            1024,
652            None,
653            Some(0o644),
654            None,
655        );
656        assert!(result1.is_ok());
657
658        let result2 = validator2.validate_entry(
659            Path::new("file2.txt"),
660            &EntryType::File,
661            2048,
662            None,
663            Some(0o644),
664            None,
665        );
666        assert!(result2.is_ok());
667
668        // Config is still accessible
669        assert_eq!(
670            config.max_file_size,
671            SecurityConfig::default().max_file_size
672        );
673    }
674
675    #[test]
676    fn test_validate_entry_with_dir_cache() {
677        let temp = TempDir::new().expect("failed to create temp dir");
678        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
679        let config = SecurityConfig::default();
680        let mut validator = EntryValidator::new(&config, &dest);
681
682        let sub = dest.as_path().join("subdir");
683        let mut dir_cache = DirCache::new();
684        dir_cache.ensure_dir(&sub).expect("should create dir");
685
686        let result = validator.validate_entry(
687            Path::new("subdir/file.txt"),
688            &EntryType::File,
689            100,
690            None,
691            Some(0o644),
692            Some(&dir_cache),
693        );
694        assert!(
695            result.is_ok(),
696            "entry with dir_cache should validate: {result:?}"
697        );
698    }
699
700    #[test]
701    fn test_symlink_seen_flag_propagates() {
702        let temp = TempDir::new().unwrap();
703        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
704        let mut config = SecurityConfig::default();
705        config.allowed.symlinks = true;
706        let mut validator = EntryValidator::new(&config, &dest);
707
708        assert!(!validator.symlink_seen);
709
710        // Validate a symlink entry
711        validator
712            .validate_entry(
713                Path::new("link"),
714                &EntryType::Symlink {
715                    target: PathBuf::from("target.txt"),
716                },
717                0,
718                None,
719                None,
720                None,
721            )
722            .unwrap();
723
724        assert!(validator.symlink_seen);
725    }
726}