axum_gate/permissions/
validation_report.rs

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