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}