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    /// - `ExtractionError::PathTraversal` - Path escapes destination
139    /// - `ExtractionError::QuotaExceeded` - Size or count limits exceeded
140    /// - `ExtractionError::ZipBomb` - Compression ratio too high
141    /// - `ExtractionError::SymlinkEscape` - Symlink target escapes
142    /// - `ExtractionError::HardlinkEscape` - Hardlink target escapes
143    /// - `ExtractionError::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 = if let Some(m) = mode {
175                    Some(sanitize_permissions(safe_path.as_path(), m, self.config)?)
176                } else {
177                    None
178                };
179                (ValidatedEntryType::File, sanitized)
180            }
181
182            EntryType::Directory => (ValidatedEntryType::Directory, None),
183
184            EntryType::Symlink { target } => {
185                let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
186                self.symlink_seen = true;
187                (ValidatedEntryType::Symlink(safe_symlink), None)
188            }
189
190            EntryType::Hardlink { target } => {
191                // Hardlink tracker validates: absolute paths, traversal, normalization, escapes
192                self.hardlink_tracker.validate_hardlink(
193                    &safe_path,
194                    target,
195                    self.dest,
196                    self.config,
197                )?;
198
199                // SAFETY: validate_hardlink verified target is relative, normalized, within
200                // dest
201                let target_safe = SafePath::new_unchecked(target.clone());
202
203                (
204                    ValidatedEntryType::Hardlink {
205                        target: target_safe,
206                    },
207                    None,
208                )
209            }
210        };
211
212        Ok(ValidatedEntry {
213            safe_path,
214            entry_type: validated_type,
215            mode: sanitized_mode,
216        })
217    }
218
219    /// Finishes validation and returns a summary report.
220    ///
221    /// This consumes the validator and returns statistics about the
222    /// validation process.
223    #[must_use]
224    pub fn finish(self) -> ValidationReport {
225        ValidationReport {
226            files_validated: self.quota_tracker.files_extracted(),
227            total_bytes: self.quota_tracker.bytes_written(),
228            hardlinks_tracked: self.hardlink_tracker.count(),
229        }
230    }
231}
232
233/// Summary report of validation process.
234#[derive(Debug)]
235pub struct ValidationReport {
236    /// Number of files validated
237    pub files_validated: usize,
238
239    /// Total bytes processed
240    pub total_bytes: u64,
241
242    /// Number of hardlinks tracked
243    pub hardlinks_tracked: usize,
244}
245
246#[cfg(test)]
247#[allow(
248    clippy::unwrap_used,
249    clippy::expect_used,
250    clippy::field_reassign_with_default
251)]
252mod tests {
253    use super::*;
254    use std::path::PathBuf;
255    use tempfile::TempDir;
256
257    #[test]
258    fn test_entry_validator_new() {
259        let temp = TempDir::new().expect("failed to create temp dir");
260        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
261        let config = SecurityConfig::default();
262        let validator = EntryValidator::new(&config, &dest);
263        let report = validator.finish();
264        assert_eq!(report.files_validated, 0);
265        assert_eq!(report.total_bytes, 0);
266        assert_eq!(report.hardlinks_tracked, 0);
267    }
268
269    #[test]
270    fn test_validate_file_entry() {
271        let temp = TempDir::new().expect("failed to create temp dir");
272        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
273        let config = SecurityConfig::default();
274        let mut validator = EntryValidator::new(&config, &dest);
275
276        let result = validator.validate_entry(
277            Path::new("file.txt"),
278            &EntryType::File,
279            1024,
280            None,
281            Some(0o644),
282            None,
283        );
284
285        assert!(result.is_ok());
286        let entry = result.unwrap();
287        assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
288        assert!(matches!(entry.entry_type, ValidatedEntryType::File));
289        assert_eq!(entry.mode, Some(0o644));
290    }
291
292    #[test]
293    fn test_validate_directory_entry() {
294        let temp = TempDir::new().expect("failed to create temp dir");
295        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
296        let config = SecurityConfig::default();
297        let mut validator = EntryValidator::new(&config, &dest);
298
299        let result =
300            validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
301
302        assert!(result.is_ok());
303        let entry = result.unwrap();
304        assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
305        assert!(entry.mode.is_none());
306    }
307
308    #[test]
309    fn test_validate_path_traversal_rejected() {
310        let temp = TempDir::new().expect("failed to create temp dir");
311        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
312        let config = SecurityConfig::default();
313        let mut validator = EntryValidator::new(&config, &dest);
314
315        let result = validator.validate_entry(
316            Path::new("../etc/passwd"),
317            &EntryType::File,
318            1024,
319            None,
320            Some(0o644),
321            None,
322        );
323
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_quota_exceeded_file_size() {
329        let temp = TempDir::new().unwrap();
330        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
331        let mut config = SecurityConfig::default();
332        config.max_file_size = 100;
333        let mut validator = EntryValidator::new(&config, &dest);
334
335        let result = validator.validate_entry(
336            Path::new("large.txt"),
337            &EntryType::File,
338            1000,
339            None,
340            Some(0o644),
341            None,
342        );
343
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn test_quota_exceeded_file_count() {
349        let temp = TempDir::new().unwrap();
350        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
351        let mut config = SecurityConfig::default();
352        config.max_file_count = 2;
353        let mut validator = EntryValidator::new(&config, &dest);
354
355        assert!(
356            validator
357                .validate_entry(
358                    Path::new("file1.txt"),
359                    &EntryType::File,
360                    100,
361                    None,
362                    Some(0o644),
363                    None,
364                )
365                .is_ok()
366        );
367        assert!(
368            validator
369                .validate_entry(
370                    Path::new("file2.txt"),
371                    &EntryType::File,
372                    100,
373                    None,
374                    Some(0o644),
375                    None,
376                )
377                .is_ok()
378        );
379
380        let result = validator.validate_entry(
381            Path::new("file3.txt"),
382            &EntryType::File,
383            100,
384            None,
385            Some(0o644),
386            None,
387        );
388        assert!(result.is_err());
389    }
390
391    #[test]
392    fn test_zip_bomb_detected() {
393        let temp = TempDir::new().expect("failed to create temp dir");
394        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
395        let config = SecurityConfig::default();
396        let mut validator = EntryValidator::new(&config, &dest);
397
398        let result = validator.validate_entry(
399            Path::new("bomb.txt"),
400            &EntryType::File,
401            1_000_000,
402            Some(100),
403            Some(0o644),
404            None,
405        );
406
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn test_validation_report() {
412        let temp = TempDir::new().expect("failed to create temp dir");
413        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
414        let config = SecurityConfig::default();
415        let mut validator = EntryValidator::new(&config, &dest);
416
417        validator
418            .validate_entry(
419                Path::new("file1.txt"),
420                &EntryType::File,
421                1024,
422                None,
423                Some(0o644),
424                None,
425            )
426            .unwrap();
427
428        validator
429            .validate_entry(
430                Path::new("file2.txt"),
431                &EntryType::File,
432                2048,
433                None,
434                Some(0o644),
435                None,
436            )
437            .unwrap();
438
439        let report = validator.finish();
440        assert_eq!(report.files_validated, 2);
441        assert_eq!(report.total_bytes, 1024 + 2048);
442    }
443
444    #[test]
445    fn test_sanitize_permissions_setuid() {
446        let temp = TempDir::new().expect("failed to create temp dir");
447        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
448        let config = SecurityConfig::default();
449        let mut validator = EntryValidator::new(&config, &dest);
450
451        let result = validator.validate_entry(
452            Path::new("file.txt"),
453            &EntryType::File,
454            1024,
455            None,
456            Some(0o4755),
457            None,
458        );
459
460        assert!(result.is_ok());
461        let entry = result.unwrap();
462        assert_eq!(entry.mode, Some(0o755)); // setuid stripped
463    }
464
465    #[test]
466    fn test_symlink_validation() {
467        let temp = TempDir::new().unwrap();
468        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
469        let mut config = SecurityConfig::default();
470        config.allowed.symlinks = true;
471        let mut validator = EntryValidator::new(&config, &dest);
472
473        let result = validator.validate_entry(
474            Path::new("link"),
475            &EntryType::Symlink {
476                target: PathBuf::from("target.txt"),
477            },
478            0,
479            None,
480            None,
481            None,
482        );
483
484        assert!(result.is_ok());
485        let entry = result.unwrap();
486        assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
487        assert!(validator.symlink_seen);
488    }
489
490    #[test]
491    fn test_hardlink_validation() {
492        let temp = TempDir::new().unwrap();
493        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
494        let mut config = SecurityConfig::default();
495        config.allowed.hardlinks = true;
496        let mut validator = EntryValidator::new(&config, &dest);
497
498        let result = validator.validate_entry(
499            Path::new("link"),
500            &EntryType::Hardlink {
501                target: PathBuf::from("target.txt"),
502            },
503            0,
504            None,
505            None,
506            None,
507        );
508
509        assert!(result.is_ok());
510        let entry = result.unwrap();
511        assert!(matches!(
512            entry.entry_type,
513            ValidatedEntryType::Hardlink { .. }
514        ));
515    }
516
517    #[test]
518    fn test_multiple_entries_with_report() {
519        let temp = TempDir::new().unwrap();
520        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
521        let mut config = SecurityConfig::default();
522        config.allowed.hardlinks = true;
523        let mut validator = EntryValidator::new(&config, &dest);
524
525        // Validate multiple entry types
526        validator
527            .validate_entry(
528                Path::new("file1.txt"),
529                &EntryType::File,
530                1024,
531                None,
532                Some(0o644),
533                None,
534            )
535            .unwrap();
536
537        validator
538            .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
539            .unwrap();
540
541        validator
542            .validate_entry(
543                Path::new("hardlink"),
544                &EntryType::Hardlink {
545                    target: PathBuf::from("file1.txt"),
546                },
547                0,
548                None,
549                None,
550                None,
551            )
552            .unwrap();
553
554        let report = validator.finish();
555        assert_eq!(report.files_validated, 1); // Only files counted
556        assert_eq!(report.total_bytes, 1024);
557        assert_eq!(report.hardlinks_tracked, 1);
558    }
559
560    // M-TEST-1: Empty directory handling
561    #[test]
562    fn test_empty_directory_validation() {
563        let temp = TempDir::new().expect("failed to create temp dir");
564        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
565        let config = SecurityConfig::default();
566        let mut validator = EntryValidator::new(&config, &dest);
567
568        // Empty directory should be valid
569        let result = validator.validate_entry(
570            Path::new("empty_dir/"),
571            &EntryType::Directory,
572            0,
573            None,
574            None,
575            None,
576        );
577
578        assert!(result.is_ok(), "empty directory should be valid");
579        let entry = result.unwrap();
580        assert!(
581            matches!(entry.entry_type, ValidatedEntryType::Directory),
582            "should be directory type"
583        );
584        assert!(entry.mode.is_none(), "directory should not have mode set");
585    }
586
587    #[test]
588    fn test_nested_empty_directories() {
589        let temp = TempDir::new().expect("failed to create temp dir");
590        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
591        let config = SecurityConfig::default();
592        let mut validator = EntryValidator::new(&config, &dest);
593
594        // Multiple nested empty directories
595        let dirs = ["a/", "a/b/", "a/b/c/"];
596        for dir in &dirs {
597            let result = validator.validate_entry(
598                Path::new(dir),
599                &EntryType::Directory,
600                0,
601                None,
602                None,
603                None,
604            );
605            assert!(result.is_ok(), "nested directory {dir} should be valid");
606        }
607
608        let report = validator.finish();
609        assert_eq!(
610            report.files_validated, 0,
611            "directories are not counted as files"
612        );
613    }
614
615    // OPT-H004: Test validator uses references (no cloning)
616    #[test]
617    fn test_validator_uses_references() {
618        let temp = TempDir::new().unwrap();
619        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
620        let config = SecurityConfig::default();
621
622        // Create validator with references
623        let validator = EntryValidator::new(&config, &dest);
624
625        // Verify config and dest are still accessible (not moved)
626        assert_eq!(
627            config.max_file_size,
628            SecurityConfig::default().max_file_size
629        );
630        // Note: dest.as_path() may be canonicalized on macOS (/var vs /private/var)
631        // Just verify dest is still accessible
632        let _ = dest.as_path();
633
634        // Validator can still be used
635        drop(validator);
636    }
637
638    // OPT-H004: Test multiple validators can share same config
639    #[test]
640    fn test_multiple_validators_share_config() {
641        let temp1 = TempDir::new().unwrap();
642        let temp2 = TempDir::new().unwrap();
643        let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
644        let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
645        let config = SecurityConfig::default();
646
647        // Create two validators sharing the same config reference
648        let mut validator1 = EntryValidator::new(&config, &dest1);
649        let mut validator2 = EntryValidator::new(&config, &dest2);
650
651        // Both validators work independently
652        let result1 = validator1.validate_entry(
653            Path::new("file1.txt"),
654            &EntryType::File,
655            1024,
656            None,
657            Some(0o644),
658            None,
659        );
660        assert!(result1.is_ok());
661
662        let result2 = validator2.validate_entry(
663            Path::new("file2.txt"),
664            &EntryType::File,
665            2048,
666            None,
667            Some(0o644),
668            None,
669        );
670        assert!(result2.is_ok());
671
672        // Config is still accessible
673        assert_eq!(
674            config.max_file_size,
675            SecurityConfig::default().max_file_size
676        );
677    }
678
679    #[test]
680    fn test_validate_entry_with_dir_cache() {
681        let temp = TempDir::new().expect("failed to create temp dir");
682        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
683        let config = SecurityConfig::default();
684        let mut validator = EntryValidator::new(&config, &dest);
685
686        let sub = dest.as_path().join("subdir");
687        let mut dir_cache = DirCache::new();
688        dir_cache.ensure_dir(&sub).expect("should create dir");
689
690        let result = validator.validate_entry(
691            Path::new("subdir/file.txt"),
692            &EntryType::File,
693            100,
694            None,
695            Some(0o644),
696            Some(&dir_cache),
697        );
698        assert!(
699            result.is_ok(),
700            "entry with dir_cache should validate: {result:?}"
701        );
702    }
703
704    #[test]
705    fn test_symlink_seen_flag_propagates() {
706        let temp = TempDir::new().unwrap();
707        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
708        let mut config = SecurityConfig::default();
709        config.allowed.symlinks = true;
710        let mut validator = EntryValidator::new(&config, &dest);
711
712        assert!(!validator.symlink_seen);
713
714        // Validate a symlink entry
715        validator
716            .validate_entry(
717                Path::new("link"),
718                &EntryType::Symlink {
719                    target: PathBuf::from("target.txt"),
720                },
721                0,
722                None,
723                None,
724                None,
725            )
726            .unwrap();
727
728        assert!(validator.symlink_seen);
729    }
730}