Skip to main content

rma_common/suppression/
store.rs

1//! Sled-backed suppression store
2
3use super::{AuditAction, AuditEvent, SuppressionEntry, SuppressionStatus};
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8/// Filter criteria for listing suppressions
9#[derive(Debug, Clone, Default)]
10pub struct SuppressionFilter {
11    /// Filter by rule ID (exact match or prefix with wildcard)
12    pub rule_id: Option<String>,
13    /// Filter by file path (exact match or contains)
14    pub file_path: Option<PathBuf>,
15    /// Filter by status
16    pub status: Option<SuppressionStatus>,
17    /// Include all statuses (not just active)
18    pub include_all: bool,
19    /// Limit number of results
20    pub limit: Option<usize>,
21}
22
23impl SuppressionFilter {
24    /// Create a filter for active suppressions only
25    pub fn active_only() -> Self {
26        Self {
27            status: Some(SuppressionStatus::Active),
28            ..Default::default()
29        }
30    }
31
32    /// Create a filter that includes all suppressions
33    pub fn all() -> Self {
34        Self {
35            include_all: true,
36            ..Default::default()
37        }
38    }
39
40    /// Filter by rule ID
41    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    /// Filter by file path
47    pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
48        self.file_path = Some(path.into());
49        self
50    }
51
52    /// Limit results
53    pub fn with_limit(mut self, limit: usize) -> Self {
54        self.limit = Some(limit);
55        self
56    }
57}
58
59/// Export format for suppressions
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SuppressionExport {
62    /// Version of the export format
63    pub version: String,
64    /// When the export was created
65    pub exported_at: String,
66    /// Who created the export
67    pub exported_by: String,
68    /// The suppression entries
69    pub suppressions: Vec<SuppressionEntry>,
70}
71
72impl SuppressionExport {
73    /// Create a new export
74    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
84/// Sled-backed store for suppressions
85///
86/// ## Sled Layout
87///
88/// - `by_id`: Primary store, key=id, value=SuppressionEntry JSON
89/// - `by_fingerprint`: Index fingerprint -> id for fast lookup
90/// - `by_rule`: Index rule_id:id -> () for filtering by rule
91/// - `by_file`: Index file_path:id -> () for filtering by file
92/// - `audit_log`: key=timestamp-id, value=AuditEvent JSON
93pub struct SuppressionStore {
94    /// The Sled database
95    db: sled::Db,
96    /// Primary store: id -> SuppressionEntry
97    by_id: sled::Tree,
98    /// Index: fingerprint -> id
99    by_fingerprint: sled::Tree,
100    /// Index: rule_id:id -> () for fast rule filtering
101    by_rule: sled::Tree,
102    /// Index: file_path:id -> () for fast file filtering
103    by_file: sled::Tree,
104    /// Tree for audit log (key: timestamp-id, value: AuditEvent)
105    audit_log: sled::Tree,
106}
107
108impl SuppressionStore {
109    /// Open or create a suppression store at the given path
110    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    /// Get the database path
140    pub fn db_path(&self) -> Option<PathBuf> {
141        // sled doesn't expose the path directly, but we can get it from config
142        None // Would need to store it separately
143    }
144
145    /// Get the entry count
146    pub fn entry_count(&self) -> usize {
147        self.by_id.len()
148    }
149
150    /// Open or create a suppression store for a project
151    ///
152    /// Uses `.rma/suppressions.db` within the project root
153    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        // Create .rma directory if it doesn't exist
157        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    /// Check if a fingerprint is suppressed
166    ///
167    /// Returns the suppression entry if found and active
168    pub fn is_suppressed(&self, fingerprint: &str) -> Result<Option<SuppressionEntry>> {
169        // Look up the suppression ID from the fingerprint index
170        if let Some(id_bytes) = self.by_fingerprint.get(fingerprint.as_bytes())? {
171            let id = String::from_utf8_lossy(&id_bytes);
172
173            // Get the suppression entry
174            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                // Check if expired
179                if entry.is_expired() && entry.status == SuppressionStatus::Active {
180                    entry.status = SuppressionStatus::Expired;
181                    // Update the stored entry
182                    let _ = self.update_entry(&entry);
183                }
184
185                // Only return if active
186                if entry.is_active() {
187                    return Ok(Some(entry));
188                }
189            }
190        }
191
192        Ok(None)
193    }
194
195    /// Add a new suppression
196    ///
197    /// Returns the suppression ID
198    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        // Store the suppression in primary tree
204        self.by_id.insert(entry.id.as_bytes(), entry_json)?;
205
206        // Update indexes
207        self.by_fingerprint
208            .insert(entry.fingerprint.as_bytes(), entry.id.as_bytes())?;
209
210        // rule:id index for fast rule filtering
211        let rule_key = format!("{}:{}", entry.rule_id, entry.id);
212        self.by_rule.insert(rule_key.as_bytes(), &[])?;
213
214        // file:id index for fast file filtering
215        let file_key = format!("{}:{}", entry.file_path.display(), entry.id);
216        self.by_file.insert(file_key.as_bytes(), &[])?;
217
218        // Log the audit event
219        self.log_audit(AuditEvent::new(
220            &entry.id,
221            AuditAction::Created,
222            &entry.suppressed_by,
223        ))?;
224
225        // Flush to disk
226        self.db.flush()?;
227
228        Ok(id)
229    }
230
231    /// Revoke a suppression by ID
232    pub fn revoke(&self, id: &str, actor: &str) -> Result<bool> {
233        self.revoke_with_reason(id, actor, None)
234    }
235
236    /// Revoke a suppression by ID with optional reason
237    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            // Remove from fingerprint index
246            self.by_fingerprint.remove(entry.fingerprint.as_bytes())?;
247
248            // Remove from rule index
249            let rule_key = format!("{}:{}", entry.rule_id, entry.id);
250            self.by_rule.remove(rule_key.as_bytes())?;
251
252            // Remove from file index
253            let file_key = format!("{}:{}", entry.file_path.display(), entry.id);
254            self.by_file.remove(file_key.as_bytes())?;
255
256            // Log the audit event
257            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    /// Get a suppression by ID
272    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    /// List suppressions with optional filtering
281    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            // Update expired status
289            if entry.is_expired() && entry.status == SuppressionStatus::Active {
290                entry.status = SuppressionStatus::Expired;
291            }
292
293            // Apply filters
294            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        // Sort by created_at descending
337        results.sort_by(|a, b| b.created_at.cmp(&a.created_at));
338
339        Ok(results)
340    }
341
342    /// Export all active suppressions to JSON
343    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    /// Import suppressions from JSON
350    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            // Check if this fingerprint already has an active suppression
358            if self.is_suppressed(&entry.fingerprint)?.is_some() {
359                continue;
360            }
361
362            // Create a new entry with a fresh ID
363            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            // Preserve optional fields
373            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            // Log import audit event
400            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    /// Check for stale suppressions based on current findings
412    ///
413    /// Returns a list of suppressions that no longer match their original code
414    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    /// Clean up expired suppressions
438    ///
439    /// Returns the number of suppressions cleaned up
440    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                // Remove from fingerprint index
453                self.by_fingerprint.remove(entry.fingerprint.as_bytes())?;
454
455                // Log audit event
456                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    /// Get the audit log for a specific suppression
467    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        // Sort by timestamp descending
479        events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
480
481        Ok(events)
482    }
483
484    /// Get recent audit events
485    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    /// Get statistics about the store
502    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            // Update expired status
517            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    /// Update an existing entry (public version with audit logging)
546    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    /// Submit a suppression for approval
556    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    /// Approve a suppression
571    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            // Check if we have enough approvals
580            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    /// Reject a suppression
601    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    /// Add a tag to a suppression
619    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    /// Remove a tag from a suppression
635    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    /// Add a suppression to a group
651    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    /// Remove a suppression from a group
667    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    /// Schedule a suppression for auto-revocation
683    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    /// Cancel a scheduled revocation
705    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    /// Process scheduled revocations that are due
719    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                // Remove from indexes
735                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    /// List suppressions by tag
757    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    /// List suppressions by group
774    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    /// List all unique tags in the store
791    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    /// List all unique groups in the store
806    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    /// Bulk add tag to multiple suppressions
821    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    /// Bulk revoke suppressions
832    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    /// Update an existing entry (internal)
843    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    /// Log an audit event
850    fn log_audit(&self, event: AuditEvent) -> Result<()> {
851        // Use timestamp + id as key for ordering
852        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/// Statistics about the suppression store
860#[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        // Create suppressions in store1
930        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        // Export
950        let json = store1.export("exporter").unwrap();
951
952        // Import into store2
953        let imported = store2.import(&json, "importer").unwrap();
954        assert_eq!(imported, 2);
955
956        // Verify
957        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        // Filter by rule prefix
994        let security_only = store
995            .list(SuppressionFilter::all().with_rule("security/*"))
996            .unwrap();
997        assert_eq!(security_only.len(), 2);
998
999        // Filter by file
1000        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        // Most recent first
1020        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        // Create a suppression
1054        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        // Create suppression engine with the store
1065        let engine = SuppressionEngine::new(&RulesConfig::default(), false).with_store(store);
1066
1067        // Check that a finding with the known fingerprint is suppressed
1068        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        // Check that a finding with unknown fingerprint is NOT suppressed
1082        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}