use-archive-policy 0.1.0

Archive safety policy primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

//! Archive safety policy primitives for `RustUse`.

use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
use use_archive_path::{ArchivePathIssue, archive_path_issues};

/// Policy primitives for safe extraction planning.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ArchivePolicy {
    /// Whether absolute or root-anchored paths are allowed.
    pub allow_absolute_paths: bool,
    /// Whether parent traversal components are allowed.
    pub allow_parent_traversal: bool,
    /// Whether symbolic link entries are allowed.
    pub allow_symlinks: bool,
    /// Maximum single entry payload size in bytes.
    pub max_entry_size: Option<u64>,
    /// Maximum total known payload size in bytes.
    pub max_total_size: Option<u64>,
    /// Maximum number of archive entries.
    pub max_entries: Option<usize>,
}

impl ArchivePolicy {
    /// Returns a strict extraction-oriented policy.
    #[must_use]
    pub const fn strict() -> Self {
        Self {
            allow_absolute_paths: false,
            allow_parent_traversal: false,
            allow_symlinks: false,
            max_entry_size: None,
            max_total_size: None,
            max_entries: None,
        }
    }

    /// Returns a permissive policy for trusted archive metadata.
    #[must_use]
    pub const fn permissive() -> Self {
        Self {
            allow_absolute_paths: true,
            allow_parent_traversal: true,
            allow_symlinks: true,
            max_entry_size: None,
            max_total_size: None,
            max_entries: None,
        }
    }

    /// Returns a policy suitable for listing-only workflows.
    #[must_use]
    pub const fn list_only() -> Self {
        Self::permissive()
    }

    /// Adds a maximum single entry size.
    #[must_use]
    pub const fn with_max_entry_size(mut self, max_entry_size: u64) -> Self {
        self.max_entry_size = Some(max_entry_size);
        self
    }

    /// Adds a maximum total known size.
    #[must_use]
    pub const fn with_max_total_size(mut self, max_total_size: u64) -> Self {
        self.max_total_size = Some(max_total_size);
        self
    }

    /// Adds a maximum entry count.
    #[must_use]
    pub const fn with_max_entries(mut self, max_entries: usize) -> Self {
        self.max_entries = Some(max_entries);
        self
    }

    /// Returns whether a path is allowed by this policy.
    #[must_use]
    pub fn allows_path(&self, path: &str) -> bool {
        archive_path_issues(path)
            .into_iter()
            .all(|issue| self.path_issue_allowed(issue))
    }

    /// Returns policy issues for a single entry.
    #[must_use]
    pub fn entry_issues(&self, entry: &ArchiveEntry) -> Vec<ArchivePolicyIssue> {
        let mut issues = Vec::new();

        for path_issue in archive_path_issues(entry.path()) {
            if !self.path_issue_allowed(path_issue) {
                issues.push(ArchivePolicyIssue::PathIssue(path_issue));
            }
        }

        if !self.allow_symlinks && entry.kind() == ArchiveEntryKind::Symlink {
            issues.push(ArchivePolicyIssue::SymlinkNotAllowed);
        }

        if let (Some(size), Some(max)) = (entry.size(), self.max_entry_size)
            && size > max
        {
            issues.push(ArchivePolicyIssue::EntryTooLarge { size, max });
        }

        issues
    }

    /// Returns whether a single entry is allowed by this policy.
    #[must_use]
    pub fn allows_entry(&self, entry: &ArchiveEntry) -> bool {
        self.entry_issues(entry).is_empty()
    }

    /// Returns policy issues for a complete entry listing.
    #[must_use]
    pub fn entries_issues(&self, entries: &[ArchiveEntry]) -> Vec<ArchivePolicyIssue> {
        let mut issues = Vec::new();

        for entry in entries {
            issues.extend(self.entry_issues(entry));
        }

        if let Some(max_entries) = self.max_entries
            && entries.len() > max_entries
        {
            issues.push(ArchivePolicyIssue::TooManyEntries {
                entries: entries.len(),
                max: max_entries,
            });
        }

        if let Some(max_total_size) = self.max_total_size {
            let total = entries.iter().filter_map(ArchiveEntry::size).sum();
            if total > max_total_size {
                issues.push(ArchivePolicyIssue::TotalSizeTooLarge {
                    total,
                    max: max_total_size,
                });
            }
        }

        issues
    }

