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