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}