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