    /// Returns whether a complete entry listing is allowed by this policy.
    #[must_use]
    pub fn allows_entries(&self, entries: &[ArchiveEntry]) -> bool {
        self.entries_issues(entries).is_empty()
    }

    fn path_issue_allowed(&self, issue: ArchivePathIssue) -> bool {
        match issue {
            ArchivePathIssue::AbsolutePath
            | ArchivePathIssue::WindowsDrivePrefix
            | ArchivePathIssue::WindowsUncPrefix => self.allow_absolute_paths,
            ArchivePathIssue::ParentTraversal => self.allow_parent_traversal,
            ArchivePathIssue::EmptyComponent | ArchivePathIssue::SuspiciousSeparator => {
                self.allow_absolute_paths && self.allow_parent_traversal
            },
        }
    }
}

impl Default for ArchivePolicy {
    fn default() -> Self {
        Self::strict()
    }
}

/// Policy violations detected for archive entries or entry lists.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ArchivePolicyIssue {
    /// The entry path violates the configured path policy.
    PathIssue(ArchivePathIssue),
    /// Symbolic links are disallowed by the policy.
    SymlinkNotAllowed,
    /// A single entry is larger than the configured maximum.
    EntryTooLarge {
        /// Observed entry size in bytes.
        size: u64,
        /// Configured maximum entry size in bytes.
        max: u64,
    },
    /// Total known entry size is larger than the configured maximum.
    TotalSizeTooLarge {
        /// Observed total known size in bytes.
        total: u64,
        /// Configured maximum total size in bytes.
        max: u64,
    },
    /// Entry count is larger than the configured maximum.
    TooManyEntries {
        /// Observed entry count.
        entries: usize,
        /// Configured maximum entry count.
        max: usize,
    },
}

#[cfg(test)]
mod tests {
    use super::{ArchivePolicy, ArchivePolicyIssue};
    use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
    use use_archive_path::ArchivePathIssue;

    #[test]
    fn strict_policy_rejects_unsafe_paths_and_symlinks() {
        let policy = ArchivePolicy::strict();
        let unsafe_entry = ArchiveEntry::new("../secrets.env", ArchiveEntryKind::File);
        let symlink = ArchiveEntry::new("link", ArchiveEntryKind::Symlink);

        assert!(
            policy
                .entry_issues(&unsafe_entry)
                .contains(&ArchivePolicyIssue::PathIssue(
                    ArchivePathIssue::ParentTraversal
                ))
        );
        assert!(
            policy
                .entry_issues(&symlink)
                .contains(&ArchivePolicyIssue::SymlinkNotAllowed)
        );
    }

    #[test]
    fn enforces_size_and_count_limits() {
        let policy = ArchivePolicy::strict()
            .with_max_entry_size(10)
            .with_max_total_size(20)
            .with_max_entries(1);
        let entries = vec![
            ArchiveEntry::file("one.txt").with_size(15),
            ArchiveEntry::file("two.txt").with_size(15),
        ];

        let issues = policy.entries_issues(&entries);

        assert!(issues.contains(&ArchivePolicyIssue::EntryTooLarge { size: 15, max: 10 }));
        assert!(issues.contains(&ArchivePolicyIssue::TotalSizeTooLarge { total: 30, max: 20 }));
        assert!(issues.contains(&ArchivePolicyIssue::TooManyEntries { entries: 2, max: 1 }));
    }

    #[test]
    fn permissive_policy_allows_listing_paths() {
        let policy = ArchivePolicy::list_only();

        assert!(policy.allows_path("../secrets.env"));
        assert!(policy.allows_path(r"C:\Users\name\secret.txt"));
    }
}