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::ArchiveError;
9use crate::Result;
10use crate::SecurityConfig;
11use crate::config::Validated;
12use crate::formats::common::DirCache;
13use crate::security::context::ValidationContext;
14use crate::security::hardlink::HardlinkTracker;
15use crate::security::hardlink::require_hardlinks_allowed;
16use crate::security::permissions::SanitizedMode;
17use crate::security::permissions::sanitize_permissions;
18use crate::security::quota::EntryCount;
19use crate::security::quota::QuotaPermit;
20use crate::security::quota::QuotaTracker;
21use crate::security::symlink::validate_symlink;
22use crate::security::zipbomb::validate_compression_ratio;
23use crate::types::DestDir;
24use crate::types::EntryType;
25use crate::types::SafePath;
26use crate::types::SafeSymlink;
27use crate::types::safe_path::EMPTY_PATH_REASON;
28use crate::types::safe_symlink::require_symlinks_allowed;
29
30/// Whether an archive entry is a directory, as the root-entry rule sees it.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub(crate) enum NodeKind {
33    /// The entry is a directory (or stands for one, like ZIP's empty root
34    /// file).
35    Directory,
36    /// Any other entry: file, symlink, hardlink.
37    NonDirectory,
38}
39
40impl NodeKind {
41    /// Converts a format's directory flag at the boundary, so the rule itself
42    /// never takes a bare `bool`.
43    pub(crate) const fn directory_if(is_directory: bool) -> Self {
44        if is_directory {
45            Self::Directory
46        } else {
47            Self::NonDirectory
48        }
49    }
50}
51
52/// An archive entry name, split into the archive root and everything else.
53///
54/// The root (`.`, `./`) names the destination itself, so it must not be taken
55/// for an ordinary path: handlers have to match on the variant. `P` is the
56/// path as carried at that stage (`&Path` for a raw name, `SafePath` once
57/// validated).
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(crate) enum EntryPath<P> {
60    /// The destination itself (`.`, `./`).
61    Root(P),
62    /// Any path below the destination.
63    Child(P),
64}
65
66impl<'a> EntryPath<&'a Path> {
67    /// Classifies a raw entry name; the single definition of "root" for names
68    /// not yet validated.
69    pub(crate) fn of(path: &'a Path) -> Self {
70        if path == Path::new(".") {
71            Self::Root(path)
72        } else {
73            Self::Child(path)
74        }
75    }
76}
77
78impl<P> EntryPath<P> {
79    /// Whether this is the root; for tests and callers that only need to know
80    /// that, not to act on the path.
81    pub(crate) const fn is_root(&self) -> bool {
82        matches!(self, Self::Root(_))
83    }
84
85    /// The path, for callers that treat the root like any directory.
86    pub(crate) fn into_inner(self) -> P {
87        match self {
88            Self::Root(path) | Self::Child(path) => path,
89        }
90    }
91
92    /// Rejects a root entry that is not a directory.
93    ///
94    /// A file, symlink or hardlink standing for the root would be written
95    /// onto, or remove, the destination. Shared by entry validation and
96    /// listing so `extract`, `list` and `verify` agree.
97    pub(crate) fn require_directory_root(self, kind: NodeKind) -> Result<Self> {
98        if self.is_root() && kind == NodeKind::NonDirectory {
99            return Err(ArchiveError::violation(
100                "archive root entry '.' must be a directory".into(),
101            ));
102        }
103        Ok(self)
104    }
105}
106
107/// Rejects a root entry that is not a directory, for the TAR extractor, which
108/// checks the raw name before its extension filter.
109pub(crate) fn check_root_entry_is_directory(path: &Path, kind: NodeKind) -> Result<()> {
110    EntryPath::of(path).require_directory_root(kind).map(drop)
111}
112
113/// Rejects an empty entry name, so listing fails on the name that extraction
114/// and `verify` refuse, instead of joining it onto the destination directory.
115pub(crate) fn require_non_empty_name(path: &Path) -> Result<()> {
116    if path.is_empty() {
117        return Err(ArchiveError::violation(EMPTY_PATH_REASON.into()));
118    }
119    Ok(())
120}
121
122/// Result of entry validation.
123///
124/// Contains validated and sanitized entry information ready for extraction.
125///
126/// # Security Properties
127///
128/// - Can ONLY be constructed through [`EntryValidator::validate_entry`]
129/// - Fields are private; accessed through getters, so a `ValidatedEntry` cannot
130///   be hand-assembled from unvalidated parts anywhere else in the crate
131#[derive(Debug)]
132pub struct ValidatedEntry {
133    safe_path: SafePath,
134    entry_type: ValidatedEntryType,
135    mode: Option<SanitizedMode>,
136}
137
138impl ValidatedEntry {
139    /// Constructs a `ValidatedEntry` from its already-validated parts.
140    ///
141    /// `pub(crate)` rather than fully private so that unit tests elsewhere in
142    /// this crate (`formats::common`) can build fixtures without weakening
143    /// the sealing against external construction.
144    pub(crate) fn new(
145        safe_path: SafePath,
146        entry_type: ValidatedEntryType,
147        mode: Option<SanitizedMode>,
148    ) -> Self {
149        Self {
150            safe_path,
151            entry_type,
152            mode,
153        }
154    }
155
156    /// Returns the validated path within the destination directory.
157    #[inline]
158    #[must_use]
159    pub fn safe_path(&self) -> &SafePath {
160        &self.safe_path
161    }
162
163    /// Returns the validated entry type.
164    #[inline]
165    #[must_use]
166    pub fn entry_type(&self) -> &ValidatedEntryType {
167        &self.entry_type
168    }
169
170    /// Returns the sanitized file permissions, if applicable.
171    #[inline]
172    #[must_use]
173    pub fn mode(&self) -> Option<SanitizedMode> {
174        self.mode
175    }
176
177    /// Consumes the entry, returning its validated parts by value.
178    ///
179    /// `entry_type()` only lends a shared reference, which is enough to
180    /// observe a `QuotaPermit` but not to move it out of the `File` variant
181    /// — `QuotaPermit` is neither `Clone` nor `Copy` by design, so ownership
182    /// can only be obtained by consuming the `ValidatedEntry` that holds it.
183    /// Write paths that need to thread the permit by value into a helper
184    /// (mirroring `formats::common::copy_file_content_with_permit`'s guarantee)
185    /// call this instead of `entry_type()`.
186    #[inline]
187    #[must_use]
188    pub(crate) fn into_parts(self) -> (SafePath, ValidatedEntryType, Option<SanitizedMode>) {
189        (self.safe_path, self.entry_type, self.mode)
190    }
191}
192
193/// Validated entry type variants.
194///
195/// This enum alone does not carry the sealing guarantee — the external seal
196/// comes from [`ValidatedEntry`]: its constructor is `pub(crate)`, so
197/// nothing outside this crate can assemble a `ValidatedEntry` at all,
198/// regardless of how its `ValidatedEntryType` field was produced. Within
199/// that guarantee, several variants add a second, crate-internal layer for
200/// their own payloads:
201///
202/// - The `Symlink` and `Hardlink` variants wrap [`SafeSymlink`] and
203///   [`SafePath`], which are independently sealed and can only be produced by
204///   their own validation routines, so even crate-internal code cannot forge a
205///   "validated" symlink/hardlink target.
206/// - The `File` variant wraps [`QuotaPermit`], whose only producer is
207///   [`QuotaTracker::reserve`]: a `File` variant cannot be built without a
208///   quota reservation having actually succeeded. This is crate-internal
209///   discipline, not an external-construction barrier — enum variant fields are
210///   as visible as the enum itself, so `ValidatedEntryType::File(todo!())`
211///   compiles from outside this crate too; it just can never *run*, since
212///   nothing outside this crate can produce a genuine `QuotaPermit`.
213///
214/// `#[non_exhaustive]` so a future variant is not a breaking change for
215/// downstream matches — [`ValidatedEntry::entry_type`] is the only way
216/// external code observes this type, and even that always sees output from
217/// [`EntryValidator::validate_entry`].
218#[derive(Debug)]
219#[non_exhaustive]
220pub enum ValidatedEntryType {
221    /// Regular file, carrying proof that its size was reserved against a
222    /// [`QuotaTracker`] before this entry was validated.
223    File(QuotaPermit),
224
225    /// Directory
226    Directory,
227
228    /// Validated symlink
229    Symlink(SafeSymlink),
230
231    /// Hardlink (validated in tracker, target path stored for two-pass)
232    #[non_exhaustive]
233    Hardlink {
234        /// Target path (already validated)
235        target: SafePath,
236        /// Proof the hardlink was counted against `max_file_count` in the
237        /// first pass; the second pass spends it to charge the copied bytes.
238        counted: EntryCount,
239    },
240}
241
242/// Orchestrates security validation for archive entries.
243///
244/// This type maintains state across entry validations:
245/// - Quota tracking (file count, total size)
246/// - Compression ratio monitoring (zip bomb detection)
247/// - Hardlink target tracking
248/// - Symlink-seen flag (for canonicalize optimization)
249///
250/// # Lifecycle
251///
252/// 1. Create with `EntryValidator::new(&config, &dest)`
253/// 2. For each entry, call `validate_entry()`
254/// 3. After all entries processed, call `finish()` for final report
255///
256/// # Examples
257///
258/// ```no_run
259/// use exarch_core::SecurityConfig;
260/// use exarch_core::security::EntryValidator;
261/// use exarch_core::types::DestDir;
262/// use exarch_core::types::EntryType;
263/// use std::path::Path;
264/// use std::path::PathBuf;
265///
266/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
267/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
268/// let config = SecurityConfig::default().validate()?;
269///
270/// let mut validator = EntryValidator::new(&config, &dest);
271///
272/// // Validate a file entry
273/// let entry = validator.validate_entry(
274///     Path::new("foo/bar.txt"),
275///     &EntryType::File,
276///     1024,        // uncompressed size
277///     Some(512),   // compressed size
278///     Some(0o644), // mode
279///     None,        // dir_cache
280/// )?;
281///
282/// let report = validator.finish();
283/// println!("Validated {} files", report.files_validated);
284/// # Ok(())
285/// # }
286/// ```
287/// OPT-H004: Validator uses references to avoid cloning config and dest.
288/// This eliminates 1 clone per extraction (`SecurityConfig` + `DestDir`).
289pub struct EntryValidator<'a> {
290    config: &'a SecurityConfig<Validated>,
291    dest: &'a DestDir,
292    quota_tracker: QuotaTracker,
293    hardlink_tracker: HardlinkTracker,
294    symlink_seen: bool,
295}
296
297impl<'a> EntryValidator<'a> {
298    /// Creates a new entry validator with the given security configuration.
299    #[must_use]
300    pub fn new(config: &'a SecurityConfig<Validated>, dest: &'a DestDir) -> Self {
301        Self {
302            config,
303            dest,
304            quota_tracker: QuotaTracker::new(),
305            hardlink_tracker: HardlinkTracker::new(),
306            symlink_seen: false,
307        }
308    }
309
310    /// Validates an archive entry.
311    ///
312    /// This method orchestrates all security validations:
313    /// 1. Path validation (traversal, depth, banned components)
314    /// 2. Quota checking (file size, count, total size) — files and symlinks; a
315    ///    hardlink is counted here and charged its bytes in the second pass.
316    ///    Directories are not counted
317    /// 3. Compression ratio validation (zip bomb detection)
318    /// 4. Type-specific validation (symlink, hardlink, permissions)
319    ///
320    /// When `dir_cache` is provided, path validation can skip expensive
321    /// `canonicalize()` syscalls for parents that were created by us.
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if any validation fails. Common errors:
326    /// - `ArchiveError::PathTraversal` - Path escapes destination
327    /// - `ArchiveError::QuotaExceeded` - Size or count limits exceeded
328    /// - `ArchiveError::ZipBomb` - Compression ratio too high
329    /// - `ArchiveError::SymlinkEscape` - Symlink target escapes
330    /// - `ArchiveError::HardlinkEscape` - Hardlink target escapes
331    /// - `ArchiveError::InvalidPermissions` - Dangerous permissions
332    /// - `ArchiveError::SecurityViolation` - Root entry (`.`) that is not a
333    ///   directory. This is a backstop for formats that reach here without an
334    ///   earlier check; TAR checks first, in `process_entry`, so the root is
335    ///   rejected before the extension filter can skip it.
336    #[inline]
337    pub fn validate_entry(
338        &mut self,
339        path: &Path,
340        entry_type: &EntryType,
341        uncompressed_size: u64,
342        compressed_size: Option<u64>,
343        mode: Option<u32>,
344        dir_cache: Option<&DirCache>,
345    ) -> Result<ValidatedEntry> {
346        let kind = NodeKind::directory_if(matches!(entry_type, EntryType::Directory));
347        let safe_path = self
348            .validate_entry_path(path, kind, dir_cache)?
349            .into_inner();
350
351        let (validated_type, sanitized_mode) = match entry_type {
352            EntryType::File => {
353                let permit = self.quota_tracker.reserve(uncompressed_size, self.config)?;
354                self.check_ratio(compressed_size, uncompressed_size)?;
355                let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
356                (ValidatedEntryType::File(permit), sanitized)
357            }
358
359            EntryType::Directory => {
360                self.check_ratio(compressed_size, uncompressed_size)?;
361                (ValidatedEntryType::Directory, None)
362            }
363
364            EntryType::Symlink { target } => {
365                self.check_ratio(compressed_size, uncompressed_size)?;
366                require_symlinks_allowed(self.config)?;
367                self.reserve_symlink(uncompressed_size)?;
368                let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
369                self.symlink_seen = true;
370                (ValidatedEntryType::Symlink(safe_symlink), None)
371            }
372
373            EntryType::Hardlink { target } => {
374                self.check_ratio(compressed_size, uncompressed_size)?;
375                require_hardlinks_allowed(self.config)?;
376                let counted = self.quota_tracker.reserve_count(self.config)?;
377
378                // Hardlink tracker validates: absolute paths, traversal,
379                // normalization, escapes
380                self.hardlink_tracker.validate_hardlink(
381                    &safe_path,
382                    target,
383                    self.dest,
384                    self.config,
385                )?;
386
387                // SAFETY: validate_hardlink verified target is relative,
388                // normalized, within dest
389                let target_safe = SafePath::new_unchecked(target.clone());
390
391                (
392                    ValidatedEntryType::Hardlink {
393                        target: target_safe,
394                        counted,
395                    },
396                    None,
397                )
398            }
399        };
400
401        Ok(ValidatedEntry::new(
402            safe_path,
403            validated_type,
404            sanitized_mode,
405        ))
406    }
407
408    /// Validates only the path portion of an entry — traversal, absolute-path,
409    /// depth, and symlink-escape-adjacent checks — without reserving quota or
410    /// applying type-specific validation (symlink/hardlink target checks,
411    /// permission sanitization).
412    ///
413    /// Split out of [`validate_entry`](Self::validate_entry), which calls
414    /// this for its own path-validation step so the two never diverge.
415    /// Callers that must resolve an entry's destination path *before*
416    /// deciding whether to reserve quota — 7z's duplicate-skip check (issue
417    /// #478), which needs the path to check for a pre-existing file at the
418    /// destination — use this together with
419    /// [`reserve_file`](Self::reserve_file) instead of `validate_entry`, so
420    /// a skipped entry never reserves quota it will not use.
421    ///
422    /// # Errors
423    ///
424    /// Returns [`ArchiveError::PathTraversal`](crate::ArchiveError::PathTraversal)
425    /// or another path-validation error if `path` fails validation, and
426    /// [`ArchiveError::SecurityViolation`](crate::ArchiveError::SecurityViolation)
427    /// for a root entry (`.`) whose `kind` is not a directory.
428    pub(crate) fn validate_entry_path(
429        &self,
430        path: &Path,
431        kind: NodeKind,
432        dir_cache: Option<&DirCache>,
433    ) -> Result<EntryPath<SafePath>> {
434        let entry_path = EntryPath::of(path).require_directory_root(kind)?;
435        let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
436        if let Some(cache) = dir_cache {
437            ctx = ctx.with_dir_cache(cache);
438        }
439        if self.symlink_seen {
440            ctx.mark_symlink_seen();
441        }
442
443        let validate = |path| SafePath::validate_with_context(path, self.dest, self.config, &ctx);
444        match entry_path {
445            EntryPath::Root(path) => validate(path).map(EntryPath::Root),
446            EntryPath::Child(path) => validate(path).map(EntryPath::Child),
447        }
448    }
449
450    /// Reserves quota capacity for a regular file's byte count, independent
451    /// of path validation.
452    ///
453    /// Mirrors [`charge_hardlink`](Self::charge_hardlink), which exists for
454    /// the same reason on the hardlink path: reserving quota unconditionally
455    /// inside `validate_entry` and only checking for a duplicate afterward
456    /// would permanently consume the entry's quota allotment on the skip
457    /// path, since [`QuotaPermit`] has no `Drop` impl to release it. Used by
458    /// 7z's extraction callback together with
459    /// [`validate_entry_path`](Self::validate_entry_path) (issue #478).
460    ///
461    /// # Errors
462    ///
463    /// Returns [`ArchiveError::QuotaExceeded`](crate::ArchiveError::QuotaExceeded)
464    /// if recording `size` bytes would exceed `max_file_size`,
465    /// `max_file_count`, or `max_total_size`.
466    pub(crate) fn reserve_file(&mut self, size: u64) -> Result<QuotaPermit> {
467        self.quota_tracker.reserve(size, self.config)
468    }
469
470    /// Validates the compression ratio (zip bomb detection) when a
471    /// compressed size is known.
472    ///
473    /// Extracted so [`validate_entry`](Self::validate_entry) can call it at
474    /// the exact ordering position each entry type requires relative to its
475    /// other checks, without duplicating the `if let Some(compressed) = ...`
476    /// pattern at every call site.
477    ///
478    /// # Errors
479    ///
480    /// Returns [`ArchiveError::ZipBomb`] if the compression ratio exceeds the
481    /// configured limit.
482    #[inline]
483    pub(crate) fn check_ratio(
484        &self,
485        compressed_size: Option<u64>,
486        uncompressed_size: u64,
487    ) -> Result<()> {
488        if let Some(compressed) = compressed_size {
489            validate_compression_ratio(compressed, uncompressed_size, self.config)?;
490        }
491        Ok(())
492    }
493
494    /// Reserves quota capacity for a symlink, charging its stored size the way
495    /// `list` and `verify` do.
496    ///
497    /// Counts the symlink against `max_file_count` so a link flood is bounded
498    /// like a file flood. The permit is discarded: no writer consumes one for a
499    /// link.
500    ///
501    /// # Errors
502    ///
503    /// Returns [`ArchiveError::QuotaExceeded`] if recording the link would
504    /// exceed `max_file_size`, `max_file_count`, or `max_total_size`.
505    pub(crate) fn reserve_symlink(&mut self, size: u64) -> Result<()> {
506        self.quota_tracker.reserve(size, self.config).map(drop)
507    }
508
509    /// Charges a hardlink's copied byte count against the shared quota tracker,
510    /// returning a capability token that proves the charge succeeded.
511    ///
512    /// The hardlink was already counted against `max_file_count` in the first
513    /// pass (`validate_entry`), which is what `counted` proves; its size is
514    /// only known once the target file exists on disk, in the second pass. This
515    /// routes that size through the same `QuotaTracker` used for regular files,
516    /// so `max_file_size` and `max_total_size` are enforced uniformly
517    /// regardless of entry type, without counting the entry twice.
518    ///
519    /// # Errors
520    ///
521    /// Returns [`ArchiveError::QuotaExceeded`] if the copy would exceed
522    /// `max_file_size` or `max_total_size`.
523    pub(crate) fn charge_hardlink(
524        &mut self,
525        counted: EntryCount,
526        size: u64,
527    ) -> Result<QuotaPermit> {
528        self.quota_tracker.charge(counted, size, self.config)
529    }
530
531    /// Finishes validation and returns a summary report.
532    ///
533    /// This consumes the validator and returns statistics about the
534    /// validation process.
535    #[must_use]
536    pub fn finish(self) -> ValidationReport {
537        ValidationReport {
538            files_validated: self.quota_tracker.files_extracted(),
539            total_bytes: self.quota_tracker.bytes_written(),
540            hardlinks_tracked: self.hardlink_tracker.count(),
541        }
542    }
543}
544
545/// Summary report of validation process.
546#[derive(Debug)]
547pub struct ValidationReport {
548    /// Number of entries counted against `max_file_count`: files, symlinks and
549    /// hardlinks (directories are not counted)
550    pub files_validated: usize,
551
552    /// Total bytes processed
553    pub total_bytes: u64,
554
555    /// Number of hardlinks tracked
556    pub hardlinks_tracked: usize,
557}
558
559#[cfg(test)]
560#[allow(
561    clippy::unwrap_used,
562    clippy::expect_used,
563    clippy::field_reassign_with_default
564)]
565mod tests {
566    use super::*;
567    use std::assert_matches;
568    use std::path::PathBuf;
569    use tempfile::TempDir;
570
571    #[test]
572    fn test_entry_path_of_splits_root_from_child() {
573        for root in [".", "./", "./."] {
574            assert!(EntryPath::of(Path::new(root)).is_root(), "{root:?}");
575        }
576        assert_eq!(
577            EntryPath::of(Path::new("a/b")),
578            EntryPath::Child(Path::new("a/b"))
579        );
580    }
581
582    #[test]
583    fn test_root_requires_directory() {
584        let root = EntryPath::of(Path::new("."));
585        assert!(root.require_directory_root(NodeKind::Directory).is_ok());
586        assert_matches!(
587            root.require_directory_root(NodeKind::NonDirectory),
588            Err(ArchiveError::SecurityViolation { .. })
589        );
590        let child = EntryPath::of(Path::new("a"));
591        assert!(child.require_directory_root(NodeKind::NonDirectory).is_ok());
592    }
593
594    #[test]
595    fn test_validate_entry_path_returns_root_for_directory_and_rejects_file() {
596        let temp = TempDir::new().expect("failed to create temp dir");
597        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
598        let config = SecurityConfig::default().validate().expect("valid config");
599        let validator = EntryValidator::new(&config, &dest);
600
601        let root = validator
602            .validate_entry_path(Path::new("."), NodeKind::Directory, None)
603            .expect("root directory");
604        assert!(root.is_root());
605        assert_matches!(
606            validator.validate_entry_path(Path::new("."), NodeKind::NonDirectory, None),
607            Err(ArchiveError::SecurityViolation { .. })
608        );
609        let child = validator
610            .validate_entry_path(Path::new("a.txt"), NodeKind::NonDirectory, None)
611            .expect("child");
612        assert!(!child.is_root());
613    }
614
615    #[test]
616    fn test_entry_validator_new() {
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 validator = EntryValidator::new(&config, &dest);
621        let report = validator.finish();
622        assert_eq!(report.files_validated, 0);
623        assert_eq!(report.total_bytes, 0);
624        assert_eq!(report.hardlinks_tracked, 0);
625    }
626
627    #[test]
628    fn test_validate_file_entry() {
629        let temp = TempDir::new().expect("failed to create temp dir");
630        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
631        let config = SecurityConfig::default().validate().expect("valid config");
632        let mut validator = EntryValidator::new(&config, &dest);
633
634        let result = validator.validate_entry(
635            Path::new("file.txt"),
636            &EntryType::File,
637            1024,
638            None,
639            Some(0o644),
640            None,
641        );
642
643        assert!(result.is_ok());
644        let entry = result.unwrap();
645        assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
646        assert_matches!(entry.entry_type, ValidatedEntryType::File(_));
647        assert_eq!(entry.mode.map(SanitizedMode::as_u32), Some(0o644));
648    }
649
650    #[test]
651    fn test_validate_directory_entry() {
652        let temp = TempDir::new().expect("failed to create temp dir");
653        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
654        let config = SecurityConfig::default().validate().expect("valid config");
655        let mut validator = EntryValidator::new(&config, &dest);
656
657        let result =
658            validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
659
660        assert!(result.is_ok());
661        let entry = result.unwrap();
662        assert_matches!(entry.entry_type, ValidatedEntryType::Directory);
663        assert!(entry.mode.is_none());
664    }
665
666    #[test]
667    fn test_validate_path_traversal_rejected() {
668        let temp = TempDir::new().expect("failed to create temp dir");
669        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
670        let config = SecurityConfig::default().validate().expect("valid config");
671        let mut validator = EntryValidator::new(&config, &dest);
672
673        let result = validator.validate_entry(
674            Path::new("../etc/passwd"),
675            &EntryType::File,
676            1024,
677            None,
678            Some(0o644),
679            None,
680        );
681
682        assert!(result.is_err());
683    }
684
685    #[test]
686    fn test_quota_exceeded_file_size() {
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.max_file_size = 100;
691        let config = config.validate().expect("valid config");
692        let mut validator = EntryValidator::new(&config, &dest);
693
694        let result = validator.validate_entry(
695            Path::new("large.txt"),
696            &EntryType::File,
697            1000,
698            None,
699            Some(0o644),
700            None,
701        );
702
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn test_quota_exceeded_file_count() {
708        let temp = TempDir::new().unwrap();
709        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
710        let mut config = SecurityConfig::default();
711        config.max_file_count = 2;
712        let config = config.validate().expect("valid config");
713        let mut validator = EntryValidator::new(&config, &dest);
714
715        assert!(
716            validator
717                .validate_entry(
718                    Path::new("file1.txt"),
719                    &EntryType::File,
720                    100,
721                    None,
722                    Some(0o644),
723                    None,
724                )
725                .is_ok()
726        );
727        assert!(
728            validator
729                .validate_entry(
730                    Path::new("file2.txt"),
731                    &EntryType::File,
732                    100,
733                    None,
734                    Some(0o644),
735                    None,
736                )
737                .is_ok()
738        );
739
740        let result = validator.validate_entry(
741            Path::new("file3.txt"),
742            &EntryType::File,
743            100,
744            None,
745            Some(0o644),
746            None,
747        );
748        assert!(result.is_err());
749    }
750
751    #[test]
752    fn test_zip_bomb_detected() {
753        let temp = TempDir::new().expect("failed to create temp dir");
754        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
755        let config = SecurityConfig::default().validate().expect("valid config");
756        let mut validator = EntryValidator::new(&config, &dest);
757
758        let result = validator.validate_entry(
759            Path::new("bomb.txt"),
760            &EntryType::File,
761            1_000_000,
762            Some(100),
763            Some(0o644),
764            None,
765        );
766
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_validation_report() {
772        let temp = TempDir::new().expect("failed to create temp dir");
773        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
774        let config = SecurityConfig::default().validate().expect("valid config");
775        let mut validator = EntryValidator::new(&config, &dest);
776
777        validator
778            .validate_entry(
779                Path::new("file1.txt"),
780                &EntryType::File,
781                1024,
782                None,
783                Some(0o644),
784                None,
785            )
786            .unwrap();
787
788        validator
789            .validate_entry(
790                Path::new("file2.txt"),
791                &EntryType::File,
792                2048,
793                None,
794                Some(0o644),
795                None,
796            )
797            .unwrap();
798
799        let report = validator.finish();
800        assert_eq!(report.files_validated, 2);
801        assert_eq!(report.total_bytes, 1024 + 2048);
802    }
803
804    #[test]
805    fn test_sanitize_permissions_setuid() {
806        let temp = TempDir::new().expect("failed to create temp dir");
807        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
808        let config = SecurityConfig::default().validate().expect("valid config");
809        let mut validator = EntryValidator::new(&config, &dest);
810
811        let result = validator.validate_entry(
812            Path::new("file.txt"),
813            &EntryType::File,
814            1024,
815            None,
816            Some(0o4755),
817            None,
818        );
819
820        assert!(result.is_ok());
821        let entry = result.unwrap();
822        assert_eq!(entry.mode.map(SanitizedMode::as_u32), Some(0o755)); // setuid stripped
823    }
824
825    #[test]
826    fn test_symlink_validation() {
827        let temp = TempDir::new().unwrap();
828        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
829        let mut config = SecurityConfig::default();
830        config.allowed.symlinks = true;
831        let config = config.validate().expect("valid config");
832        let mut validator = EntryValidator::new(&config, &dest);
833
834        let result = validator.validate_entry(
835            Path::new("link"),
836            &EntryType::Symlink {
837                target: PathBuf::from("target.txt"),
838            },
839            0,
840            None,
841            None,
842            None,
843        );
844
845        assert!(result.is_ok());
846        let entry = result.unwrap();
847        assert_matches!(entry.entry_type, ValidatedEntryType::Symlink(_));
848        assert!(validator.symlink_seen);
849    }
850
851    #[test]
852    fn test_hardlink_validation() {
853        let temp = TempDir::new().unwrap();
854        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
855        let mut config = SecurityConfig::default();
856        config.allowed.hardlinks = true;
857        let config = config.validate().expect("valid config");
858        let mut validator = EntryValidator::new(&config, &dest);
859
860        let result = validator.validate_entry(
861            Path::new("link"),
862            &EntryType::Hardlink {
863                target: PathBuf::from("target.txt"),
864            },
865            0,
866            None,
867            None,
868            None,
869        );
870
871        assert!(result.is_ok());
872        let entry = result.unwrap();
873        assert_matches!(entry.entry_type, ValidatedEntryType::Hardlink { .. });
874    }
875
876    #[test]
877    fn test_multiple_entries_with_report() {
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.hardlinks = true;
882        let config = config.validate().expect("valid config");
883        let mut validator = EntryValidator::new(&config, &dest);
884
885        // Validate multiple entry types
886        validator
887            .validate_entry(
888                Path::new("file1.txt"),
889                &EntryType::File,
890                1024,
891                None,
892                Some(0o644),
893                None,
894            )
895            .unwrap();
896
897        validator
898            .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
899            .unwrap();
900
901        validator
902            .validate_entry(
903                Path::new("hardlink"),
904                &EntryType::Hardlink {
905                    target: PathBuf::from("file1.txt"),
906                },
907                0,
908                None,
909                None,
910                None,
911            )
912            .unwrap();
913
914        let report = validator.finish();
915        assert_eq!(report.files_validated, 2); // File and hardlink; directories are not counted
916        assert_eq!(report.total_bytes, 1024);
917        assert_eq!(report.hardlinks_tracked, 1);
918    }
919
920    // M-TEST-1: Empty directory handling
921    #[test]
922    fn test_empty_directory_validation() {
923        let temp = TempDir::new().expect("failed to create temp dir");
924        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
925        let config = SecurityConfig::default().validate().expect("valid config");
926        let mut validator = EntryValidator::new(&config, &dest);
927
928        // Empty directory should be valid
929        let result = validator.validate_entry(
930            Path::new("empty_dir/"),
931            &EntryType::Directory,
932            0,
933            None,
934            None,
935            None,
936        );
937
938        assert!(result.is_ok(), "empty directory should be valid");
939        let entry = result.unwrap();
940        assert_matches!(
941            entry.entry_type,
942            ValidatedEntryType::Directory,
943            "should be directory type"
944        );
945        assert!(entry.mode.is_none(), "directory should not have mode set");
946    }
947
948    #[test]
949    fn test_nested_empty_directories() {
950        let temp = TempDir::new().expect("failed to create temp dir");
951        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
952        let config = SecurityConfig::default().validate().expect("valid config");
953        let mut validator = EntryValidator::new(&config, &dest);
954
955        // Multiple nested empty directories
956        let dirs = ["a/", "a/b/", "a/b/c/"];
957        for dir in &dirs {
958            let result = validator.validate_entry(
959                Path::new(dir),
960                &EntryType::Directory,
961                0,
962                None,
963                None,
964                None,
965            );
966            assert!(result.is_ok(), "nested directory {dir} should be valid");
967        }
968
969        let report = validator.finish();
970        assert_eq!(
971            report.files_validated, 0,
972            "directories are not counted as files"
973        );
974    }
975
976    // OPT-H004: Test validator uses references (no cloning)
977    #[test]
978    fn test_validator_uses_references() {
979        let temp = TempDir::new().unwrap();
980        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
981        let config = SecurityConfig::default().validate().expect("valid config");
982
983        // Create validator with references
984        let validator = EntryValidator::new(&config, &dest);
985
986        // Verify config and dest are still accessible (not moved)
987        assert_eq!(
988            config.max_file_size,
989            SecurityConfig::default().max_file_size
990        );
991        // Note: dest.as_path() may be canonicalized on macOS (/var vs
992        // /private/var) Just verify dest is still accessible
993        let _ = dest.as_path();
994
995        // Validator can still be used
996        drop(validator);
997    }
998
999    // OPT-H004: Test multiple validators can share same config
1000    #[test]
1001    fn test_multiple_validators_share_config() {
1002        let temp1 = TempDir::new().unwrap();
1003        let temp2 = TempDir::new().unwrap();
1004        let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
1005        let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
1006        let config = SecurityConfig::default().validate().expect("valid config");
1007
1008        // Create two validators sharing the same config reference
1009        let mut validator1 = EntryValidator::new(&config, &dest1);
1010        let mut validator2 = EntryValidator::new(&config, &dest2);
1011
1012        // Both validators work independently
1013        let result1 = validator1.validate_entry(
1014            Path::new("file1.txt"),
1015            &EntryType::File,
1016            1024,
1017            None,
1018            Some(0o644),
1019            None,
1020        );
1021        assert!(result1.is_ok());
1022
1023        let result2 = validator2.validate_entry(
1024            Path::new("file2.txt"),
1025            &EntryType::File,
1026            2048,
1027            None,
1028            Some(0o644),
1029            None,
1030        );
1031        assert!(result2.is_ok());
1032
1033        // Config is still accessible
1034        assert_eq!(
1035            config.max_file_size,
1036            SecurityConfig::default().max_file_size
1037        );
1038    }
1039
1040    #[test]
1041    fn test_validate_entry_with_dir_cache() {
1042        let temp = TempDir::new().expect("failed to create temp dir");
1043        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
1044        let config = SecurityConfig::default().validate().expect("valid config");
1045        let mut validator = EntryValidator::new(&config, &dest);
1046
1047        let sub = dest.as_path().join("subdir");
1048        let mut dir_cache = DirCache::new();
1049        dir_cache.ensure_dir(&sub).expect("should create dir");
1050
1051        let result = validator.validate_entry(
1052            Path::new("subdir/file.txt"),
1053            &EntryType::File,
1054            100,
1055            None,
1056            Some(0o644),
1057            Some(&dir_cache),
1058        );
1059        assert!(
1060            result.is_ok(),
1061            "entry with dir_cache should validate: {result:?}"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_symlink_seen_flag_propagates() {
1067        let temp = TempDir::new().unwrap();
1068        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1069        let mut config = SecurityConfig::default();
1070        config.allowed.symlinks = true;
1071        let config = config.validate().expect("valid config");
1072        let mut validator = EntryValidator::new(&config, &dest);
1073
1074        assert!(!validator.symlink_seen);
1075
1076        // Validate a symlink entry
1077        validator
1078            .validate_entry(
1079                Path::new("link"),
1080                &EntryType::Symlink {
1081                    target: PathBuf::from("target.txt"),
1082                },
1083                0,
1084                None,
1085                None,
1086                None,
1087            )
1088            .unwrap();
1089
1090        assert!(validator.symlink_seen);
1091    }
1092
1093    // Issue #426: hardlink quota bypass regression tests.
1094
1095    fn validate_hardlink_entry(
1096        validator: &mut EntryValidator<'_>,
1097        name: &str,
1098    ) -> crate::Result<EntryCount> {
1099        let entry = validator.validate_entry(
1100            Path::new(name),
1101            &EntryType::Hardlink {
1102                target: PathBuf::from("target.txt"),
1103            },
1104            0,
1105            None,
1106            None,
1107            None,
1108        )?;
1109        match entry.into_parts() {
1110            (_, ValidatedEntryType::Hardlink { counted, .. }, _) => Ok(counted),
1111            other => panic!("expected a hardlink, got {other:?}"),
1112        }
1113    }
1114
1115    fn validate_symlink_entry(
1116        validator: &mut EntryValidator<'_>,
1117        name: &str,
1118    ) -> crate::Result<ValidatedEntry> {
1119        validator.validate_entry(
1120            Path::new(name),
1121            &EntryType::Symlink {
1122                target: PathBuf::from("target.txt"),
1123            },
1124            0,
1125            None,
1126            None,
1127            None,
1128        )
1129    }
1130
1131    #[test]
1132    fn test_charge_hardlink_exceeding_max_file_size_rejected() {
1133        let temp = TempDir::new().unwrap();
1134        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1135        let mut config = SecurityConfig::default();
1136        config.allowed.hardlinks = true;
1137        config.max_file_size = 100;
1138        let config = config.validate().expect("valid config");
1139        let mut validator = EntryValidator::new(&config, &dest);
1140
1141        let counted = validate_hardlink_entry(&mut validator, "link").unwrap();
1142        let result = validator.charge_hardlink(counted, 1_000);
1143
1144        assert_matches!(
1145            result,
1146            Err(crate::ArchiveError::QuotaExceeded {
1147                resource: crate::QuotaResource::FileSize { .. }
1148            }),
1149            "hardlink exceeding max_file_size must be rejected, got: {result:?}"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_charge_hardlink_shares_quota_tracker_with_files() {
1155        let temp = TempDir::new().unwrap();
1156        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1157        let mut config = SecurityConfig::default();
1158        config.allowed.hardlinks = true;
1159        config.max_total_size = 250;
1160        let config = config.validate().expect("valid config");
1161        let mut validator = EntryValidator::new(&config, &dest);
1162
1163        validator
1164            .validate_entry(
1165                Path::new("file1.txt"),
1166                &EntryType::File,
1167                100,
1168                None,
1169                Some(0o644),
1170                None,
1171            )
1172            .unwrap();
1173
1174        let first = validate_hardlink_entry(&mut validator, "link1").unwrap();
1175        let second = validate_hardlink_entry(&mut validator, "link2").unwrap();
1176        assert!(validator.charge_hardlink(first, 100).is_ok());
1177
1178        let result = validator.charge_hardlink(second, 100);
1179        assert_matches!(
1180            result,
1181            Err(crate::ArchiveError::QuotaExceeded {
1182                resource: crate::QuotaResource::TotalSize { .. }
1183            }),
1184            "hardlink bytes must accumulate on the same tracker as file bytes \
1185             (200 already recorded + 100 more exceeds the 250 budget), got: {result:?}"
1186        );
1187    }
1188
1189    #[test]
1190    fn test_hardlink_counted_once_in_pass_one() {
1191        let temp = TempDir::new().unwrap();
1192        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1193        let mut config = SecurityConfig::default();
1194        config.allowed.hardlinks = true;
1195        config.max_file_count = 2;
1196        let config = config.validate().expect("valid config");
1197        let mut validator = EntryValidator::new(&config, &dest);
1198
1199        let first = validate_hardlink_entry(&mut validator, "link1").unwrap();
1200        let second = validate_hardlink_entry(&mut validator, "link2").unwrap();
1201
1202        let result = validate_hardlink_entry(&mut validator, "link3");
1203        assert_matches!(
1204            result,
1205            Err(crate::ArchiveError::QuotaExceeded {
1206                resource: crate::QuotaResource::FileCount { .. }
1207            }),
1208            "the third hardlink must exceed max_file_count in pass one, got: {result:?}"
1209        );
1210
1211        assert!(validator.charge_hardlink(first, 1).is_ok());
1212        assert!(validator.charge_hardlink(second, 1).is_ok());
1213        assert_eq!(validator.finish().files_validated, 3);
1214    }
1215
1216    #[test]
1217    fn test_symlink_exceeding_max_file_count_rejected() {
1218        let temp = TempDir::new().unwrap();
1219        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1220        let mut config = SecurityConfig::default();
1221        config.allowed.symlinks = true;
1222        config.max_file_count = 2;
1223        let config = config.validate().expect("valid config");
1224        let mut validator = EntryValidator::new(&config, &dest);
1225
1226        assert!(validate_symlink_entry(&mut validator, "l1").is_ok());
1227        assert!(validate_symlink_entry(&mut validator, "l2").is_ok());
1228
1229        let result = validate_symlink_entry(&mut validator, "l3");
1230        assert_matches!(
1231            result,
1232            Err(crate::ArchiveError::QuotaExceeded {
1233                resource: crate::QuotaResource::FileCount { .. }
1234            }),
1235            "symlinks must count against max_file_count, got: {result:?}"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_symlink_policy_error_wins_over_file_count() {
1241        let temp = TempDir::new().unwrap();
1242        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1243        let mut config = SecurityConfig::default();
1244        config.max_file_count = 1;
1245        let config = config.validate().expect("valid config");
1246        let mut validator = EntryValidator::new(&config, &dest);
1247        validator
1248            .validate_entry(Path::new("f"), &EntryType::File, 1, None, None, None)
1249            .unwrap();
1250
1251        let result = validate_symlink_entry(&mut validator, "l1");
1252        assert_matches!(
1253            result,
1254            Err(crate::ArchiveError::SecurityViolation { ref reason, .. })
1255                if reason == "symlinks not allowed",
1256            "got: {result:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn test_hardlink_policy_error_wins_over_file_count() {
1262        let temp = TempDir::new().unwrap();
1263        let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
1264        let mut config = SecurityConfig::default();
1265        config.max_file_count = 1;
1266        let config = config.validate().expect("valid config");
1267        let mut validator = EntryValidator::new(&config, &dest);
1268        validator
1269            .validate_entry(Path::new("f"), &EntryType::File, 1, None, None, None)
1270            .unwrap();
1271
1272        let result = validate_hardlink_entry(&mut validator, "l1");
1273        assert_matches!(
1274            result,
1275            Err(crate::ArchiveError::SecurityViolation { ref reason, .. })
1276                if reason == "hardlinks not allowed",
1277            "got: {result:?}"
1278        );
1279    }
1280}