1use super::{AuditAction, AuditEvent, SuppressionEntry, SuppressionStatus};
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Default)]
10pub struct SuppressionFilter {
11 pub rule_id: Option<String>,
13 pub file_path: Option<PathBuf>,
15 pub status: Option<SuppressionStatus>,
17 pub include_all: bool,
19 pub limit: Option<usize>,
21}
22
23impl SuppressionFilter {
24 pub fn active_only() -> Self {
26 Self {
27 status: Some(SuppressionStatus::Active),
28 ..Default::default()
29 }
30 }
31
32 pub fn all() -> Self {
34 Self {
35 include_all: true,
36 ..Default::default()
37 }
38 }
39
40 pub fn with_rule(mut self, rule_id: impl Into<String>) -> Self {
42 self.rule_id = Some(rule_id.into());
43 self
44 }
45
46 pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
48 self.file_path = Some(path.into());
49 self
50 }
51
52 pub fn with_limit(mut self, limit: usize) -> Self {
54 self.limit = Some(limit);
55 self
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SuppressionExport {
62 pub version: String,
64 pub exported_at: String,
66 pub exported_by: String,
68 pub suppressions: Vec<SuppressionEntry>,
70}
71
72impl SuppressionExport {
73 pub fn new(suppressions: Vec<SuppressionEntry>, exported_by: impl Into<String>) -> Self {
75 Self {
76 version: "1.0".to_string(),
77 exported_at: chrono::Utc::now().to_rfc3339(),
78 exported_by: exported_by.into(),
79 suppressions,
80 }
81 }
82}
83
84pub struct SuppressionStore {
94 db: sled::Db,
96 by_id: sled::Tree,
98 by_fingerprint: sled::Tree,
100 by_rule: sled::Tree,
102 by_file: sled::Tree,
104 audit_log: sled::Tree,
106}
107
108impl SuppressionStore {
109 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
111 let db = sled::open(path.as_ref()).with_context(|| {
112 format!("Failed to open suppression database at {:?}", path.as_ref())
113 })?;
114
115 let by_id = db.open_tree("by_id").context("Failed to open by_id tree")?;
116 let by_fingerprint = db
117 .open_tree("by_fingerprint")
118 .context("Failed to open by_fingerprint tree")?;
119 let by_rule = db
120 .open_tree("by_rule")
121 .context("Failed to open by_rule tree")?;
122 let by_file = db
123 .open_tree("by_file")
124 .context("Failed to open by_file tree")?;
125 let audit_log = db
126 .open_tree("audit_log")
127 .context("Failed to open audit_log tree")?;
128
129 Ok(Self {
130 db,
131 by_id,
132 by_fingerprint,
133 by_rule,
134 by_file,
135 audit_log,
136 })
137 }
138
139 pub fn db_path(&self) -> Option<PathBuf> {
141 None }
144
145 pub fn entry_count(&self) -> usize {
147 self.by_id.len()
148 }
149
150 pub fn open_project(project_root: impl AsRef<Path>) -> Result<Self> {
154 let db_path = project_root.as_ref().join(".rma").join("suppressions.db");
155
156 if let Some(parent) = db_path.parent() {
158 std::fs::create_dir_all(parent)
159 .with_context(|| format!("Failed to create directory {:?}", parent))?;
160 }
161
162 Self::open(&db_path)
163 }
164
165 pub fn is_suppressed(&self, fingerprint: &str) -> Result<Option<SuppressionEntry>> {
169 if let Some(id_bytes) = self.by_fingerprint.get(fingerprint.as_bytes())? {
171 let id = String::from_utf8_lossy(&id_bytes);
172
173 if let Some(entry_bytes) = self.by_id.get(id.as_bytes())? {
175 let mut entry: SuppressionEntry = serde_json::from_slice(&entry_bytes)
176 .context("Failed to deserialize suppression entry")?;
177
178 if entry.is_expired() && entry.status == SuppressionStatus::Active {
180 entry.status = SuppressionStatus::Expired;
181 let _ = self.update_entry(&entry);
183 }
184
185 if entry.is_active() {
187 return Ok(Some(entry));
188 }
189 }
190 }
191
192 Ok(None)
193 }
194
195 pub fn suppress(&self, entry: SuppressionEntry) -> Result<String> {
199 let id = entry.id.clone();
200 let entry_json =
201 serde_json::to_vec(&entry).context("Failed to serialize suppression entry")?;
202
203 self.by_id.insert(entry.id.as_bytes(), entry_json)?;
205
206 self.by_fingerprint
208 .insert(entry.fingerprint.as_bytes(), entry.id.as_bytes())?;
209
210 let rule_key = format!("{}:{}", entry.rule_id, entry.id);
212 self.by_rule.insert(rule_key.as_bytes(), &[])?;
213
214 let file_key = format!("{}:{}", entry.file_path.display(), entry.id);
216 self.by_file.insert(file_key.as_bytes(), &[])?;
217
218 self.log_audit(AuditEvent::new(
220 &entry.id,
221 AuditAction::Created,
222 &entry.suppressed_by,
223 ))?;
224
225 self.db.flush()?;
227
228 Ok(id)
229 }
230
231 pub fn revoke(&self, id: &str, actor: &str) -> Result<bool> {
233 self.revoke_with_reason(id, actor, None)
234 }
235
236 pub fn revoke_with_reason(&self, id: &str, actor: &str, reason: Option<&str>) -> Result<bool> {
238 if let Some(entry_bytes) = self.by_id.get(id.as_bytes())? {
239 let mut entry: SuppressionEntry = serde_json::from_slice(&entry_bytes)?;
240 entry.revoke();
241
242 let entry_json = serde_json::to_vec(&entry)?;
243 self.by_id.insert(id.as_bytes(), entry_json)?;
244
245 self.by_fingerprint.remove(entry.fingerprint.as_bytes())?;
247
248 let rule_key = format!("{}:{}", entry.rule_id, entry.id);
250 self.by_rule.remove(rule_key.as_bytes())?;
251
252 let file_key = format!("{}:{}", entry.file_path.display(), entry.id);
254 self.by_file.remove(file_key.as_bytes())?;
255
256 let event = if let Some(r) = reason {
258 AuditEvent::new(id, AuditAction::Revoked, actor).reason(r)
259 } else {
260 AuditEvent::new(id, AuditAction::Revoked, actor)
261 };
262 self.log_audit(event)?;
263
264 self.db.flush()?;
265 return Ok(true);
266 }
267
268 Ok(false)
269 }
270
271 pub fn get(&self, id: &str) -> Result<Option<SuppressionEntry>> {
273 if let Some(entry_bytes) = self.by_id.get(id.as_bytes())? {
274 let entry: SuppressionEntry = serde_json::from_slice(&entry_bytes)?;
275 return Ok(Some(entry));
276 }
277 Ok(None)
278 }
279
280 pub fn list(&self, filter: SuppressionFilter) -> Result<Vec<SuppressionEntry>> {
282 let mut results = Vec::new();
283
284 for item in self.by_id.iter() {
285 let (_, value) = item?;
286 let mut entry: SuppressionEntry = serde_json::from_slice(&value)?;
287
288 if entry.is_expired() && entry.status == SuppressionStatus::Active {
290 entry.status = SuppressionStatus::Expired;
291 }
292
293 if !filter.include_all
295 && !entry.is_active()
296 && (filter.status.is_none() || filter.status != Some(entry.status))
297 {
298 continue;
299 }
300
301 if let Some(ref status) = filter.status
302 && entry.status != *status
303 {
304 continue;
305 }
306
307 if let Some(ref rule_id) = filter.rule_id {
308 if rule_id.ends_with('*') {
309 let prefix = rule_id.trim_end_matches('*');
310 if !entry.rule_id.starts_with(prefix) {
311 continue;
312 }
313 } else if entry.rule_id != *rule_id {
314 continue;
315 }
316 }
317
318 if let Some(ref file_path) = filter.file_path
319 && !entry
320 .file_path
321 .to_string_lossy()
322 .contains(file_path.to_string_lossy().as_ref())
323 {
324 continue;
325 }
326
327 results.push(entry);
328
329 if let Some(limit) = filter.limit
330 && results.len() >= limit
331 {
332 break;
333 }
334 }
335
336 results.sort_by(|a, b| b.created_at.cmp(&a.created_at));
338
339 Ok(results)
340 }
341
342 pub fn export(&self, actor: &str) -> Result<String> {
344 let entries = self.list(SuppressionFilter::active_only())?;
345 let export = SuppressionExport::new(entries, actor);
346 serde_json::to_string_pretty(&export).context("Failed to serialize export")
347 }
348
349 pub fn import(&self, json: &str, actor: &str) -> Result<usize> {
351 let export: SuppressionExport =
352 serde_json::from_str(json).context("Failed to parse import JSON")?;
353
354 let mut imported = 0;
355
356 for entry in export.suppressions {
357 if self.is_suppressed(&entry.fingerprint)?.is_some() {
359 continue;
360 }
361
362 let new_entry = SuppressionEntry::new(
364 &entry.fingerprint,
365 &entry.rule_id,
366 &entry.file_path,
367 actor,
368 &entry.reason,
369 )
370 .with_severity(entry.original_severity);
371
372 let new_entry = if let Some(snippet_hash) = entry.snippet_hash {
374 new_entry.with_snippet_hash(snippet_hash)
375 } else {
376 new_entry
377 };
378
379 let new_entry = if let Some(context_hash) = entry.context_hash {
380 new_entry.with_context_hash(context_hash)
381 } else {
382 new_entry
383 };
384
385 let new_entry = if let Some(ticket) = entry.ticket_ref {
386 new_entry.with_ticket(ticket)
387 } else {
388 new_entry
389 };
390
391 let new_entry = if let Some(expires_at) = entry.expires_at {
392 new_entry.with_expiration(expires_at)
393 } else {
394 new_entry
395 };
396
397 self.suppress(new_entry)?;
398
399 self.log_audit(
401 AuditEvent::new(&entry.id, AuditAction::Imported, actor)
402 .description(format!("Imported from export by {}", export.exported_by)),
403 )?;
404
405 imported += 1;
406 }
407
408 Ok(imported)
409 }
410
411 pub fn check_staleness<F>(&self, get_snippet: F) -> Result<Vec<SuppressionEntry>>
415 where
416 F: Fn(&SuppressionEntry) -> Option<String>,
417 {
418 let mut stale = Vec::new();
419
420 for item in self.by_id.iter() {
421 let (_, value) = item?;
422 let entry: SuppressionEntry = serde_json::from_slice(&value)?;
423
424 if entry.status != SuppressionStatus::Active {
425 continue;
426 }
427
428 let current_snippet = get_snippet(&entry);
429 if entry.is_stale(current_snippet.as_deref()) {
430 stale.push(entry);
431 }
432 }
433
434 Ok(stale)
435 }
436
437 pub fn cleanup_expired(&self, actor: &str) -> Result<usize> {
441 let mut cleaned = 0;
442
443 for item in self.by_id.iter() {
444 let (key, value) = item?;
445 let mut entry: SuppressionEntry = serde_json::from_slice(&value)?;
446
447 if entry.status == SuppressionStatus::Active && entry.is_expired() {
448 entry.status = SuppressionStatus::Expired;
449 let entry_json = serde_json::to_vec(&entry)?;
450 self.by_id.insert(&key, entry_json)?;
451
452 self.by_fingerprint.remove(entry.fingerprint.as_bytes())?;
454
455 self.log_audit(AuditEvent::new(&entry.id, AuditAction::Expired, actor))?;
457
458 cleaned += 1;
459 }
460 }
461
462 self.db.flush()?;
463 Ok(cleaned)
464 }
465
466 pub fn get_audit_log(&self, suppression_id: &str) -> Result<Vec<AuditEvent>> {
468 let mut events = Vec::new();
469
470 for item in self.audit_log.iter() {
471 let (_, value) = item?;
472 let event: AuditEvent = serde_json::from_slice(&value)?;
473 if event.suppression_id == suppression_id {
474 events.push(event);
475 }
476 }
477
478 events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
480
481 Ok(events)
482 }
483
484 pub fn get_recent_audit(&self, limit: usize) -> Result<Vec<AuditEvent>> {
486 let mut events = Vec::new();
487
488 for item in self.audit_log.iter().rev() {
489 let (_, value) = item?;
490 let event: AuditEvent = serde_json::from_slice(&value)?;
491 events.push(event);
492
493 if events.len() >= limit {
494 break;
495 }
496 }
497
498 Ok(events)
499 }
500
501 pub fn stats(&self) -> Result<StoreStats> {
503 let mut total = 0;
504 let mut active = 0;
505 let mut expired = 0;
506 let mut revoked = 0;
507 let mut stale = 0;
508 let mut pending_approval = 0;
509 let mut rejected = 0;
510 let mut scheduled_revocation = 0;
511
512 for item in self.by_id.iter() {
513 let (_, value) = item?;
514 let mut entry: SuppressionEntry = serde_json::from_slice(&value)?;
515
516 if entry.is_expired() && entry.status == SuppressionStatus::Active {
518 entry.status = SuppressionStatus::Expired;
519 }
520
521 total += 1;
522 match entry.status {
523 SuppressionStatus::Active => active += 1,
524 SuppressionStatus::Expired => expired += 1,
525 SuppressionStatus::Revoked => revoked += 1,
526 SuppressionStatus::Stale => stale += 1,
527 SuppressionStatus::PendingApproval => pending_approval += 1,
528 SuppressionStatus::Rejected => rejected += 1,
529 SuppressionStatus::ScheduledRevocation => scheduled_revocation += 1,
530 }
531 }
532
533 Ok(StoreStats {
534 total,
535 active,
536 expired,
537 revoked,
538 stale,
539 pending_approval,
540 rejected,
541 scheduled_revocation,
542 })
543 }
544
545 pub fn update(&self, entry: &SuppressionEntry, actor: &str) -> Result<()> {
547 self.update_entry(entry)?;
548 self.log_audit(
549 AuditEvent::new(&entry.id, AuditAction::Updated, actor).description("Entry updated"),
550 )?;
551 self.db.flush()?;
552 Ok(())
553 }
554
555 pub fn submit_for_approval(&self, id: &str, actor: &str) -> Result<bool> {
557 if let Some(mut entry) = self.get(id)? {
558 entry.status = SuppressionStatus::PendingApproval;
559 self.update_entry(&entry)?;
560 self.log_audit(
561 AuditEvent::new(id, AuditAction::SubmittedForApproval, actor)
562 .description("Submitted for approval"),
563 )?;
564 self.db.flush()?;
565 return Ok(true);
566 }
567 Ok(false)
568 }
569
570 pub fn approve(&self, id: &str, approver: &str, comment: Option<&str>) -> Result<bool> {
572 if let Some(mut entry) = self.get(id)? {
573 if entry.status != SuppressionStatus::PendingApproval {
574 anyhow::bail!("Suppression is not pending approval");
575 }
576
577 entry.add_approval(approver, comment);
578
579 if entry.is_approved() {
581 entry.status = SuppressionStatus::Active;
582 }
583
584 self.update_entry(&entry)?;
585
586 let event = AuditEvent::new(id, AuditAction::Approved, approver);
587 let event = if let Some(c) = comment {
588 event.description(c)
589 } else {
590 event
591 };
592 self.log_audit(event)?;
593
594 self.db.flush()?;
595 return Ok(true);
596 }
597 Ok(false)
598 }
599
600 pub fn reject(&self, id: &str, rejector: &str, reason: &str) -> Result<bool> {
602 if let Some(mut entry) = self.get(id)? {
603 if entry.status != SuppressionStatus::PendingApproval {
604 anyhow::bail!("Suppression is not pending approval");
605 }
606
607 entry.reject(rejector, reason);
608 self.update_entry(&entry)?;
609
610 self.log_audit(AuditEvent::new(id, AuditAction::Rejected, rejector).reason(reason))?;
611
612 self.db.flush()?;
613 return Ok(true);
614 }
615 Ok(false)
616 }
617
618 pub fn add_tag(&self, id: &str, tag: &str, actor: &str) -> Result<bool> {
620 if let Some(mut entry) = self.get(id)? {
621 if entry.tags.insert(tag.to_string()) {
622 self.update_entry(&entry)?;
623 self.log_audit(
624 AuditEvent::new(id, AuditAction::TagAdded, actor)
625 .description(format!("Added tag: {}", tag)),
626 )?;
627 self.db.flush()?;
628 }
629 return Ok(true);
630 }
631 Ok(false)
632 }
633
634 pub fn remove_tag(&self, id: &str, tag: &str, actor: &str) -> Result<bool> {
636 if let Some(mut entry) = self.get(id)? {
637 if entry.tags.remove(tag) {
638 self.update_entry(&entry)?;
639 self.log_audit(
640 AuditEvent::new(id, AuditAction::TagRemoved, actor)
641 .description(format!("Removed tag: {}", tag)),
642 )?;
643 self.db.flush()?;
644 }
645 return Ok(true);
646 }
647 Ok(false)
648 }
649
650 pub fn add_to_group(&self, id: &str, group: &str, actor: &str) -> Result<bool> {
652 if let Some(mut entry) = self.get(id)? {
653 if entry.groups.insert(group.to_string()) {
654 self.update_entry(&entry)?;
655 self.log_audit(
656 AuditEvent::new(id, AuditAction::AddedToGroup, actor)
657 .description(format!("Added to group: {}", group)),
658 )?;
659 self.db.flush()?;
660 }
661 return Ok(true);
662 }
663 Ok(false)
664 }
665
666 pub fn remove_from_group(&self, id: &str, group: &str, actor: &str) -> Result<bool> {
668 if let Some(mut entry) = self.get(id)? {
669 if entry.groups.remove(group) {
670 self.update_entry(&entry)?;
671 self.log_audit(
672 AuditEvent::new(id, AuditAction::RemovedFromGroup, actor)
673 .description(format!("Removed from group: {}", group)),
674 )?;
675 self.db.flush()?;
676 }
677 return Ok(true);
678 }
679 Ok(false)
680 }
681
682 pub fn schedule_revocation(
684 &self,
685 id: &str,
686 scheduled_for: &str,
687 actor: &str,
688 reason: &str,
689 ) -> Result<bool> {
690 if let Some(mut entry) = self.get(id)? {
691 entry.set_scheduled_revocation(scheduled_for, reason, actor);
692 self.update_entry(&entry)?;
693 self.log_audit(
694 AuditEvent::new(id, AuditAction::ScheduledRevocation, actor)
695 .description(format!("Scheduled for revocation on {}", scheduled_for))
696 .reason(reason),
697 )?;
698 self.db.flush()?;
699 return Ok(true);
700 }
701 Ok(false)
702 }
703
704 pub fn cancel_revocation(&self, id: &str, actor: &str) -> Result<bool> {
706 if let Some(mut entry) = self.get(id)?
707 && entry.status == SuppressionStatus::ScheduledRevocation
708 {
709 entry.cancel_scheduled_revocation();
710 self.update_entry(&entry)?;
711 self.log_audit(AuditEvent::new(id, AuditAction::RevocationCancelled, actor))?;
712 self.db.flush()?;
713 return Ok(true);
714 }
715 Ok(false)
716 }
717
718 pub fn process_scheduled_revocations(&self, actor: &str) -> Result<Vec<String>> {
720 let mut revoked = Vec::new();
721 let now = chrono::Utc::now().to_rfc3339();
722
723 for item in self.by_id.iter() {
724 let (_, value) = item?;
725 let mut entry: SuppressionEntry = serde_json::from_slice(&value)?;
726
727 if entry.status == SuppressionStatus::ScheduledRevocation
728 && let Some(ref schedule) = entry.scheduled_revocation
729 && schedule.scheduled_at <= now
730 {
731 entry.revoke();
732 self.update_entry(&entry)?;
733
734 self.by_fingerprint.remove(entry.fingerprint.as_bytes())?;
736 let rule_key = format!("{}:{}", entry.rule_id, entry.id);
737 self.by_rule.remove(rule_key.as_bytes())?;
738 let file_key = format!("{}:{}", entry.file_path.display(), entry.id);
739 self.by_file.remove(file_key.as_bytes())?;
740
741 self.log_audit(
742 AuditEvent::new(&entry.id, AuditAction::Revoked, actor)
743 .description("Auto-revoked as scheduled"),
744 )?;
745 revoked.push(entry.id.clone());
746 }
747 }
748
749 if !revoked.is_empty() {
750 self.db.flush()?;
751 }
752
753 Ok(revoked)
754 }
755
756 pub fn list_by_tag(&self, tag: &str) -> Result<Vec<SuppressionEntry>> {
758 let mut results = Vec::new();
759
760 for item in self.by_id.iter() {
761 let (_, value) = item?;
762 let entry: SuppressionEntry = serde_json::from_slice(&value)?;
763
764 if entry.tags.contains(tag) {
765 results.push(entry);
766 }
767 }
768
769 results.sort_by(|a, b| b.created_at.cmp(&a.created_at));
770 Ok(results)
771 }
772
773 pub fn list_by_group(&self, group: &str) -> Result<Vec<SuppressionEntry>> {
775 let mut results = Vec::new();
776
777 for item in self.by_id.iter() {
778 let (_, value) = item?;
779 let entry: SuppressionEntry = serde_json::from_slice(&value)?;
780
781 if entry.groups.contains(group) {
782 results.push(entry);
783 }
784 }
785
786 results.sort_by(|a, b| b.created_at.cmp(&a.created_at));
787 Ok(results)
788 }
789
790 pub fn list_tags(&self) -> Result<Vec<String>> {
792 let mut tags = std::collections::HashSet::new();
793
794 for item in self.by_id.iter() {
795 let (_, value) = item?;
796 let entry: SuppressionEntry = serde_json::from_slice(&value)?;
797 tags.extend(entry.tags);
798 }
799
800 let mut tags: Vec<_> = tags.into_iter().collect();
801 tags.sort();
802 Ok(tags)
803 }
804
805 pub fn list_groups(&self) -> Result<Vec<String>> {
807 let mut groups = std::collections::HashSet::new();
808
809 for item in self.by_id.iter() {
810 let (_, value) = item?;
811 let entry: SuppressionEntry = serde_json::from_slice(&value)?;
812 groups.extend(entry.groups);
813 }
814
815 let mut groups: Vec<_> = groups.into_iter().collect();
816 groups.sort();
817 Ok(groups)
818 }
819
820 pub fn bulk_add_tag(&self, ids: &[&str], tag: &str, actor: &str) -> Result<usize> {
822 let mut count = 0;
823 for id in ids {
824 if self.add_tag(id, tag, actor)? {
825 count += 1;
826 }
827 }
828 Ok(count)
829 }
830
831 pub fn bulk_revoke(&self, ids: &[&str], actor: &str, reason: Option<&str>) -> Result<usize> {
833 let mut count = 0;
834 for id in ids {
835 if self.revoke_with_reason(id, actor, reason)? {
836 count += 1;
837 }
838 }
839 Ok(count)
840 }
841
842 fn update_entry(&self, entry: &SuppressionEntry) -> Result<()> {
844 let entry_json = serde_json::to_vec(entry)?;
845 self.by_id.insert(entry.id.as_bytes(), entry_json)?;
846 Ok(())
847 }
848
849 fn log_audit(&self, event: AuditEvent) -> Result<()> {
851 let key = format!("{}-{}", event.timestamp, event.suppression_id);
853 let value = serde_json::to_vec(&event)?;
854 self.audit_log.insert(key.as_bytes(), value)?;
855 Ok(())
856 }
857}
858
859#[derive(Debug, Clone, Default)]
861pub struct StoreStats {
862 pub total: usize,
863 pub active: usize,
864 pub expired: usize,
865 pub revoked: usize,
866 pub stale: usize,
867 pub pending_approval: usize,
868 pub rejected: usize,
869 pub scheduled_revocation: usize,
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use tempfile::TempDir;
876
877 fn create_test_store() -> (SuppressionStore, TempDir) {
878 let temp = TempDir::new().unwrap();
879 let store = SuppressionStore::open(temp.path().join("test.db")).unwrap();
880 (store, temp)
881 }
882
883 #[test]
884 fn test_suppress_and_lookup() {
885 let (store, _temp) = create_test_store();
886
887 let entry = SuppressionEntry::new(
888 "sha256:abc123",
889 "generic/hardcoded-secret",
890 "src/test.rs",
891 "admin",
892 "Test fixture",
893 );
894
895 store.suppress(entry).unwrap();
896
897 let found = store.is_suppressed("sha256:abc123").unwrap();
898 assert!(found.is_some());
899
900 let found = found.unwrap();
901 assert_eq!(found.rule_id, "generic/hardcoded-secret");
902 assert_eq!(found.reason, "Test fixture");
903 }
904
905 #[test]
906 fn test_revoke() {
907 let (store, _temp) = create_test_store();
908
909 let entry = SuppressionEntry::new("sha256:abc123", "rule", "file.rs", "user", "reason");
910 store.suppress(entry).unwrap();
911
912 assert!(store.is_suppressed("sha256:abc123").unwrap().is_some());
913
914 store
915 .revoke(
916 &store.list(SuppressionFilter::all()).unwrap()[0].id,
917 "admin",
918 )
919 .unwrap();
920
921 assert!(store.is_suppressed("sha256:abc123").unwrap().is_none());
922 }
923
924 #[test]
925 fn test_export_import() {
926 let (store1, _temp1) = create_test_store();
927 let (store2, _temp2) = create_test_store();
928
929 store1
931 .suppress(SuppressionEntry::new(
932 "sha256:111",
933 "rule1",
934 "file1.rs",
935 "user",
936 "reason1",
937 ))
938 .unwrap();
939 store1
940 .suppress(SuppressionEntry::new(
941 "sha256:222",
942 "rule2",
943 "file2.rs",
944 "user",
945 "reason2",
946 ))
947 .unwrap();
948
949 let json = store1.export("exporter").unwrap();
951
952 let imported = store2.import(&json, "importer").unwrap();
954 assert_eq!(imported, 2);
955
956 assert!(store2.is_suppressed("sha256:111").unwrap().is_some());
958 assert!(store2.is_suppressed("sha256:222").unwrap().is_some());
959 }
960
961 #[test]
962 fn test_list_with_filter() {
963 let (store, _temp) = create_test_store();
964
965 store
966 .suppress(SuppressionEntry::new(
967 "fp1",
968 "security/sql-injection",
969 "file1.rs",
970 "user",
971 "reason",
972 ))
973 .unwrap();
974 store
975 .suppress(SuppressionEntry::new(
976 "fp2",
977 "security/xss",
978 "file2.rs",
979 "user",
980 "reason",
981 ))
982 .unwrap();
983 store
984 .suppress(SuppressionEntry::new(
985 "fp3",
986 "quality/long-function",
987 "file3.rs",
988 "user",
989 "reason",
990 ))
991 .unwrap();
992
993 let security_only = store
995 .list(SuppressionFilter::all().with_rule("security/*"))
996 .unwrap();
997 assert_eq!(security_only.len(), 2);
998
999 let file1_only = store
1001 .list(SuppressionFilter::all().with_file("file1"))
1002 .unwrap();
1003 assert_eq!(file1_only.len(), 1);
1004 }
1005
1006 #[test]
1007 fn test_audit_log() {
1008 let (store, _temp) = create_test_store();
1009
1010 let entry = SuppressionEntry::new("sha256:abc", "rule", "file.rs", "user1", "reason");
1011 let id = entry.id.clone();
1012
1013 store.suppress(entry).unwrap();
1014 store.revoke(&id, "user2").unwrap();
1015
1016 let log = store.get_audit_log(&id).unwrap();
1017 assert_eq!(log.len(), 2);
1018
1019 assert_eq!(log[0].action, AuditAction::Revoked);
1021 assert_eq!(log[0].actor, "user2");
1022 assert_eq!(log[1].action, AuditAction::Created);
1023 assert_eq!(log[1].actor, "user1");
1024 }
1025
1026 #[test]
1027 fn test_stats() {
1028 let (store, _temp) = create_test_store();
1029
1030 store
1031 .suppress(SuppressionEntry::new("fp1", "r1", "f1.rs", "u", "r"))
1032 .unwrap();
1033 store
1034 .suppress(SuppressionEntry::new("fp2", "r2", "f2.rs", "u", "r"))
1035 .unwrap();
1036
1037 let id = store.list(SuppressionFilter::all()).unwrap()[0].id.clone();
1038 store.revoke(&id, "admin").unwrap();
1039
1040 let stats = store.stats().unwrap();
1041 assert_eq!(stats.total, 2);
1042 assert_eq!(stats.active, 1);
1043 assert_eq!(stats.revoked, 1);
1044 }
1045
1046 #[test]
1047 fn test_suppression_engine_with_store() {
1048 use crate::config::{RulesConfig, SuppressionEngine, SuppressionSource};
1049 use std::path::PathBuf;
1050
1051 let (store, _temp) = create_test_store();
1052
1053 store
1055 .suppress(SuppressionEntry::new(
1056 "sha256:known_fingerprint",
1057 "security/sql-injection",
1058 "src/database.rs",
1059 "security-team",
1060 "Verified false positive - parameterized query",
1061 ))
1062 .unwrap();
1063
1064 let engine = SuppressionEngine::new(&RulesConfig::default(), false).with_store(store);
1066
1067 let result = engine.check(
1069 "security/sql-injection",
1070 &PathBuf::from("src/database.rs"),
1071 42,
1072 &[],
1073 Some("sha256:known_fingerprint"),
1074 );
1075
1076 assert!(result.suppressed);
1077 assert_eq!(result.source, Some(SuppressionSource::Database));
1078 assert!(result.reason.is_some());
1079 assert!(result.location.unwrap().starts_with("database:"));
1080
1081 let result = engine.check(
1083 "security/sql-injection",
1084 &PathBuf::from("src/database.rs"),
1085 42,
1086 &[],
1087 Some("sha256:unknown_fingerprint"),
1088 );
1089
1090 assert!(!result.suppressed);
1091 }
1092}