webgates-core 1.0.0

Core domain types, permission system, and authorization building blocks for webgates.
Documentation
use super::permission_collision::PermissionCollision;
use tracing::{info, warn};

/// Validation outcome for a set of permission strings.
///
/// This is the main result type returned by permission validation APIs. It tells
/// you whether the permission set is safe to use and, if not, what needs to be
/// fixed.
///
/// Produced by:
/// - [`collision_checker::PermissionCollisionChecker::validate`](super::collision_checker::PermissionCollisionChecker::validate)
/// - [`application_validator::ApplicationValidator::validate`](super::application_validator::ApplicationValidator::validate)
///
/// # Terminology
/// - *Duplicate* permission: The exact same string appears more than once. These are
///   represented internally as a collision where every entry in the collision
///   group is identical.
/// - *Hash collision*: Two different normalized permission strings that deterministically
///   hash to the same ID. This is extremely unlikely and should be treated as a
///   critical configuration problem if it ever occurs.
///
/// # Interpreting Results
/// - [`ValidationReport::is_valid`] is `true` when there are no collisions at all.
/// - [`ValidationReport::duplicates`] returns only pure duplicates.
/// - Distinct collisions are more severe and appear in log output and
///   [`ValidationReport::detailed_errors`].
///
/// # Typical Actions
/// | Situation                             | Action                                                     | Severity         |
/// |---------------------------------------|------------------------------------------------------------|------------------|
/// | Report is valid                       | Proceed with startup or reload                             | None             |
/// | One or more duplicates only           | Remove redundant entries                                   | Low or medium    |
/// | Any non-duplicate hash collision      | Rename at least one colliding permission immediately       | High             |
///
/// # Convenience Methods
/// - [`ValidationReport::summary`] returns a compact human-readable description.
/// - [`ValidationReport::detailed_errors`] returns issue-by-issue diagnostics.
/// - [`ValidationReport::total_issues`] returns the number of collision groups.
///
/// # Example
/// ```rust
/// use webgates_core::permissions::application_validator::ApplicationValidator;
/// use webgates_core::permissions::collision_checker::PermissionCollisionChecker;
///
/// let mut checker = PermissionCollisionChecker::new(vec![
///     "user:read".into(),
///     "user:read".into(),
///     "admin:full".into(),
/// ]);
/// let report = checker.validate().map_err(|err| err.to_string())?;
/// assert!(!report.is_valid());
/// assert_eq!(report.duplicates(), vec!["user:read".to_string()]);
///
/// let report2 = ApplicationValidator::new()
///     .add_permissions(["user:read", "user:read"])
///     .validate()
///     .map_err(|err| err.to_string())?;
/// assert!(!report2.is_valid());
/// # Ok::<(), String>(())
/// ```
///
/// # Logging
/// Use [`ValidationReport::log_results`] for structured `tracing` output. Successful
/// validation logs at `INFO`, and issues log at `WARN`.
#[derive(Debug, Default)]
pub struct ValidationReport {
    /// All collision groups (duplicates and *true* hash collisions).
    ///
    /// Each entry contains:
    /// - The 64‑bit permission ID (`id`)
    /// - The list of original permission strings that map to that ID
    ///
    /// Invariants:
    /// - Length >= 2 for each `permissions` vector
    /// - A "duplicate" group has every element string-equal
    /// - A "distinct collision" group has at least one differing string
    pub collisions: Vec<PermissionCollision>,
}

impl ValidationReport {
    /// Returns `true` when validation passed without any issues.
    ///
    /// A report is valid when there are no duplicate groups and no true hash collisions.
    pub fn is_valid(&self) -> bool {
        self.collisions.is_empty()
    }

    /// Returns duplicate permission strings found in the report.
    ///
    /// Duplicates are collision groups where all permission strings are identical.
    pub fn duplicates(&self) -> Vec<String> {
        self.collisions
            .iter()
            .filter(|collision| {
                collision.permissions.len() > 1
                    && collision.permissions.windows(2).all(|w| w[0] == w[1])
            })
            .map(|collision| collision.permissions[0].clone())
            .collect()
    }

