1use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ComplianceFramework {
18 #[serde(rename = "gdpr")]
19 GDPR,
20 #[serde(rename = "hipaa")]
21 HIPAA,
22 #[serde(rename = "soc2")]
23 SOC2,
24 #[serde(rename = "pci_dss")]
25 PCIDSS,
26}
27
28impl ComplianceFramework {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::GDPR => "gdpr",
32 Self::HIPAA => "hipaa",
33 Self::SOC2 => "soc2",
34 Self::PCIDSS => "pci_dss",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ComplianceRequirement {
41 pub requirement_id: String,
42 pub framework: ComplianceFramework,
43 pub name: String,
44 pub description: String,
45 pub controls: Vec<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum AuditEventType {
50 #[serde(rename = "scan_initiated")]
51 ScanInitiated,
52 #[serde(rename = "finding_discovered")]
53 FindingDiscovered,
54 #[serde(rename = "data_accessed")]
55 DataAccessed,
56 #[serde(rename = "user_login")]
57 UserLogin,
58 #[serde(rename = "user_logout")]
59 UserLogout,
60 #[serde(rename = "config_changed")]
61 ConfigChanged,
62 #[serde(rename = "report_recorded")]
63 ReportRecorded,
64 #[serde(rename = "access_denied")]
65 AccessDenied,
66}
67
68impl AuditEventType {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Self::ScanInitiated => "scan_initiated",
72 Self::FindingDiscovered => "finding_discovered",
73 Self::DataAccessed => "data_accessed",
74 Self::UserLogin => "user_login",
75 Self::UserLogout => "user_logout",
76 Self::ConfigChanged => "config_changed",
77 Self::ReportRecorded => "report_recorded",
78 Self::AccessDenied => "access_denied",
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct AuditLogEntry {
86 pub log_id: String,
87 pub timestamp: u64,
88 pub event_type: AuditEventType,
89 pub user_id: String,
90 pub resource: String,
91 pub action: String,
92 pub reported_status: String,
93 pub details: String,
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct AuditTrail {
99 entries: Vec<AuditLogEntry>,
100}
101
102impl AuditTrail {
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn record_entry(&mut self, entry: AuditLogEntry) {
108 self.entries.push(entry);
109 }
110
111 pub fn entries_by_type(&self, event_type: AuditEventType) -> Vec<&AuditLogEntry> {
112 self.entries
113 .iter()
114 .filter(|entry| entry.event_type == event_type)
115 .collect()
116 }
117
118 pub fn entries_by_user(&self, user_id: &str) -> Vec<&AuditLogEntry> {
119 self.entries
120 .iter()
121 .filter(|entry| entry.user_id == user_id)
122 .collect()
123 }
124
125 pub fn entries_since(&self, timestamp: u64) -> Vec<&AuditLogEntry> {
126 self.entries
127 .iter()
128 .filter(|entry| entry.timestamp >= timestamp)
129 .collect()
130 }
131
132 pub fn len(&self) -> usize {
133 self.entries.len()
134 }
135
136 pub fn is_empty(&self) -> bool {
137 self.entries.is_empty()
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ComplianceAssessment {
144 pub assessment_id: String,
145 pub framework: ComplianceFramework,
146 pub timestamp: u64,
147 pub total_controls: u32,
148 pub compliant_controls: u32,
149 pub non_compliant_controls: u32,
150}
151
152impl ComplianceAssessment {
153 pub fn has_consistent_control_counts(&self) -> bool {
154 u64::from(self.compliant_controls) + u64::from(self.non_compliant_controls)
155 == u64::from(self.total_controls)
156 }
157
158 pub fn compliance_percentage(&self) -> Option<f64> {
160 if self.total_controls == 0 || !self.has_consistent_control_counts() {
161 return None;
162 }
163 Some(f64::from(self.compliant_controls) / f64::from(self.total_controls) * 100.0)
164 }
165
166 pub fn meets_threshold(&self, threshold_percent: f64) -> Option<bool> {
168 if !threshold_percent.is_finite() || !(0.0..=100.0).contains(&threshold_percent) {
169 return None;
170 }
171 self.compliance_percentage()
172 .map(|percentage| percentage >= threshold_percent)
173 }
174}
175
176#[derive(Debug, Clone, Default)]
178pub struct ComplianceCatalog {
179 requirements: BTreeMap<String, ComplianceRequirement>,
180 assessments: Vec<ComplianceAssessment>,
181}
182
183impl ComplianceCatalog {
184 pub fn new() -> Self {
185 Self::default()
186 }
187
188 pub fn record_requirement(
189 &mut self,
190 requirement: ComplianceRequirement,
191 ) -> Option<ComplianceRequirement> {
192 self.requirements
193 .insert(requirement.requirement_id.clone(), requirement)
194 }
195
196 pub fn record_assessment(&mut self, assessment: ComplianceAssessment) -> bool {
198 if !assessment.has_consistent_control_counts() {
199 return false;
200 }
201 self.assessments.push(assessment);
202 true
203 }
204
205 pub fn requirements_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceRequirement> {
206 self.requirements
207 .values()
208 .filter(|requirement| requirement.framework == framework)
209 .collect()
210 }
211
212 pub fn assessments_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceAssessment> {
213 self.assessments
214 .iter()
215 .filter(|assessment| assessment.framework == framework)
216 .collect()
217 }
218
219 pub fn requirement_count(&self) -> usize {
220 self.requirements.len()
221 }
222
223 pub fn assessment_count(&self) -> usize {
224 self.assessments.len()
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
229pub enum DataClassification {
230 #[serde(rename = "public")]
231 Public,
232 #[serde(rename = "internal")]
233 Internal,
234 #[serde(rename = "confidential")]
235 Confidential,
236 #[serde(rename = "restricted")]
237 Restricted,
238}
239
240impl DataClassification {
241 pub fn as_str(self) -> &'static str {
242 match self {
243 Self::Public => "public",
244 Self::Internal => "internal",
245 Self::Confidential => "confidential",
246 Self::Restricted => "restricted",
247 }
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct DataProtectionRecord {
253 pub record_id: String,
254 pub data_type: String,
255 pub classification: DataClassification,
256 pub owner_id: String,
257 pub last_accessed: u64,
258 pub access_count: u32,
259 pub reported_encrypted: bool,
260}
261
262#[derive(Debug, Clone, Default)]
264pub struct DataProtectionCatalog {
265 records: BTreeMap<String, DataProtectionRecord>,
266}
267
268impl DataProtectionCatalog {
269 pub fn new() -> Self {
270 Self::default()
271 }
272
273 pub fn record(&mut self, record: DataProtectionRecord) -> Option<DataProtectionRecord> {
274 self.records.insert(record.record_id.clone(), record)
275 }
276
277 pub fn records_by_classification(
278 &self,
279 classification: DataClassification,
280 ) -> Vec<&DataProtectionRecord> {
281 self.records
282 .values()
283 .filter(|record| record.classification == classification)
284 .collect()
285 }
286
287 pub fn records_reported_unencrypted_at_or_above(
288 &self,
289 minimum: DataClassification,
290 ) -> Vec<&DataProtectionRecord> {
291 self.records
292 .values()
293 .filter(|record| !record.reported_encrypted && record.classification >= minimum)
294 .collect()
295 }
296
297 pub fn len(&self) -> usize {
298 self.records.len()
299 }
300
301 pub fn is_empty(&self) -> bool {
302 self.records.is_empty()
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct ComplianceReport {
309 pub report_id: String,
310 pub framework: ComplianceFramework,
311 pub generated_at: u64,
312 pub assessment_period_days: u32,
313 #[serde(with = "optional_percent_f32")]
314 pub reported_compliance_score_percent: Option<f32>,
315 pub reported_critical_findings: u32,
316 pub proposed_remediation_actions: Vec<String>,
317}
318
319impl ComplianceReport {
320 pub fn has_valid_reported_score(&self) -> bool {
321 self.reported_compliance_score_percent
322 .is_none_or(|score| score.is_finite() && (0.0..=100.0).contains(&score))
323 }
324}
325
326mod optional_percent_f32 {
327 use serde::{Deserialize, Deserializer, Serializer};
328
329 pub fn serialize<S>(value: &Option<f32>, serializer: S) -> Result<S::Ok, S::Error>
330 where
331 S: Serializer,
332 {
333 match value {
334 Some(value) if value.is_finite() && (0.0..=100.0).contains(value) => {
335 serializer.serialize_some(value)
336 },
337 Some(_) => Err(serde::ser::Error::custom(
338 "reported compliance score must be finite and between 0 and 100",
339 )),
340 None => serializer.serialize_none(),
341 }
342 }
343
344 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f32>, D::Error>
345 where
346 D: Deserializer<'de>,
347 {
348 let value = Option::<f32>::deserialize(deserializer)?;
349 Ok(match value {
350 Some(value) if value.is_finite() && (0.0..=100.0).contains(&value) => Some(value),
351 Some(_) => {
352 return Err(serde::de::Error::custom(
353 "reported compliance score must be finite and between 0 and 100",
354 ));
355 },
356 None => None,
357 })
358 }
359}
360
361#[derive(Debug, Clone, Default)]
363pub struct ComplianceReportCatalog {
364 reports: Vec<ComplianceReport>,
365}
366
367impl ComplianceReportCatalog {
368 pub fn new() -> Self {
369 Self::default()
370 }
371
372 pub fn record_report(&mut self, report: ComplianceReport) -> bool {
374 if !report.has_valid_reported_score() {
375 return false;
376 }
377 self.reports.push(report);
378 true
379 }
380
381 pub fn reports_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceReport> {
382 self.reports
383 .iter()
384 .filter(|report| report.framework == framework)
385 .collect()
386 }
387
388 pub fn most_recent_reports(&self, framework: ComplianceFramework) -> Vec<&ComplianceReport> {
390 let reports = self.reports_for(framework);
391 let Some(timestamp) = reports.iter().map(|report| report.generated_at).max() else {
392 return Vec::new();
393 };
394 reports
395 .into_iter()
396 .filter(|report| report.generated_at == timestamp)
397 .collect()
398 }
399
400 pub fn len(&self) -> usize {
401 self.reports.len()
402 }
403
404 pub fn is_empty(&self) -> bool {
405 self.reports.is_empty()
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn assessment(compliant: u32, non_compliant: u32) -> ComplianceAssessment {
414 ComplianceAssessment {
415 assessment_id: "assessment".into(),
416 framework: ComplianceFramework::GDPR,
417 timestamp: 1,
418 total_controls: 100,
419 compliant_controls: compliant,
420 non_compliant_controls: non_compliant,
421 }
422 }
423
424 #[test]
425 fn threshold_is_caller_supplied_not_hardcoded() {
426 let assessment = assessment(94, 6);
427 assert_eq!(assessment.meets_threshold(90.0), Some(true));
428 assert_eq!(assessment.meets_threshold(95.0), Some(false));
429 assert_eq!(assessment.meets_threshold(f64::NAN), None);
430 }
431
432 #[test]
433 fn inconsistent_counts_do_not_produce_a_score() {
434 let inconsistent = assessment(90, 5);
435 assert!(!inconsistent.has_consistent_control_counts());
436 assert_eq!(inconsistent.compliance_percentage(), None);
437 assert_eq!(inconsistent.meets_threshold(80.0), None);
438 }
439
440 #[test]
441 fn catalogs_are_empty_until_records_are_supplied() {
442 assert!(AuditTrail::new().is_empty());
443 assert!(DataProtectionCatalog::new().is_empty());
444 assert!(ComplianceReportCatalog::new().is_empty());
445 }
446
447 #[test]
448 fn most_recent_report_preserves_timestamp_ties() {
449 let mut catalog = ComplianceReportCatalog::new();
450 for id in ["one", "two"] {
451 assert!(catalog.record_report(ComplianceReport {
452 report_id: id.into(),
453 framework: ComplianceFramework::SOC2,
454 generated_at: 10,
455 assessment_period_days: 30,
456 reported_compliance_score_percent: None,
457 reported_critical_findings: 0,
458 proposed_remediation_actions: Vec::new(),
459 }));
460 }
461 assert_eq!(
462 catalog.most_recent_reports(ComplianceFramework::SOC2).len(),
463 2
464 );
465 }
466
467 #[test]
468 fn catalogs_reject_inconsistent_and_nonfinite_records() {
469 let mut assessments = ComplianceCatalog::new();
470 assert!(!assessments.record_assessment(assessment(90, 5)));
471 assert_eq!(assessments.assessment_count(), 0);
472
473 let mut reports = ComplianceReportCatalog::new();
474 let invalid = ComplianceReport {
475 report_id: "invalid".into(),
476 framework: ComplianceFramework::SOC2,
477 generated_at: 1,
478 assessment_period_days: 1,
479 reported_compliance_score_percent: Some(f32::NAN),
480 reported_critical_findings: 0,
481 proposed_remediation_actions: Vec::new(),
482 };
483 assert!(!reports.record_report(invalid.clone()));
484 assert!(serde_json::to_string(&invalid).is_err());
485 assert!(reports.is_empty());
486 }
487
488 #[test]
489 fn keyed_catalog_queries_are_stably_ordered() {
490 let mut catalog = ComplianceCatalog::new();
491 for id in ["zeta", "alpha"] {
492 let _ = catalog.record_requirement(ComplianceRequirement {
493 requirement_id: id.into(),
494 framework: ComplianceFramework::SOC2,
495 name: "fixture".into(),
496 description: "fixture".into(),
497 controls: Vec::new(),
498 });
499 }
500 assert_eq!(
501 catalog
502 .requirements_for(ComplianceFramework::SOC2)
503 .into_iter()
504 .map(|requirement| requirement.requirement_id.as_str())
505 .collect::<Vec<_>>(),
506 vec!["alpha", "zeta"]
507 );
508 }
509}