Skip to main content

sentinel_core/
acknowledgments.rs

1//! Ignore rules / acknowledgments for findings.
2//!
3//! Loads `.perf-sentinel-acknowledgments.toml`, computes a canonical
4//! signature per [`Finding`], filters findings flagged as acknowledged
5//! at the post-processing stage, and re-evaluates the quality gate on
6//! the surviving set so an ack can flip a previously failing gate to
7//! green.
8//!
9//! This is the CI / batch-mode side of the ack workflow. The daemon
10//! runtime ack store lives at `crate::daemon::ack` and shares the
11//! signature format defined here. The two are unioned at query time
12//! with TOML winning on conflict (immutable baseline shipped via PR
13//! review).
14
15use std::borrow::Cow;
16use std::collections::HashMap;
17use std::fmt::Write as _;
18use std::io::Read;
19use std::path::Path;
20
21use chrono::{DateTime, NaiveDate, Utc};
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25use crate::config::Config;
26use crate::detect::Finding;
27use crate::quality_gate;
28use crate::report::{AcknowledgedFinding, Report};
29
30/// Hard cap on the size of `.perf-sentinel-acknowledgments.toml`. Mirrors
31/// the trace-ingest payload-cap discipline so a stray
32/// `--acknowledgments /dev/zero` or a multi-GB malformed TOML cannot
33/// silently exhaust process memory.
34pub const MAX_ACKNOWLEDGMENTS_FILE_BYTES: u64 = 16 * 1024 * 1024;
35
36/// A single acknowledgment entry deserialized from the TOML file.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Acknowledgment {
39    /// Canonical signature: `<finding_type>:<service>:<sanitized_endpoint>:<sha256-prefix>`.
40    pub signature: String,
41    /// Email or identifier of the user who created the ack.
42    pub acknowledged_by: String,
43    /// ISO 8601 date when the ack was created (`YYYY-MM-DD`).
44    pub acknowledged_at: String,
45    /// Free-text reason / context for the ack.
46    pub reason: String,
47    /// Optional ISO 8601 date (`YYYY-MM-DD`) at which the ack expires.
48    /// `None` means the ack is permanent.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub expires_at: Option<String>,
51}
52
53/// Container for the deserialized TOML file.
54///
55/// The TOML root is `[[acknowledged]]` blocks. Empty file (no blocks)
56/// deserializes to a default value, making "file exists but is empty" a
57/// no-op.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct AcknowledgmentsFile {
60    #[serde(default)]
61    pub acknowledged: Vec<Acknowledgment>,
62}
63
64/// Compute the canonical signature of a finding.
65///
66/// Format: `<finding_type>:<service>:<sanitized_endpoint>:<sha256-prefix-of-template>`.
67/// The `sha256` prefix uses the first 16 bytes (32 hex characters), giving
68/// ~128 bits of collision resistance. The triple
69/// `(finding_type, service, sanitized_endpoint)` is already part of the
70/// signature, so the hash only needs to disambiguate templates within the
71/// same triple, an extremely small population in practice. The 32-char
72/// prefix is defense in depth against accidental ack masking after a SQL
73/// refactor or a service rename.
74///
75/// Sanitization replaces `/` and ` ` (space) inside `source_endpoint`
76/// with `_` so the resulting signature uses `:` as a single, unambiguous
77/// separator that operators can split on in shell pipelines. `BiDi`
78/// override and invisible-format characters (Trojan Source, CVE-2021-42574)
79/// are stripped from both `service` and `source_endpoint` so two visually
80/// identical signatures cannot map to distinct ack entries.
81#[must_use]
82pub fn compute_signature(finding: &Finding) -> String {
83    let mut hasher = Sha256::new();
84    hasher.update(finding.pattern.template.as_bytes());
85    let digest = hasher.finalize();
86    let safe_service = crate::text_safety::strip_bidi_and_invisible(&finding.service);
87    let sanitized_endpoint = sanitize_endpoint(&finding.source_endpoint);
88    let safe_endpoint = crate::text_safety::strip_bidi_and_invisible(&sanitized_endpoint);
89    let kind = finding.finding_type.as_str();
90    // Pre-size: type + 2 separators + service + endpoint + ':' + 32 hex.
91    let mut out = String::with_capacity(kind.len() + safe_service.len() + safe_endpoint.len() + 35);
92    out.push_str(kind);
93    out.push(':');
94    out.push_str(safe_service.as_ref());
95    out.push(':');
96    out.push_str(safe_endpoint.as_ref());
97    out.push(':');
98    for byte in &digest[..16] {
99        let _ = write!(out, "{byte:02x}");
100    }
101    out
102}
103
104fn sanitize_endpoint(endpoint: &str) -> Cow<'_, str> {
105    if endpoint.bytes().any(|b| matches!(b, b'/' | b' ')) {
106        Cow::Owned(endpoint.replace(['/', ' '], "_"))
107    } else {
108        Cow::Borrowed(endpoint)
109    }
110}
111
112/// Fill in the `signature` field of every finding in place.
113///
114/// Idempotent: an existing signature is overwritten so re-running this
115/// function on a baseline that already carries signatures (e.g. a
116/// pre-0.5.17 dump that was just re-emitted) keeps the values fresh
117/// against the current signature scheme.
118pub fn enrich_with_signatures(findings: &mut [Finding]) {
119    for finding in findings.iter_mut() {
120        finding.signature = compute_signature(finding);
121    }
122}
123
124/// Load acknowledgments from a TOML file.
125///
126/// Returns `Ok(default)` when the file does not exist, so a project
127/// without any acks observes the legacy behavior with zero error noise.
128/// Returns `Err` on TOML parse failure or on a malformed `expires_at`
129/// date so a typo in the ack file fails the run loud rather than
130/// silently widening the matched set.
131///
132/// Reads with a hard cap of [`MAX_ACKNOWLEDGMENTS_FILE_BYTES`]. The TOML
133/// crate has no public depth limiter, but the size cap keeps the worst
134/// case bounded and rejects `/dev/zero` and the like.
135///
136/// # Errors
137///
138/// - [`AcknowledgmentLoadError::Io`] when the file exists but cannot be read.
139/// - [`AcknowledgmentLoadError::TooLarge`] when the file exceeds the cap.
140/// - [`AcknowledgmentLoadError::Parse`] when the TOML cannot be parsed.
141/// - [`AcknowledgmentLoadError::InvalidDate`] when an `expires_at` value is
142///   not a valid `YYYY-MM-DD` ISO 8601 date.
143pub fn load_from_file(path: &Path) -> Result<AcknowledgmentsFile, AcknowledgmentLoadError> {
144    // Use symlink_metadata so a symlink at the configured path does not
145    // redirect the read to a sensitive file (e.g. a hostile collaborator
146    // landing a symlink to /etc/passwd in a CI runner working tree). The
147    // daemon JSONL store applies the same discipline at write time, this
148    // mirrors it for the read-side baseline.
149    match std::fs::symlink_metadata(path) {
150        Ok(meta) => {
151            if meta.file_type().is_symlink() {
152                return Err(AcknowledgmentLoadError::SymlinkRefused);
153            }
154        }
155        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
156            return Ok(AcknowledgmentsFile::default());
157        }
158        Err(err) => return Err(AcknowledgmentLoadError::Io(err)),
159    }
160    let file = std::fs::File::open(path).map_err(AcknowledgmentLoadError::Io)?;
161    // `take(cap + 1)` closes the TOCTOU window between metadata().len()
162    // and read(): we read at most cap+1 bytes, and reject if we hit the
163    // cap+1th byte. Same pattern as `read_file_capped` in the CLI.
164    let mut buf = String::new();
165    file.take(MAX_ACKNOWLEDGMENTS_FILE_BYTES + 1)
166        .read_to_string(&mut buf)
167        .map_err(AcknowledgmentLoadError::Io)?;
168    if buf.len() as u64 > MAX_ACKNOWLEDGMENTS_FILE_BYTES {
169        return Err(AcknowledgmentLoadError::TooLarge {
170            cap: MAX_ACKNOWLEDGMENTS_FILE_BYTES,
171        });
172    }
173    let parsed: AcknowledgmentsFile =
174        toml::from_str(&buf).map_err(AcknowledgmentLoadError::Parse)?;
175
176    for (idx, ack) in parsed.acknowledged.iter().enumerate() {
177        if let Some(ref expires) = ack.expires_at {
178            NaiveDate::parse_from_str(expires, "%Y-%m-%d").map_err(|e| {
179                AcknowledgmentLoadError::InvalidDate {
180                    entry_index: idx,
181                    field: "expires_at",
182                    value: expires.clone(),
183                    message: e.to_string(),
184                }
185            })?;
186        }
187    }
188
189    Ok(parsed)
190}
191
192/// Apply acknowledgments to a `Report` in place.
193///
194/// 1. Clears any prior `report.acknowledged_findings` so a Report fed
195///    back through this function (e.g. a baseline JSON round-trip)
196///    cannot accumulate stale ack pairs across runs.
197/// 2. Filters `report.findings`, moving acked entries into
198///    `report.acknowledged_findings`.
199/// 3. Re-evaluates the quality gate on the surviving set so an ack can
200///    flip a previously failing gate to green (the entire point of
201///    "won't fix / accepted" semantics). Re-evaluation runs even when no
202///    ack matched, so the gate field is always self-consistent with the
203///    final `findings` slice.
204///
205/// Acks with an `expires_at` strictly before `now` are treated as inactive
206/// and the corresponding finding is preserved in `report.findings`.
207pub fn apply_to_report(
208    report: &mut Report,
209    acks: &AcknowledgmentsFile,
210    config: &Config,
211    now: DateTime<Utc>,
212) {
213    // Drop any prior ack pairs from the source Report. The caller may
214    // have loaded a baseline that already carried `acknowledged_findings`
215    // from a previous `--show-acknowledged` run, which we do not want to
216    // double-count or treat as authoritative.
217    report.acknowledged_findings.clear();
218
219    let active: HashMap<&str, &Acknowledgment> = acks
220        .acknowledged
221        .iter()
222        .filter(|a| is_ack_active(a, now))
223        .map(|a| (a.signature.as_str(), a))
224        .collect();
225
226    if !active.is_empty() {
227        let original = std::mem::take(&mut report.findings);
228        let mut kept = Vec::with_capacity(original.len());
229        for finding in original {
230            let sig: Cow<'_, str> = if finding.signature.is_empty() {
231                Cow::Owned(compute_signature(&finding))
232            } else {
233                Cow::Borrowed(finding.signature.as_str())
234            };
235            if let Some(ack) = active.get(sig.as_ref()) {
236                report.acknowledged_findings.push(AcknowledgedFinding {
237                    finding,
238                    acknowledgment: (*ack).clone(),
239                });
240            } else {
241                kept.push(finding);
242            }
243        }
244        report.findings = kept;
245    }
246
247    report.quality_gate = quality_gate::evaluate(&report.findings, &report.green_summary, config);
248}
249
250pub(crate) fn is_ack_active(ack: &Acknowledgment, now: DateTime<Utc>) -> bool {
251    let Some(ref expires) = ack.expires_at else {
252        return true;
253    };
254    let Ok(parsed) = NaiveDate::parse_from_str(expires, "%Y-%m-%d") else {
255        // Malformed dates are rejected at load time; defensively treat a
256        // bad value as inactive rather than ack-everything.
257        return false;
258    };
259    // Treat the entire expiry day as still valid: an ack `expires_at =
260    // 2026-12-31` is honored through 2026-12-31 23:59:59 UTC.
261    let Some(end_of_day) = parsed.and_hms_opt(23, 59, 59) else {
262        return false;
263    };
264    end_of_day.and_utc() >= now
265}
266
267/// Errors that can occur when loading the acknowledgments file.
268#[derive(Debug, thiserror::Error)]
269pub enum AcknowledgmentLoadError {
270    #[error("Failed to read acknowledgments file: {0}")]
271    Io(#[from] std::io::Error),
272
273    #[error("Acknowledgments file exceeds the {cap}-byte cap")]
274    TooLarge { cap: u64 },
275
276    #[error("Failed to parse acknowledgments TOML: {0}")]
277    Parse(toml::de::Error),
278
279    #[error("Entry {entry_index}: invalid {field} value '{value}': {message}")]
280    InvalidDate {
281        entry_index: usize,
282        field: &'static str,
283        value: String,
284        message: String,
285    },
286
287    #[error("Acknowledgments file is a symlink, refusing to follow")]
288    SymlinkRefused,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::detect::{FindingType, Severity};
295    use crate::report::{Analysis, GreenSummary, QualityGate};
296    use crate::test_helpers::make_finding;
297    use chrono::TimeZone;
298    use core::assert_matches;
299
300    fn empty_report(findings: Vec<Finding>) -> Report {
301        Report {
302            analysis: Analysis {
303                duration_ms: 0,
304                events_processed: findings.len(),
305                traces_analyzed: 1,
306            },
307            findings,
308            green_summary: GreenSummary::disabled(0),
309            quality_gate: QualityGate {
310                passed: true,
311                rules: vec![],
312            },
313            per_endpoint_io_ops: vec![],
314            correlations: vec![],
315            warnings: vec![],
316            warning_details: vec![],
317            acknowledged_findings: vec![],
318            binary_version: String::new(),
319            disclosure_waste: None,
320        }
321    }
322
323    fn ack(signature: &str, expires_at: Option<&str>) -> Acknowledgment {
324        Acknowledgment {
325            signature: signature.to_string(),
326            acknowledged_by: "test@example.com".to_string(),
327            acknowledged_at: "2026-05-02".to_string(),
328            reason: "test".to_string(),
329            expires_at: expires_at.map(str::to_string),
330        }
331    }
332
333    fn now_2026_05_02() -> DateTime<Utc> {
334        Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap()
335    }
336
337    #[test]
338    fn compute_signature_deterministic() {
339        let f = make_finding(FindingType::NPlusOneSql, Severity::Warning);
340        let sig1 = compute_signature(&f);
341        let sig2 = compute_signature(&f);
342        assert_eq!(sig1, sig2);
343    }
344
345    #[test]
346    fn compute_signature_differs_with_template() {
347        let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
348        let mut f2 = f1.clone();
349        f1.pattern.template = "SELECT * FROM users WHERE id = ?".to_string();
350        f2.pattern.template = "SELECT * FROM orders WHERE id = ?".to_string();
351        assert_ne!(compute_signature(&f1), compute_signature(&f2));
352    }
353
354    #[test]
355    fn compute_signature_sanitizes_endpoint() {
356        let mut f = make_finding(FindingType::NPlusOneSql, Severity::Warning);
357        f.source_endpoint = "GET /api/foo bar".to_string();
358        let sig = compute_signature(&f);
359        let parts: Vec<&str> = sig.split(':').collect();
360        assert_eq!(
361            parts.len(),
362            4,
363            "signature must have 4 colon-separated parts: {sig}"
364        );
365        assert!(
366            !parts[2].contains('/'),
367            "endpoint segment must not contain '/'"
368        );
369        assert!(
370            !parts[2].contains(' '),
371            "endpoint segment must not contain ' '"
372        );
373    }
374
375    #[test]
376    fn compute_signature_strips_bidi_and_invisible_from_service_and_endpoint() {
377        // service "alice<RLO>@evil.com" should produce the same signature as
378        // "alice@evil.com" so a hostile span attribute cannot fork ack matching.
379        let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
380        let mut f2 = f1.clone();
381        f1.service = "alice\u{202E}@evil.com".to_string();
382        f1.source_endpoint = "GET /api/items\u{200B}".to_string();
383        f2.service = "alice@evil.com".to_string();
384        f2.source_endpoint = "GET /api/items".to_string();
385        assert_eq!(
386            compute_signature(&f1),
387            compute_signature(&f2),
388            "BiDi/invisible characters must be stripped before signature construction"
389        );
390    }
391
392    #[test]
393    fn compute_signature_format_matches_brief() {
394        let mut f = make_finding(FindingType::RedundantSql, Severity::Warning);
395        f.service = "order-service".to_string();
396        f.source_endpoint = "POST /api/orders".to_string();
397        f.pattern.template = "SELECT 1".to_string();
398        let sig = compute_signature(&f);
399        // Format: redundant_sql:order-service:POST_/api/orders → after sanitization
400        // POST_/api/orders becomes POST__api_orders.
401        let mut parts = sig.splitn(4, ':');
402        assert_eq!(parts.next(), Some("redundant_sql"));
403        assert_eq!(parts.next(), Some("order-service"));
404        assert_eq!(parts.next(), Some("POST__api_orders"));
405        let hex = parts.next().expect("hex prefix present");
406        assert_eq!(hex.len(), 32, "hex prefix is 32 characters (16 bytes)");
407        assert!(
408            hex.chars().all(|c| c.is_ascii_hexdigit()),
409            "hex prefix is hex"
410        );
411    }
412
413    #[test]
414    fn signature_stable_across_trace_id_changes() {
415        // Core ack contract: a service restart produces new trace_id and
416        // span_id values, but the same finding type on the same service /
417        // endpoint / template must yield the same signature. Without this
418        // invariant, ack entries silently stop matching after a restart.
419        let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
420        let mut f2 = f1.clone();
421        f1.trace_id = "aaaaaaaaaaaaaaaa0000000000000000".to_string();
422        f2.trace_id = "ffffffffffffffff1111111111111111".to_string();
423        assert_ne!(f1.trace_id, f2.trace_id);
424        assert_eq!(
425            compute_signature(&f1),
426            compute_signature(&f2),
427            "signature must not depend on trace_id (acks survive service restarts)"
428        );
429    }
430
431    #[test]
432    fn compute_signature_differs_with_endpoint() {
433        let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
434        let mut f2 = f1.clone();
435        f1.source_endpoint = "POST /api/orders".to_string();
436        f2.source_endpoint = "POST /api/users".to_string();
437        assert_ne!(compute_signature(&f1), compute_signature(&f2));
438    }
439
440    #[test]
441    fn compute_signature_differs_with_service() {
442        let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
443        let mut f2 = f1.clone();
444        f1.service = "order-svc".to_string();
445        f2.service = "user-svc".to_string();
446        assert_ne!(compute_signature(&f1), compute_signature(&f2));
447    }
448
449    #[test]
450    fn compute_signature_differs_with_finding_type() {
451        let f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
452        let f2 = make_finding(FindingType::RedundantSql, Severity::Warning);
453        assert_ne!(compute_signature(&f1), compute_signature(&f2));
454    }
455
456    #[test]
457    fn load_from_file_rejects_oversized_input() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join("acks.toml");
460        let payload = vec![b'x'; (MAX_ACKNOWLEDGMENTS_FILE_BYTES + 1) as usize];
461        std::fs::write(&path, &payload).unwrap();
462        let err = load_from_file(&path).expect_err("oversized file must fail");
463        assert!(
464            matches!(err, AcknowledgmentLoadError::TooLarge { .. }),
465            "expected TooLarge, got: {err:?}"
466        );
467    }
468
469    #[test]
470    fn apply_to_report_clears_prior_acked_entries() {
471        // Simulate a Report fed back from a previous --show-acknowledged
472        // run: it carries one stale ack pair. Applying a fresh empty
473        // ack file must drop the stale pair, the gate is re-evaluated,
474        // and findings are unchanged.
475        let stale_finding = make_finding(FindingType::SlowSql, Severity::Warning);
476        let stale_ack = Acknowledgment {
477            signature: "stale".to_string(),
478            acknowledged_by: "stale@example.com".to_string(),
479            acknowledged_at: "2020-01-01".to_string(),
480            reason: "from a previous run".to_string(),
481            expires_at: None,
482        };
483        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
484        enrich_with_signatures(&mut findings);
485        let mut report = empty_report(findings);
486        report.acknowledged_findings.push(AcknowledgedFinding {
487            finding: stale_finding,
488            acknowledgment: stale_ack,
489        });
490        let acks = AcknowledgmentsFile::default();
491        let config = Config::default();
492        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
493        assert!(
494            report.acknowledged_findings.is_empty(),
495            "stale ack pair must be cleared on entry"
496        );
497        assert_eq!(report.findings.len(), 1, "active findings preserved");
498    }
499
500    #[test]
501    fn load_from_file_nonexistent_returns_empty() {
502        let path = std::path::PathBuf::from("/tmp/perf-sentinel-acks-does-not-exist.toml");
503        let result = load_from_file(&path).expect("missing file should be Ok");
504        assert!(result.acknowledged.is_empty());
505    }
506
507    #[test]
508    fn load_from_file_valid_parses() {
509        let dir = tempfile::tempdir().unwrap();
510        let path = dir.path().join("acks.toml");
511        std::fs::write(
512            &path,
513            r#"
514[[acknowledged]]
515signature = "n_plus_one_sql:svc:GET_/a:abcd1234abcd1234abcd1234abcd1234"
516acknowledged_by = "alice@example.com"
517acknowledged_at = "2026-04-15"
518reason = "documented"
519
520[[acknowledged]]
521signature = "redundant_sql:svc:POST_/b:11223344112233441122334411223344"
522acknowledged_by = "bob@example.com"
523acknowledged_at = "2026-04-20"
524reason = "won't fix"
525expires_at = "2026-12-31"
526"#,
527        )
528        .unwrap();
529        let parsed = load_from_file(&path).expect("valid TOML parses");
530        assert_eq!(parsed.acknowledged.len(), 2);
531        assert_eq!(parsed.acknowledged[0].acknowledged_by, "alice@example.com");
532        assert_eq!(
533            parsed.acknowledged[1].expires_at.as_deref(),
534            Some("2026-12-31")
535        );
536    }
537
538    #[test]
539    fn load_from_file_missing_signature_field_fails() {
540        let dir = tempfile::tempdir().unwrap();
541        let path = dir.path().join("acks.toml");
542        std::fs::write(
543            &path,
544            r#"
545[[acknowledged]]
546acknowledged_by = "alice@example.com"
547acknowledged_at = "2026-04-15"
548reason = "missing signature"
549"#,
550        )
551        .unwrap();
552        let err = load_from_file(&path).expect_err("missing field must fail");
553        assert_matches!(err, AcknowledgmentLoadError::Parse(_));
554    }
555
556    #[test]
557    fn load_from_file_invalid_expires_at_fails() {
558        let dir = tempfile::tempdir().unwrap();
559        let path = dir.path().join("acks.toml");
560        std::fs::write(
561            &path,
562            r#"
563[[acknowledged]]
564signature = "redundant_sql:svc:POST_/b:11223344112233441122334411223344"
565acknowledged_by = "alice@example.com"
566acknowledged_at = "2026-04-15"
567reason = "bad date"
568expires_at = "not-a-date"
569"#,
570        )
571        .unwrap();
572        let err = load_from_file(&path).expect_err("invalid date must fail");
573        assert_matches!(
574            err,
575            AcknowledgmentLoadError::InvalidDate {
576                field: "expires_at",
577                ..
578            }
579        );
580    }
581
582    #[test]
583    fn apply_to_report_filters_matching() {
584        let mut findings = vec![
585            make_finding(FindingType::NPlusOneSql, Severity::Warning),
586            make_finding(FindingType::RedundantSql, Severity::Warning),
587            make_finding(FindingType::SlowSql, Severity::Warning),
588        ];
589        // Distinguish the templates so signatures differ.
590        findings[0].pattern.template = "T1".to_string();
591        findings[1].pattern.template = "T2".to_string();
592        findings[2].pattern.template = "T3".to_string();
593        enrich_with_signatures(&mut findings);
594        let target_sig = findings[1].signature.clone();
595        let mut report = empty_report(findings);
596        let acks = AcknowledgmentsFile {
597            acknowledged: vec![ack(&target_sig, None)],
598        };
599        let config = Config::default();
600        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
601        assert_eq!(report.findings.len(), 2);
602        assert_eq!(report.acknowledged_findings.len(), 1);
603        assert_eq!(
604            report.acknowledged_findings[0].finding.signature,
605            target_sig
606        );
607    }
608
609    #[test]
610    fn apply_to_report_no_match_keeps_all() {
611        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
612        enrich_with_signatures(&mut findings);
613        let mut report = empty_report(findings);
614        let acks = AcknowledgmentsFile {
615            acknowledged: vec![ack(
616                "n_plus_one_sql:nope:nope:00000000000000000000000000000000",
617                None,
618            )],
619        };
620        let config = Config::default();
621        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
622        assert_eq!(report.findings.len(), 1);
623        assert!(report.acknowledged_findings.is_empty());
624    }
625
626    #[test]
627    fn apply_to_report_expired_ack_ignored() {
628        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
629        enrich_with_signatures(&mut findings);
630        let target_sig = findings[0].signature.clone();
631        let mut report = empty_report(findings);
632        let acks = AcknowledgmentsFile {
633            acknowledged: vec![ack(&target_sig, Some("2020-01-01"))],
634        };
635        let config = Config::default();
636        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
637        assert_eq!(report.findings.len(), 1);
638        assert!(report.acknowledged_findings.is_empty());
639    }
640
641    #[test]
642    fn apply_to_report_future_ack_applied() {
643        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
644        enrich_with_signatures(&mut findings);
645        let target_sig = findings[0].signature.clone();
646        let mut report = empty_report(findings);
647        let acks = AcknowledgmentsFile {
648            acknowledged: vec![ack(&target_sig, Some("2030-01-01"))],
649        };
650        let config = Config::default();
651        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
652        assert!(report.findings.is_empty());
653        assert_eq!(report.acknowledged_findings.len(), 1);
654    }
655
656    #[test]
657    fn apply_to_report_no_expires_at_permanent() {
658        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
659        enrich_with_signatures(&mut findings);
660        let target_sig = findings[0].signature.clone();
661        let mut report = empty_report(findings);
662        let acks = AcknowledgmentsFile {
663            acknowledged: vec![ack(&target_sig, None)],
664        };
665        let config = Config::default();
666        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
667        assert_eq!(report.acknowledged_findings.len(), 1);
668    }
669
670    #[test]
671    fn apply_to_report_reevaluates_quality_gate() {
672        // 1 critical N+1 SQL finding, default config has
673        // n_plus_one_sql_critical_max = 0, so the gate fails before the
674        // ack and must pass after.
675        let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Critical)];
676        enrich_with_signatures(&mut findings);
677        let target_sig = findings[0].signature.clone();
678        let config = Config::default();
679        let pre_gate = quality_gate::evaluate(&findings, &GreenSummary::disabled(0), &config);
680        assert!(!pre_gate.passed, "baseline gate must fail before ack");
681
682        let mut report = empty_report(findings);
683        report.quality_gate = pre_gate;
684        let acks = AcknowledgmentsFile {
685            acknowledged: vec![ack(&target_sig, None)],
686        };
687        apply_to_report(&mut report, &acks, &config, now_2026_05_02());
688        assert!(
689            report.quality_gate.passed,
690            "gate must flip green after the offending finding is acked"
691        );
692    }
693
694    #[test]
695    fn enrich_with_signatures_overwrites() {
696        let mut findings = vec![
697            make_finding(FindingType::NPlusOneSql, Severity::Warning),
698            make_finding(FindingType::RedundantSql, Severity::Warning),
699        ];
700        // Simulate stale signatures (e.g. computed under an older scheme).
701        findings[0].signature = "stale".to_string();
702        findings[1].signature = "also-stale".to_string();
703        enrich_with_signatures(&mut findings);
704        assert_ne!(findings[0].signature, "stale");
705        assert_ne!(findings[1].signature, "also-stale");
706        assert!(!findings[0].signature.is_empty());
707        assert!(!findings[1].signature.is_empty());
708    }
709}