1use 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
30pub const MAX_ACKNOWLEDGMENTS_FILE_BYTES: u64 = 16 * 1024 * 1024;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Acknowledgment {
39 pub signature: String,
41 pub acknowledged_by: String,
43 pub acknowledged_at: String,
45 pub reason: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub expires_at: Option<String>,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct AcknowledgmentsFile {
60 #[serde(default)]
61 pub acknowledged: Vec<Acknowledgment>,
62}
63
64#[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 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
112pub fn enrich_with_signatures(findings: &mut [Finding]) {
119 for finding in findings.iter_mut() {
120 finding.signature = compute_signature(finding);
121 }
122}
123
124pub fn load_from_file(path: &Path) -> Result<AcknowledgmentsFile, AcknowledgmentLoadError> {
144 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 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
192pub fn apply_to_report(
208 report: &mut Report,
209 acks: &AcknowledgmentsFile,
210 config: &Config,
211 now: DateTime<Utc>,
212) {
213 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 return false;
258 };
259 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#[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 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 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 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 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 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 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 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}