Skip to main content

webgates_core/permissions/
validation_report.rs

1use super::permission_collision::PermissionCollision;
2use tracing::{info, warn};
3
4/// Validation outcome for a set of permission strings.
5///
6/// This is the main result type returned by permission validation APIs. It tells
7/// you whether the permission set is safe to use and, if not, what needs to be
8/// fixed.
9///
10/// Produced by:
11/// - [`collision_checker::PermissionCollisionChecker::validate`](super::collision_checker::PermissionCollisionChecker::validate)
12/// - [`application_validator::ApplicationValidator::validate`](super::application_validator::ApplicationValidator::validate)
13///
14/// # Terminology
15/// - *Duplicate* permission: The exact same string appears more than once. These are
16///   represented internally as a collision where every entry in the collision
17///   group is identical.
18/// - *Hash collision*: Two different normalized permission strings that deterministically
19///   hash to the same ID. This is extremely unlikely and should be treated as a
20///   critical configuration problem if it ever occurs.
21///
22/// # Interpreting Results
23/// - [`ValidationReport::is_valid`] is `true` when there are no collisions at all.
24/// - [`ValidationReport::duplicates`] returns only pure duplicates.
25/// - Distinct collisions are more severe and appear in log output and
26///   [`ValidationReport::detailed_errors`].
27///
28/// # Typical Actions
29/// | Situation                             | Action                                                     | Severity         |
30/// |---------------------------------------|------------------------------------------------------------|------------------|
31/// | Report is valid                       | Proceed with startup or reload                             | None             |
32/// | One or more duplicates only           | Remove redundant entries                                   | Low or medium    |
33/// | Any non-duplicate hash collision      | Rename at least one colliding permission immediately       | High             |
34///
35/// # Convenience Methods
36/// - [`ValidationReport::summary`] returns a compact human-readable description.
37/// - [`ValidationReport::detailed_errors`] returns issue-by-issue diagnostics.
38/// - [`ValidationReport::total_issues`] returns the number of collision groups.
39///
40/// # Example
41/// ```rust
42/// use webgates_core::permissions::application_validator::ApplicationValidator;
43/// use webgates_core::permissions::collision_checker::PermissionCollisionChecker;
44///
45/// let mut checker = PermissionCollisionChecker::new(vec![
46///     "user:read".into(),
47///     "user:read".into(),
48///     "admin:full".into(),
49/// ]);
50/// let report = checker.validate().map_err(|err| err.to_string())?;
51/// assert!(!report.is_valid());
52/// assert_eq!(report.duplicates(), vec!["user:read".to_string()]);
53///
54/// let report2 = ApplicationValidator::new()
55///     .add_permissions(["user:read", "user:read"])
56///     .validate()
57///     .map_err(|err| err.to_string())?;
58/// assert!(!report2.is_valid());
59/// # Ok::<(), String>(())
60/// ```
61///
62/// # Logging
63/// Use [`ValidationReport::log_results`] for structured `tracing` output. Successful
64/// validation logs at `INFO`, and issues log at `WARN`.
65#[derive(Debug, Default)]
66pub struct ValidationReport {
67    /// All collision groups (duplicates and *true* hash collisions).
68    ///
69    /// Each entry contains:
70    /// - The 64‑bit permission ID (`id`)
71    /// - The list of original permission strings that map to that ID
72    ///
73    /// Invariants:
74    /// - Length >= 2 for each `permissions` vector
75    /// - A "duplicate" group has every element string-equal
76    /// - A "distinct collision" group has at least one differing string
77    pub collisions: Vec<PermissionCollision>,
78}
79
80impl ValidationReport {
81    /// Returns `true` when validation passed without any issues.
82    ///
83    /// A report is valid when there are no duplicate groups and no true hash collisions.
84    pub fn is_valid(&self) -> bool {
85        self.collisions.is_empty()
86    }
87
88    /// Returns duplicate permission strings found in the report.
89    ///
90    /// Duplicates are collision groups where all permission strings are identical.
91    pub fn duplicates(&self) -> Vec<String> {
92        self.collisions
93            .iter()
94            .filter(|collision| {
95                collision.permissions.len() > 1
96                    && collision.permissions.windows(2).all(|w| w[0] == w[1])
97            })
98            .map(|collision| collision.permissions[0].clone())
99            .collect()
100    }
101
102    /// Returns a human-readable summary of the validation result.
103    ///
104    /// For successful validation, this returns a success message. For failed
105    /// validation, it summarizes duplicates and true hash collisions.
106    pub fn summary(&self) -> String {
107        if self.is_valid() {
108            return "All permissions are valid and collision-free".to_string();
109        }
110
111        let mut parts = Vec::new();
112        let duplicates = self.duplicates();
113
114        if !duplicates.is_empty() {
115            parts.push(format!(
116                "{} duplicate permission string(s)",
117                duplicates.len()
118            ));
119        }
120
121        let non_duplicate_collisions = self
122            .collisions
123            .iter()
124            .filter(|collision| {
125                !(collision.permissions.len() > 1
126                    && collision.permissions.windows(2).all(|w| w[0] == w[1]))
127            })
128            .count();
129
130        if non_duplicate_collisions > 0 {
131            let total_colliding = self
132                .collisions
133                .iter()
134                .filter(|collision| {
135                    !(collision.permissions.len() > 1
136                        && collision.permissions.windows(2).all(|w| w[0] == w[1]))
137                })
138                .map(|c| c.permissions.len())
139                .sum::<usize>();
140            parts.push(format!(
141                "{} hash collision(s) affecting {} permission(s)",
142                non_duplicate_collisions, total_colliding
143            ));
144        }
145
146        parts.join(", ")
147    }
148
149    /// Logs validation results using `tracing`.
150    ///
151    /// Successful validation logs at `INFO`. Any duplicates or collisions log at `WARN`.
152    pub fn log_results(&self) {
153        if self.is_valid() {
154            info!("Permission validation passed: all permissions are valid");
155            return;
156        }
157
158        let duplicates = self.duplicates();
159        for duplicate in &duplicates {
160            warn!("Duplicate permission string found: '{}'", duplicate);
161        }
162
163        for collision in &self.collisions {
164            let is_duplicate = collision.permissions.len() > 1
165                && collision.permissions.windows(2).all(|w| w[0] == w[1]);
166            if !is_duplicate {
167                warn!(
168                    "Hash collision detected (ID: {}): permissions {:?} all hash to the same value",
169                    collision.id, collision.permissions
170                );
171            }
172        }
173    }
174
175    /// Returns detailed issue strings for debugging or reporting.
176    pub fn detailed_errors(&self) -> Vec<String> {
177        let mut errors = Vec::new();
178        let duplicates = self.duplicates();
179
180        for duplicate in &duplicates {
181            errors.push(format!("Duplicate permission: '{}'", duplicate));
182        }
183
184        for collision in &self.collisions {
185            let is_duplicate = collision.permissions.len() > 1
186                && collision.permissions.windows(2).all(|w| w[0] == w[1]);
187            if !is_duplicate {
188                errors.push(format!(
189                    "Hash collision (ID {}): {} -> {:?}",
190                    collision.id,
191                    collision.permissions.join(", "),
192                    collision.permissions
193                ));
194            }
195        }
196
197        errors
198    }
199
200    /// Returns the number of collision groups recorded in the report.
201    pub fn total_issues(&self) -> usize {
202        self.collisions.len()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn validation_report_summary() {
212        let mut report = ValidationReport::default();
213        assert!(report.is_valid());
214        assert!(report.summary().contains("valid"));
215
216        report.collisions.push(PermissionCollision {
217            id: 12345,
218            permissions: vec!["test".to_string(), "test".to_string()],
219        });
220        assert!(!report.is_valid());
221        assert!(report.summary().contains("duplicate"));
222
223        report.collisions.push(PermissionCollision {
224            id: 12345,
225            permissions: vec!["perm1".to_string(), "perm2".to_string()],
226        });
227        assert!(report.summary().contains("collision"));
228    }
229
230    #[test]
231    fn validation_report_detailed_errors() {
232        let mut report = ValidationReport::default();
233        report.collisions.push(PermissionCollision {
234            id: 54321,
235            permissions: vec!["test:duplicate".to_string(), "test:duplicate".to_string()],
236        });
237        report.collisions.push(PermissionCollision {
238            id: 12345,
239            permissions: vec!["perm1".to_string(), "perm2".to_string()],
240        });
241
242        let errors = report.detailed_errors();
243        assert_eq!(errors.len(), 2);
244        assert!(errors[0].contains("Duplicate"));
245        assert!(errors[1].contains("Hash collision"));
246    }
247}