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::config::Validated;
11use crate::formats::common::DirCache;
12use crate::security::context::ValidationContext;
13use crate::security::hardlink::HardlinkTracker;
14use crate::security::permissions::sanitize_permissions;
15use crate::security::quota::QuotaPermit;
16use crate::security::quota::QuotaTracker;
17use crate::security::symlink::validate_symlink;
18use crate::security::zipbomb::validate_compression_ratio;
19use crate::types::DestDir;
20use crate::types::EntryType;
21use crate::types::SafePath;
22use crate::types::SafeSymlink;
23
24/// Result of entry validation.
25///
26/// Contains validated and sanitized entry information ready for extraction.
27///
28/// # Security Properties
29///
30/// - Can ONLY be constructed through [`EntryValidator::validate_entry`]
31/// - Fields are private; accessed through getters, so a `ValidatedEntry` cannot
32///   be hand-assembled from unvalidated parts anywhere else in the crate
33#[derive(Debug)]
34pub struct ValidatedEntry {
35    safe_path: SafePath,
36    entry_type: ValidatedEntryType,
37    mode: Option<u32>,
38}
39
40impl ValidatedEntry {
41    /// Constructs a `ValidatedEntry` from its already-validated parts.
42    ///
43    /// `pub(crate)` rather than fully private so that unit tests elsewhere in
44    /// this crate (`formats::common`) can build fixtures without weakening
45    /// the sealing against external construction.
46    pub(crate) fn new(
47        safe_path: SafePath,
48        entry_type: ValidatedEntryType,
49        mode: Option<u32>,
50    ) -> Self {
51        Self {
52            safe_path,
53            entry_type,
54            mode,
55        }
56    }
57
58    /// Returns the validated path within the destination directory.
59    #[inline]
60    #[must_use]
61    pub fn safe_path(&self) -> &SafePath {
62        &self.safe_path
63    }
64
65    /// Returns the validated entry type.
66    #[inline]
67    #[must_use]
68    pub fn entry_type(&self) -> &ValidatedEntryType {
69        &self.entry_type
70    }
71
72    /// Returns the sanitized file permissions, if applicable.
73    #[inline]
74    #[must_use]
75    pub fn mode(&self) -> Option<u32> {
76        self.mode
77    }
78
79    /// Consumes the entry, returning its validated parts by value.
80    ///
81    /// `entry_type()` only lends a shared reference, which is enough to
82    /// observe a `QuotaPermit` but not to move it out of the `File` variant
83    /// — `QuotaPermit` is neither `Clone` nor `Copy` by design, so ownership
84    /// can only be obtained by consuming the `ValidatedEntry` that holds it.
85    /// Write paths that need to thread the permit by value into a helper
86    /// (mirroring `formats::common::copy_file_content_with_permit`'s guarantee)
87    /// call this instead of `entry_type()`.
88    #[inline]
89    #[must_use]
90    pub(crate) fn into_parts(self) -> (SafePath, ValidatedEntryType, Option<u32>) {
91        (self.safe_path, self.entry_type, self.mode)
92    }
93}
94
95/// Validated entry type variants.
96///
97/// This enum alone does not carry the sealing guarantee — the external seal
98/// comes from [`ValidatedEntry`]: its constructor is `pub(crate)`, so
99/// nothing outside this crate can assemble a `ValidatedEntry` at all,
100/// regardless of how its `ValidatedEntryType` field was produced. Within
101/// that guarantee, several variants add a second, crate-internal layer for
102/// their own payloads:
103///
104/// - The `Symlink` and `Hardlink` variants wrap [`SafeSymlink`] and
105///   [`SafePath`], which are independently sealed and can only be produced by
106///   their own validation routines, so even crate-internal code cannot forge a
107///   "validated" symlink/hardlink target.
108/// - The `File` variant wraps [`QuotaPermit`], whose only producer is
109///   [`QuotaTracker::reserve`]: a `File` variant cannot be built without a
110///   quota reservation having actually succeeded. This is crate-internal
111///   discipline, not an external-construction barrier — enum variant fields are
112///   as visible as the enum itself, so `ValidatedEntryType::File(todo!())`
113///   compiles from outside this crate too; it just can never *run*, since
114///   nothing outside this crate can produce a genuine `QuotaPermit`.
115///
116/// `#[non_exhaustive]` so a future variant is not a breaking change for
117/// downstream matches — [`ValidatedEntry::entry_type`] is the only way
118/// external code observes this type, and even that always sees output from
119/// [`EntryValidator::validate_entry`].
120#[derive(Debug)]
121#[non_exhaustive]
122pub enum ValidatedEntryType {
123    /// Regular file, carrying proof that its size was reserved against a
124    /// [`QuotaTracker`] before this entry was validated.
125    File(QuotaPermit),
126
127    /// Directory
128    Directory,
129
130    /// Validated symlink
131    Symlink(SafeSymlink),
132
133    /// Hardlink (validated in tracker, target path stored for two-pass)
134    Hardlink {
135        /// Target path (already validated)
136        target: SafePath,
137    },
138}
139
140/// Orchestrates security validation for archive entries.
141///
142/// This type maintains state across entry validations:
143/// - Quota tracking (file count, total size)
144/// - Compression ratio monitoring (zip bomb detection)
145/// - Hardlink target tracking
146/// - Symlink-seen flag (for canonicalize optimization)
147///
148/// # Lifecycle
149///
150/// 1. Create with `EntryValidator::new(&config, &dest)`
151/// 2. For each entry, call `validate_entry()`
152/// 3. After all entries processed, call `finish()` for final report
153///
154/// # Examples
155///
156/// ```no_run
157/// use exarch_core::SecurityConfig;
158/// use exarch_core::security::EntryValidator;
159/// use exarch_core::types::DestDir;
160/// use exarch_core::types::EntryType;
161/// use std::path::Path;
162/// use std::path::PathBuf;
163///
164/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
165/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
166/// let config = SecurityConfig::default().validate()?;
167///
168/// let mut validator = EntryValidator::new(&config, &dest);
169///
170/// // Validate a file entry
171/// let entry = validator.validate_entry(
172///     Path::new("foo/bar.txt"),
173///     &EntryType::File,
174///     1024,        // uncompressed size
175///     Some(512),   // compressed size
176///     Some(0o644), // mode
177///     None,        // dir_cache
178/// )?;
179///
180/// let report = validator.finish();
181/// println!("Validated {} files", report.files_validated);
182/// # Ok(())
183/// # }
184/// ```
185/// OPT-H004: Validator uses references to avoid cloning config and dest.
186/// This eliminates 1 clone per extraction (`SecurityConfig` + `DestDir`).
187pub struct EntryValidator<'a> {
188    config: &'a SecurityConfig<Validated>,
189    dest: &'a DestDir,
190    quota_tracker: QuotaTracker,
191    hardlink_tracker: HardlinkTracker,
192    symlink_seen: bool,
193}
194
195impl<'a> EntryValidator<'a> {
196    /// Creates a new entry validator with the given security configuration.
197    #[must_use]
198    pub fn new(config: &'a SecurityConfig<Validated>, dest: &'a DestDir) -> Self {
199        Self {
200            config,
201            dest,
202            quota_tracker: QuotaTracker::new(),
203            hardlink_tracker: HardlinkTracker::new(),
204            symlink_seen: false,
205        }
206    }
207
208    /// Validates an archive entry.
209    ///
210    /// This method orchestrates all security validations:
211    /// 1. Path validation (traversal, depth, banned components)
212    /// 2. Quota checking (file size, count, total size) — `EntryType::File`
213    ///    only
214    /// 3. Compression ratio validation (zip bomb detection)
215    /// 4. Type-specific validation (symlink, hardlink, permissions)
216    ///
217    /// When `dir_cache` is provided, path validation can skip expensive
218    /// `canonicalize()` syscalls for parents that were created by us.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if any validation fails. Common errors:
223    /// - `ArchiveError::PathTraversal` - Path escapes destination
224    /// - `ArchiveError::QuotaExceeded` - Size or count limits exceeded
225    /// - `ArchiveError::ZipBomb` - Compression ratio too high
226    /// - `ArchiveError::SymlinkEscape` - Symlink target escapes
227    /// - `ArchiveError::HardlinkEscape` - Hardlink target escapes
228    /// - `ArchiveError::InvalidPermissions` - Dangerous permissions
229    #[inline]
230    pub fn validate_entry(
231        &mut self,
232        path: &Path,
233        entry_type: &EntryType,
234        uncompressed_size: u64,
235        compressed_size: Option<u64>,
236        mode: Option<u32>,
237        dir_cache: Option<&DirCache>,
238    ) -> Result<ValidatedEntry> {
239        let safe_path = self.validate_entry_path(path, dir_cache)?;
240
241        let (validated_type, sanitized_mode) = match entry_type {
242            EntryType::File => {
243                let permit = self.quota_tracker.reserve(uncompressed_size, self.config)?;
244                self.check_ratio(compressed_size, uncompressed_size)?;
245                let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
246                (ValidatedEntryType::File(permit), sanitized)
247            }
248
249            EntryType::Directory => {
250                self.check_ratio(compressed_size, uncompressed_size)?;
251                (ValidatedEntryType::Directory, None)
252            }
253
254            EntryType::Symlink { target } => {
255                self.check_ratio(compressed_size, uncompressed_size)?;
256                let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
257                self.symlink_seen = true;
258                (ValidatedEntryType::Symlink(safe_symlink), None)
259            }
260
261            EntryType::Hardlink { target } => {
262                self.check_ratio(compressed_size, uncompressed_size)?;
263
264                // Hardlink tracker validates: absolute paths, traversal, normalization, escapes
265                self.hardlink_tracker.validate_hardlink(
266                    &safe_path,
267                    target,
268                    self.dest,
269                    self.config,
270                )?;
271
272                // SAFETY: validate_hardlink verified target is relative, normalized, within
273                // dest
274                let target_safe = SafePath::new_unchecked(target.clone());
275
276                (
277                    ValidatedEntryType::Hardlink {
278                        target: target_safe,
279                    },
280                    None,
281                )
282            }
283        };
284
285        Ok(ValidatedEntry::new(
286            safe_path,
287            validated_type,
288            sanitized_mode,
289        ))
290    }
291
292    /// Validates only the path portion of an entry — traversal, absolute-path,
293    /// depth, and symlink-escape-adjacent checks — without reserving quota or
294    /// applying type-specific validation (symlink/hardlink target checks,
295    /// permission sanitization).
296    ///
297    /// Split out of [`validate_entry`](Self::validate_entry), which calls
298    /// this for its own path-validation step so the two never diverge.
299    /// Callers that must resolve an entry's destination path *before*
300    /// deciding whether to reserve quota — 7z's duplicate-skip check (issue
301    /// #478), which needs the path to check for a pre-existing file at the
302    /// destination — use this together with
303    /// [`reserve_file`](Self::reserve_file) instead of `validate_entry`, so
304    /// a skipped entry never reserves quota it will not use.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`ArchiveError::PathTraversal`](crate::ArchiveError::PathTraversal)
309    /// or another path-validation error if `path` fails validation.
310    pub(crate) fn validate_entry_path(
311        &self,
312        path: &Path,
313        dir_cache: Option<&DirCache>,
314    ) -> Result<SafePath> {
315        let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
316        if let Some(cache) = dir_cache {
317            ctx = ctx.with_dir_cache(cache);
318        }
319        if self.symlink_seen {
320            ctx.mark_symlink_seen();
321        }
322
323        SafePath::validate_with_context(path, self.dest, self.config, &ctx)
324    }
325
326    /// Reserves quota capacity for a regular file's byte count, independent
327    /// of path validation.
328    ///
329    /// Mirrors [`reserve_hardlink`](Self::reserve_hardlink), which exists for
330    /// the same reason on the hardlink path: reserving quota unconditionally
331    /// inside `validate_entry` and only checking for a duplicate afterward
332    /// would permanently consume the entry's quota allotment on the skip
333    /// path, since [`QuotaPermit`] has no `Drop` impl to release it. Used by
334    /// 7z's extraction callback together with
335    /// [`validate_entry_path`](Self::validate_entry_path) (issue #478).
336    ///
337    /// # Errors
338    ///
339    /// Returns [`ArchiveError::QuotaExceeded`](crate::ArchiveError::QuotaExceeded)
340    /// if recording `size` bytes would exceed `max_file_size`,
341    /// `max_file_count`, or `max_total_size`.
342    pub(crate) fn reserve_file(&mut self, size: u64) -> Result<QuotaPermit> {
343        self.quota_tracker.reserve(size, self.config)
344    }
345
346    /// Validates the compression ratio (zip bomb detection) when a
347    /// compressed size is known.
348    ///
349    /// Extracted so [`validate_entry`](Self::validate_entry) can call it at
350    /// the exact ordering position each entry type requires relative to its
351    /// other checks, without duplicating the `if let Some(compressed) = ...`
352    /// pattern at every call site.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`ArchiveError::ZipBomb`] if the compression ratio exceeds the
357    /// configured limit.
358    #[inline]
359    fn check_ratio(&self, compressed_size: Option<u64>, uncompressed_size: u64) -> Result<()> {
360        if let Some(compressed) = compressed_size {
361            validate_compression_ratio(compressed, uncompressed_size, self.config)?;
362        }
363        Ok(())
364    }
365
366    /// Reserves quota capacity for a hardlink's copied byte count against the
367    /// shared quota tracker, returning a capability token that proves the
368    /// reservation succeeded.
369    ///
370    /// Hardlinks are validated for path/target escape during the first pass
371    /// (`validate_entry`), but their size is only known once the target file
372    /// exists on disk, in the second pass. This routes that size through the
373    /// same `QuotaTracker` used for regular files, so `max_file_size`,
374    /// `max_file_count`, and `max_total_size` are enforced uniformly
375    /// regardless of entry type.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`ArchiveError::QuotaExceeded`] if recording this hardlink
380    /// would exceed `max_file_size`, `max_file_count`, or `max_total_size`.
381    pub(crate) fn reserve_hardlink(&mut self, size: u64) -> Result<QuotaPermit> {
382        self.quota_tracker.reserve(size, self.config)
383    }
384
385    /// Finishes validation and returns a summary report.
386    ///
387    /// This consumes the validator and returns statistics about the
388    /// validation process.
389    #[must_use]
390    pub fn finish(self) -> ValidationReport {
391        ValidationReport {
392            files_validated: self.quota_tracker.files_extracted(),
393            total_bytes: self.quota_tracker.bytes_written(),
394            hardlinks_tracked: self.hardlink_tracker.count(),
395        }
396    }
397}
398
399/// Summary report of validation process.
400#[derive(Debug)]
401pub struct ValidationReport {
402    /// Number of files validated
403    pub files_validated: usize,
404
405    /// Total bytes processed
406    pub total_bytes: u64,
407
408    /// Number of hardlinks tracked
409    pub hardlinks_tracked: usize,
410}
411
412#[cfg(test)]
413#[allow(
414    clippy::unwrap_used,
415    clippy::expect_used,
416    clippy::field_reassign_with_default
417)]
418mod tests {
419    use super::*;
420    use std::assert_matches;
421    use std::path::PathBuf;
422    use tempfile::TempDir;
423
424    #[test]
425    fn test_entry_validator_new() {
426        let temp = TempDir::new().expect("failed to create temp dir");
427        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
428        let config = SecurityConfig::default().validate().expect("valid config");
429        let validator = EntryValidator::new(&config, &dest);
430        let report = validator.finish();
431        assert_eq!(report.files_validated, 0);
432        assert_eq!(report.total_bytes, 0);
433        assert_eq!(report.hardlinks_tracked, 0);
434    }
435
436    #[test]
437    fn test_validate_file_entry() {
438        let temp = TempDir::new().expect("failed to create temp dir");
439        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
440        let config = SecurityConfig::default().validate().expect("valid config");
441        let mut validator = EntryValidator::new(&config, &dest);
442
443        let result = validator.validate_entry(
444            Path::new("file.txt"),
445            &EntryType::File,
446            1024,
447            None,
448            Some(0o644),
449            None,
450        );
451
452        assert!(result.is_ok());
453        let entry = result.unwrap();
454        assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
455        assert_matches!(entry.entry_type, ValidatedEntryType::File(_));
456        assert_eq!(entry.mode, Some(0o644));
457    }
458
459    #[test]
460    fn test_validate_directory_entry() {
461        let temp = TempDir::new().expect("failed to create temp dir");
462        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
463        let config = SecurityConfig::default().validate().expect("valid config");
464        let mut validator = EntryValidator::new(&config, &dest);
465
466        let result =
467            validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
468
469        assert!(result.is_ok());
470        let entry = result.unwrap();
471        assert_matches!(entry.entry_type, ValidatedEntryType::Directory);
472        assert!(entry.mode.is_none());
473    }
474
475    #[test]
476    fn test_validate_path_traversal_rejected() {
477        let temp = TempDir::new().expect("failed to create temp dir");
478        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
479        let config = SecurityConfig::default().validate().expect("valid config");
480        let mut validator = EntryValidator::new(&config, &dest);
481
482        let result = validator.validate_entry(
483            Path::new("../etc/passwd"),
484            &EntryType::File,
485            1024,
486            None,
487            Some(0o644),
488            None,
489        );
490
491        assert!(result.is_err());
492    }
493
494    #[test]
495    fn test_quota_exceeded_file_size() {
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.max_file_size = 100;
500        let config = config.validate().expect("valid config");
501        let mut validator = EntryValidator::new(&config, &dest);
502
503        let result = validator.validate_entry(
504            Path::new("large.txt"),
505            &EntryType::File,
506            1000,
507            None,
508            Some(0o644),
509            None,
510        );
511
512        assert!(result.is_err());
513    }
514
515    #[test]
516    fn test_quota_exceeded_file_count() {
517        let temp = TempDir::new().unwrap();
518        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
519        let mut config = SecurityConfig::default();
520        config.max_file_count = 2;
521        let config = config.validate().expect("valid config");
522        let mut validator = EntryValidator::new(&config, &dest);
523
524        assert!(
525            validator
526                .validate_entry(
527                    Path::new("file1.txt"),
528                    &EntryType::File,
529                    100,
530                    None,
531                    Some(0o644),
532                    None,
533                )
534                .is_ok()
535        );
536        assert!(
537            validator
538                .validate_entry(
539                    Path::new("file2.txt"),
540                    &EntryType::File,
541                    100,
542                    None,
543                    Some(0o644),
544                    None,
545                )
546                .is_ok()
547        );
548
549        let result = validator.validate_entry(
550            Path::new("file3.txt"),
551            &EntryType::File,
552            100,
553            None,
554            Some(0o644),
555            None,
556        );
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_zip_bomb_detected() {
562        let temp = TempDir::new().expect("failed to create temp dir");
563        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
564        let config = SecurityConfig::default().validate().expect("valid config");
565        let mut validator = EntryValidator::new(&config, &dest);
566
567        let result = validator.validate_entry(
568            Path::new("bomb.txt"),
569            &EntryType::File,
570            1_000_000,
571            Some(100),
572            Some(0o644),
573            None,
574        );
575
576        assert!(result.is_err());
577    }
578
579    #[test]
580    fn test_validation_report() {
581        let temp = TempDir::new().expect("failed to create temp dir");
582        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
583        let config = SecurityConfig::default().validate().expect("valid config");
584        let mut validator = EntryValidator::new(&config, &dest);
585
586        validator
587            .validate_entry(
588                Path::new("file1.txt"),
589                &EntryType::File,
590                1024,
591                None,
592                Some(0o644),
593                None,
594            )
595            .unwrap();
596
597        validator
598            .validate_entry(
599                Path::new("file2.txt"),
600                &EntryType::File,
601                2048,
602                None,
603                Some(0o644),
604                None,
605            )
606            .unwrap();
607
608        let report = validator.finish();
609        assert_eq!(report.files_validated, 2);
610        assert_eq!(report.total_bytes, 1024 + 2048);
611    }
612
613    #[test]
614    fn test_sanitize_permissions_setuid() {
615        let temp = TempDir::new().expect("failed to create temp dir");
616        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
617        let config = SecurityConfig::default().validate().expect("valid config");
618        let mut validator = EntryValidator::new(&config, &dest);
619
620        let result = validator.validate_entry(
621            Path::new("file.txt"),
622            &EntryType::File,
623            1024,
624            None,
625            Some(0o4755),
626            None,
627        );
628
629        assert!(result.is_ok());
630        let entry = result.unwrap();
631        assert_eq!(entry.mode, Some(0o755)); // setuid stripped
632    }
633
634    #[test]
635    fn test_symlink_validation() {
636        let temp = TempDir::new().unwrap();
637        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
638        let mut config = SecurityConfig::default();
639        config.allowed.symlinks = true;
640        let config = config.validate().expect("valid config");
641        let mut validator = EntryValidator::new(&config, &dest);
642
643        let result = validator.validate_entry(
644            Path::new("link"),
645            &EntryType::Symlink {
646                target: PathBuf::from("target.txt"),
647            },
648            0,
649            None,
650            None,
651            None,
652        );
653
654        assert!(result.is_ok());
655        let entry = result.unwrap();
656        assert_matches!(entry.entry_type, ValidatedEntryType::Symlink(_));
657        assert!(validator.symlink_seen);
658    }
659
660    #[test]
661    fn test_hardlink_validation() {
662        let temp = TempDir::new().unwrap();
663        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
664        let mut config = SecurityConfig::default();
665        config.allowed.hardlinks = true;
666        let config = config.validate().expect("valid config");
667        let mut validator = EntryValidator::new(&config, &dest);
668
669        let result = validator.validate_entry(
670            Path::new("link"),
671            &EntryType::Hardlink {
672                target: PathBuf::from("target.txt"),
673            },
674            0,
675            None,
676            None,
677            None,
678        );
679
680        assert!(result.is_ok());
681        let entry = result.unwrap();
682        assert_matches!(entry.entry_type, ValidatedEntryType::Hardlink { .. });
683    }
684
685    #[test]
686    fn test_multiple_entries_with_report() {
687        let temp = TempDir::new().unwrap();
688        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
689        let mut config = SecurityConfig::default();
690        config.allowed.hardlinks = true;
691        let config = config.validate().expect("valid config");
692        let mut validator = EntryValidator::new(&config, &dest);
693
694        // Validate multiple entry types
695        validator
696            .validate_entry(
697                Path::new("file1.txt"),
698                &EntryType::File,
699                1024,
700                None,
701                Some(0o644),
702                None,
703            )
704            .unwrap();
705
706        validator
707            .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
708            .unwrap();
709
710        validator
711            .validate_entry(
712                Path::new("hardlink"),
713                &EntryType::Hardlink {
714                    target: PathBuf::from("file1.txt"),
715                },
716                0,
717                None,
718                None,
719                None,
720            )
721            .unwrap();
722
723        let report = validator.finish();
724        assert_eq!(report.files_validated, 1); // Only files counted
725        assert_eq!(report.total_bytes, 1024);
726        assert_eq!(report.hardlinks_tracked, 1);
727    }
728
729    // M-TEST-1: Empty directory handling
730    #[test]
731    fn test_empty_directory_validation() {
732        let temp = TempDir::new().expect("failed to create temp dir");
733        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
734        let config = SecurityConfig::default().validate().expect("valid config");
735        let mut validator = EntryValidator::new(&config, &dest);
736
737        // Empty directory should be valid
738        let result = validator.validate_entry(
739            Path::new("empty_dir/"),
740            &EntryType::Directory,
741            0,
742            None,
743            None,
744            None,
745        );
746
747        assert!(result.is_ok(), "empty directory should be valid");
748        let entry = result.unwrap();
749        assert_matches!(
750            entry.entry_type,
751            ValidatedEntryType::Directory,
752            "should be directory type"
753        );
754        assert!(entry.mode.is_none(), "directory should not have mode set");
755    }
756
757    #[test]
758    fn test_nested_empty_directories() {
759        let temp = TempDir::new().expect("failed to create temp dir");
760        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
761        let config = SecurityConfig::default().validate().expect("valid config");
762        let mut validator = EntryValidator::new(&config, &dest);
763
764        // Multiple nested empty directories
765        let dirs = ["a/", "a/b/", "a/b/c/"];
766        for dir in &dirs {
767            let result = validator.validate_entry(
768                Path::new(dir),
769                &EntryType::Directory,
770                0,
771                None,
772                None,
773                None,
774            );
775            assert!(result.is_ok(), "nested directory {dir} should be valid");
776        }
777
778        let report = validator.finish();
779        assert_eq!(
780            report.files_validated, 0,
781            "directories are not counted as files"
782        );
783    }
784
785    // OPT-H004: Test validator uses references (no cloning)
786    #[test]
787    fn test_validator_uses_references() {
788        let temp = TempDir::new().unwrap();
789        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
790        let config = SecurityConfig::default().validate().expect("valid config");
791
792        // Create validator with references
793        let validator = EntryValidator::new(&config, &dest);
794
795        // Verify config and dest are still accessible (not moved)
796        assert_eq!(
797            config.max_file_size,
798            SecurityConfig::default().max_file_size
799        );
800        // Note: dest.as_path() may be canonicalized on macOS (/var vs /private/var)
801        // Just verify dest is still accessible
802        let _ = dest.as_path();
803
804        // Validator can still be used
805        drop(validator);
806    }
807
808    // OPT-H004: Test multiple validators can share same config
809    #[test]
810    fn test_multiple_validators_share_config() {
811        let temp1 = TempDir::new().unwrap();
812        let temp2 = TempDir::new().unwrap();
813        let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
814        let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
815        let config = SecurityConfig::default().validate().expect("valid config");
816
817        // Create two validators sharing the same config reference
818        let mut validator1 = EntryValidator::new(&config, &dest1);
819        let mut validator2 = EntryValidator::new(&config, &dest2);
820
821        // Both validators work independently
822        let result1 = validator1.validate_entry(
823            Path::new("file1.txt"),
824            &EntryType::File,
825            1024,
826            None,
827            Some(0o644),
828            None,
829        );
830        assert!(result1.is_ok());
831
832        let result2 = validator2.validate_entry(
833            Path::new("file2.txt"),
834            &EntryType::File,
835            2048,
836            None,
837            Some(0o644),
838            None,
839        );
840        assert!(result2.is_ok());
841
842        // Config is still accessible
843        assert_eq!(
844            config.max_file_size,
845            SecurityConfig::default().max_file_size
846        );
847    }
848
849    #[test]
850    fn test_validate_entry_with_dir_cache() {
851        let temp = TempDir::new().expect("failed to create temp dir");
852        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
853        let config = SecurityConfig::default().validate().expect("valid config");
854        let mut validator = EntryValidator::new(&config, &dest);
855
856        let sub = dest.as_path().join("subdir");
857        let mut dir_cache = DirCache::new();
858        dir_cache.ensure_dir(&sub).expect("should create dir");
859
860        let result = validator.validate_entry(
861            Path::new("subdir/file.txt"),
862            &EntryType::File,
863            100,
864            None,
865            Some(0o644),
866            Some(&dir_cache),
867        );
868        assert!(
869            result.is_ok(),
870            "entry with dir_cache should validate: {result:?}"
871        );
872    }
873
874    #[test]
875    fn test_symlink_seen_flag_propagates() {
876        let temp = TempDir::new().unwrap();
877        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
878        let mut config = SecurityConfig::default();
879        config.allowed.symlinks = true;
880        let config = config.validate().expect("valid config");
881        let mut validator = EntryValidator::new(&config, &dest);
882
883        assert!(!validator.symlink_seen);
884
885        // Validate a symlink entry
886        validator
887            .validate_entry(
888                Path::new("link"),
889                &EntryType::Symlink {
890                    target: PathBuf::from("target.txt"),
891                },
892                0,
893                None,
894                None,
895                None,
896            )
897            .unwrap();
898
899        assert!(validator.symlink_seen);
900    }
901
902    // Issue #426: hardlink quota bypass regression tests.
903
904    #[test]
905    fn test_reserve_hardlink_exceeding_max_file_size_rejected() {
906        let temp = TempDir::new().unwrap();
907        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
908        let mut config = SecurityConfig::default();
909        config.max_file_size = 100;
910        let config = config.validate().expect("valid config");
911        let mut validator = EntryValidator::new(&config, &dest);
912
913        // A single hardlink whose on-disk target size alone exceeds
914        // max_file_size must be rejected, exactly like an oversized regular
915        // file would be.
916        let result = validator.reserve_hardlink(1_000);
917
918        assert_matches!(
919            result,
920            Err(crate::ArchiveError::QuotaExceeded {
921                resource: crate::QuotaResource::FileSize { .. }
922            }),
923            "hardlink exceeding max_file_size must be rejected, got: {result:?}"
924        );
925    }
926
927    #[test]
928    fn test_reserve_hardlink_shares_quota_tracker_with_files() {
929        let temp = TempDir::new().unwrap();
930        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
931        let mut config = SecurityConfig::default();
932        config.allowed.hardlinks = true;
933        config.max_total_size = 250;
934        let config = config.validate().expect("valid config");
935        let mut validator = EntryValidator::new(&config, &dest);
936
937        // A regular file consumes part of the shared total-size budget...
938        validator
939            .validate_entry(
940                Path::new("file1.txt"),
941                &EntryType::File,
942                100,
943                None,
944                Some(0o644),
945                None,
946            )
947            .unwrap();
948
949        // ...and a hardlink recorded afterwards must be charged against the
950        // same tracker, not a separate/untracked counter.
951        assert!(validator.reserve_hardlink(100).is_ok());
952
953        let result = validator.reserve_hardlink(100);
954        assert_matches!(
955            result,
956            Err(crate::ArchiveError::QuotaExceeded {
957                resource: crate::QuotaResource::TotalSize { .. }
958            }),
959            "hardlink bytes must accumulate on the same tracker as file bytes \
960             (200 already recorded + 100 more exceeds the 250 budget), got: {result:?}"
961        );
962    }
963
964    #[test]
965    fn test_reserve_hardlink_exceeding_max_file_count() {
966        let temp = TempDir::new().unwrap();
967        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
968        let mut config = SecurityConfig::default();
969        config.max_file_count = 2;
970        let config = config.validate().expect("valid config");
971        let mut validator = EntryValidator::new(&config, &dest);
972
973        assert!(validator.reserve_hardlink(1).is_ok());
974        assert!(validator.reserve_hardlink(1).is_ok());
975
976        let result = validator.reserve_hardlink(1);
977        assert_matches!(
978            result,
979            Err(crate::ArchiveError::QuotaExceeded {
980                resource: crate::QuotaResource::FileCount { .. }
981            }),
982            "hardlink count alone exceeding max_file_count must be rejected, got: {result:?}"
983        );
984    }
985}