    /// Returns a human-readable summary of the validation result.
    ///
    /// For successful validation, this returns a success message. For failed
    /// validation, it summarizes duplicates and true hash collisions.
    pub fn summary(&self) -> String {
        if self.is_valid() {
            return "All permissions are valid and collision-free".to_string();
        }

        let mut parts = Vec::new();
        let duplicates = self.duplicates();

        if !duplicates.is_empty() {
            parts.push(format!(
                "{} duplicate permission string(s)",
                duplicates.len()
            ));
        }

        let non_duplicate_collisions = self
            .collisions
            .iter()
            .filter(|collision| {
                !(collision.permissions.len() > 1
                    && collision.permissions.windows(2).all(|w| w[0] == w[1]))
            })
            .count();

        if non_duplicate_collisions > 0 {
            let total_colliding = self
                .collisions
                .iter()
                .filter(|collision| {
                    !(collision.permissions.len() > 1
                        && collision.permissions.windows(2).all(|w| w[0] == w[1]))
                })
                .map(|c| c.permissions.len())
                .sum::<usize>();
            parts.push(format!(
                "{} hash collision(s) affecting {} permission(s)",
                non_duplicate_collisions, total_colliding
            ));
        }

        parts.join(", ")
    }

    /// Logs validation results using `tracing`.
    ///
    /// Successful validation logs at `INFO`. Any duplicates or collisions log at `WARN`.
    pub fn log_results(&self) {
        if self.is_valid() {
            info!("Permission validation passed: all permissions are valid");
            return;
        }

        let duplicates = self.duplicates();
        for duplicate in &duplicates {
            warn!("Duplicate permission string found: '{}'", duplicate);
        }

        for collision in &self.collisions {
            let is_duplicate = collision.permissions.len() > 1
                && collision.permissions.windows(2).all(|w| w[0] == w[1]);
            if !is_duplicate {
                warn!(
                    "Hash collision detected (ID: {}): permissions {:?} all hash to the same value",
                    collision.id, collision.permissions
                );
            }
        }
    }

    /// Returns detailed issue strings for debugging or reporting.
    pub fn detailed_errors(&self) -> Vec<String> {
        let mut errors = Vec::new();
        let duplicates = self.duplicates();

        for duplicate in &duplicates {
            errors.push(format!("Duplicate permission: '{}'", duplicate));
        }

        for collision in &self.collisions {
            let is_duplicate = collision.permissions.len() > 1
                && collision.permissions.windows(2).all(|w| w[0] == w[1]);
            if !is_duplicate {
                errors.push(format!(
                    "Hash collision (ID {}): {} -> {:?}",
                    collision.id,
                    collision.permissions.join(", "),
                    collision.permissions
                ));
            }
        }

        errors
    }

    /// Returns the number of collision groups recorded in the report.
    pub fn total_issues(&self) -> usize {
        self.collisions.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validation_report_summary() {
        let mut report = ValidationReport::default();
        assert!(report.is_valid());
        assert!(report.summary().contains("valid"));

        report.collisions.push(PermissionCollision {
            id: 12345,
            permissions: vec!["test".to_string(), "test".to_string()],
        });
        assert!(!report.is_valid());
        assert!(report.summary().contains("duplicate"));

        report.collisions.push(PermissionCollision {
            id: 12345,
            permissions: vec!["perm1".to_string(), "perm2".to_string()],
        });
        assert!(report.summary().contains("collision"));
    }

    #[test]
    fn validation_report_detailed_errors() {
        let mut report = ValidationReport::default();
        report.collisions.push(PermissionCollision {
            id: 54321,
            permissions: vec!["test:duplicate".to_string(), "test:duplicate".to_string()],
        });
        report.collisions.push(PermissionCollision {
            id: 12345,
            permissions: vec!["perm1".to_string(), "perm2".to_string()],
        });

        let errors = report.detailed_errors();
        assert_eq!(errors.len(), 2);
        assert!(errors[0].contains("Duplicate"));
        assert!(errors[1].contains("Hash collision"));
    }
}