use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Read;
use std::path::Path;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::config::Config;
use crate::detect::Finding;
use crate::quality_gate;
use crate::report::{AcknowledgedFinding, Report};
pub const MAX_ACKNOWLEDGMENTS_FILE_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Acknowledgment {
pub signature: String,
pub acknowledged_by: String,
pub acknowledged_at: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AcknowledgmentsFile {
#[serde(default)]
pub acknowledged: Vec<Acknowledgment>,
}
#[must_use]
pub fn compute_signature(finding: &Finding) -> String {
let mut hasher = Sha256::new();
hasher.update(finding.pattern.template.as_bytes());
let digest = hasher.finalize();
let safe_service = crate::report::sarif::strip_bidi_and_invisible(&finding.service);
let sanitized_endpoint = sanitize_endpoint(&finding.source_endpoint);
let safe_endpoint = crate::report::sarif::strip_bidi_and_invisible(&sanitized_endpoint);
let kind = finding.finding_type.as_str();
let mut out = String::with_capacity(kind.len() + safe_service.len() + safe_endpoint.len() + 35);
out.push_str(kind);
out.push(':');
out.push_str(safe_service.as_ref());
out.push(':');
out.push_str(safe_endpoint.as_ref());
out.push(':');
for byte in &digest[..16] {
let _ = write!(out, "{byte:02x}");
}
out
}
fn sanitize_endpoint(endpoint: &str) -> Cow<'_, str> {
if endpoint.bytes().any(|b| matches!(b, b'/' | b' ')) {
Cow::Owned(endpoint.replace(['/', ' '], "_"))
} else {
Cow::Borrowed(endpoint)
}
}
pub fn enrich_with_signatures(findings: &mut [Finding]) {
for finding in findings.iter_mut() {
finding.signature = compute_signature(finding);
}
}
pub fn load_from_file(path: &Path) -> Result<AcknowledgmentsFile, AcknowledgmentLoadError> {
match std::fs::symlink_metadata(path) {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(AcknowledgmentLoadError::SymlinkRefused);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(AcknowledgmentsFile::default());
}
Err(err) => return Err(AcknowledgmentLoadError::Io(err)),
}
let file = std::fs::File::open(path).map_err(AcknowledgmentLoadError::Io)?;
let mut buf = String::new();
file.take(MAX_ACKNOWLEDGMENTS_FILE_BYTES + 1)
.read_to_string(&mut buf)
.map_err(AcknowledgmentLoadError::Io)?;
if buf.len() as u64 > MAX_ACKNOWLEDGMENTS_FILE_BYTES {
return Err(AcknowledgmentLoadError::TooLarge {
cap: MAX_ACKNOWLEDGMENTS_FILE_BYTES,
});
}
let parsed: AcknowledgmentsFile =
toml::from_str(&buf).map_err(AcknowledgmentLoadError::Parse)?;
for (idx, ack) in parsed.acknowledged.iter().enumerate() {
if let Some(ref expires) = ack.expires_at {
NaiveDate::parse_from_str(expires, "%Y-%m-%d").map_err(|e| {
AcknowledgmentLoadError::InvalidDate {
entry_index: idx,
field: "expires_at",
value: expires.clone(),
message: e.to_string(),
}
})?;
}
}
Ok(parsed)
}
pub fn apply_to_report(
report: &mut Report,
acks: &AcknowledgmentsFile,
config: &Config,
now: DateTime<Utc>,
) {
report.acknowledged_findings.clear();
let active: HashMap<&str, &Acknowledgment> = acks
.acknowledged
.iter()
.filter(|a| is_ack_active(a, now))
.map(|a| (a.signature.as_str(), a))
.collect();
if !active.is_empty() {
let original = std::mem::take(&mut report.findings);
let mut kept = Vec::with_capacity(original.len());
for finding in original {
let sig: Cow<'_, str> = if finding.signature.is_empty() {
Cow::Owned(compute_signature(&finding))
} else {
Cow::Borrowed(finding.signature.as_str())
};
if let Some(ack) = active.get(sig.as_ref()) {
report.acknowledged_findings.push(AcknowledgedFinding {
finding,
acknowledgment: (*ack).clone(),
});
} else {
kept.push(finding);
}
}
report.findings = kept;
}
report.quality_gate = quality_gate::evaluate(&report.findings, &report.green_summary, config);
}
pub(crate) fn is_ack_active(ack: &Acknowledgment, now: DateTime<Utc>) -> bool {
let Some(ref expires) = ack.expires_at else {
return true;
};
let Ok(parsed) = NaiveDate::parse_from_str(expires, "%Y-%m-%d") else {
return false;
};
let Some(end_of_day) = parsed.and_hms_opt(23, 59, 59) else {
return false;
};
end_of_day.and_utc() >= now
}
#[derive(Debug, thiserror::Error)]
pub enum AcknowledgmentLoadError {
#[error("Failed to read acknowledgments file: {0}")]
Io(#[from] std::io::Error),
#[error("Acknowledgments file exceeds the {cap}-byte cap")]
TooLarge { cap: u64 },
#[error("Failed to parse acknowledgments TOML: {0}")]
Parse(toml::de::Error),
#[error("Entry {entry_index}: invalid {field} value '{value}': {message}")]
InvalidDate {
entry_index: usize,
field: &'static str,
value: String,
message: String,
},
#[error("Acknowledgments file is a symlink, refusing to follow")]
SymlinkRefused,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::{FindingType, Severity};
use crate::report::{Analysis, GreenSummary, QualityGate};
use crate::test_helpers::make_finding;
use chrono::TimeZone;
fn empty_report(findings: Vec<Finding>) -> Report {
Report {
analysis: Analysis {
duration_ms: 0,
events_processed: findings.len(),
traces_analyzed: 1,
},
findings,
green_summary: GreenSummary::disabled(0),
quality_gate: QualityGate {
passed: true,
rules: vec![],
},
per_endpoint_io_ops: vec![],
correlations: vec![],
warnings: vec![],
warning_details: vec![],
acknowledged_findings: vec![],
binary_version: String::new(),
}
}
fn ack(signature: &str, expires_at: Option<&str>) -> Acknowledgment {
Acknowledgment {
signature: signature.to_string(),
acknowledged_by: "test@example.com".to_string(),
acknowledged_at: "2026-05-02".to_string(),
reason: "test".to_string(),
expires_at: expires_at.map(str::to_string),
}
}
fn now_2026_05_02() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap()
}
#[test]
fn compute_signature_deterministic() {
let f = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let sig1 = compute_signature(&f);
let sig2 = compute_signature(&f);
assert_eq!(sig1, sig2);
}
#[test]
fn compute_signature_differs_with_template() {
let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let mut f2 = f1.clone();
f1.pattern.template = "SELECT * FROM users WHERE id = ?".to_string();
f2.pattern.template = "SELECT * FROM orders WHERE id = ?".to_string();
assert_ne!(compute_signature(&f1), compute_signature(&f2));
}
#[test]
fn compute_signature_sanitizes_endpoint() {
let mut f = make_finding(FindingType::NPlusOneSql, Severity::Warning);
f.source_endpoint = "GET /api/foo bar".to_string();
let sig = compute_signature(&f);
let parts: Vec<&str> = sig.split(':').collect();
assert_eq!(
parts.len(),
4,
"signature must have 4 colon-separated parts: {sig}"
);
assert!(
!parts[2].contains('/'),
"endpoint segment must not contain '/'"
);
assert!(
!parts[2].contains(' '),
"endpoint segment must not contain ' '"
);
}
#[test]
fn compute_signature_strips_bidi_and_invisible_from_service_and_endpoint() {
let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let mut f2 = f1.clone();
f1.service = "alice\u{202E}@evil.com".to_string();
f1.source_endpoint = "GET /api/items\u{200B}".to_string();
f2.service = "alice@evil.com".to_string();
f2.source_endpoint = "GET /api/items".to_string();
assert_eq!(
compute_signature(&f1),
compute_signature(&f2),
"BiDi/invisible characters must be stripped before signature construction"
);
}
#[test]
fn compute_signature_format_matches_brief() {
let mut f = make_finding(FindingType::RedundantSql, Severity::Warning);
f.service = "order-service".to_string();
f.source_endpoint = "POST /api/orders".to_string();
f.pattern.template = "SELECT 1".to_string();
let sig = compute_signature(&f);
let mut parts = sig.splitn(4, ':');
assert_eq!(parts.next(), Some("redundant_sql"));
assert_eq!(parts.next(), Some("order-service"));
assert_eq!(parts.next(), Some("POST__api_orders"));
let hex = parts.next().expect("hex prefix present");
assert_eq!(hex.len(), 32, "hex prefix is 32 characters (16 bytes)");
assert!(
hex.chars().all(|c| c.is_ascii_hexdigit()),
"hex prefix is hex"
);
}
#[test]
fn signature_stable_across_trace_id_changes() {
let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let mut f2 = f1.clone();
f1.trace_id = "aaaaaaaaaaaaaaaa0000000000000000".to_string();
f2.trace_id = "ffffffffffffffff1111111111111111".to_string();
assert_ne!(f1.trace_id, f2.trace_id);
assert_eq!(
compute_signature(&f1),
compute_signature(&f2),
"signature must not depend on trace_id (acks survive service restarts)"
);
}
#[test]
fn compute_signature_differs_with_endpoint() {
let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let mut f2 = f1.clone();
f1.source_endpoint = "POST /api/orders".to_string();
f2.source_endpoint = "POST /api/users".to_string();
assert_ne!(compute_signature(&f1), compute_signature(&f2));
}
#[test]
fn compute_signature_differs_with_service() {
let mut f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let mut f2 = f1.clone();
f1.service = "order-svc".to_string();
f2.service = "user-svc".to_string();
assert_ne!(compute_signature(&f1), compute_signature(&f2));
}
#[test]
fn compute_signature_differs_with_finding_type() {
let f1 = make_finding(FindingType::NPlusOneSql, Severity::Warning);
let f2 = make_finding(FindingType::RedundantSql, Severity::Warning);
assert_ne!(compute_signature(&f1), compute_signature(&f2));
}
#[test]
fn load_from_file_rejects_oversized_input() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("acks.toml");
let payload = vec![b'x'; (MAX_ACKNOWLEDGMENTS_FILE_BYTES + 1) as usize];
std::fs::write(&path, &payload).unwrap();
let err = load_from_file(&path).expect_err("oversized file must fail");
assert!(
matches!(err, AcknowledgmentLoadError::TooLarge { .. }),
"expected TooLarge, got: {err:?}"
);
}
#[test]
fn apply_to_report_clears_prior_acked_entries() {
let stale_finding = make_finding(FindingType::SlowSql, Severity::Warning);
let stale_ack = Acknowledgment {
signature: "stale".to_string(),
acknowledged_by: "stale@example.com".to_string(),
acknowledged_at: "2020-01-01".to_string(),
reason: "from a previous run".to_string(),
expires_at: None,
};
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
enrich_with_signatures(&mut findings);
let mut report = empty_report(findings);
report.acknowledged_findings.push(AcknowledgedFinding {
finding: stale_finding,
acknowledgment: stale_ack,
});
let acks = AcknowledgmentsFile::default();
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert!(
report.acknowledged_findings.is_empty(),
"stale ack pair must be cleared on entry"
);
assert_eq!(report.findings.len(), 1, "active findings preserved");
}
#[test]
fn load_from_file_nonexistent_returns_empty() {
let path = std::path::PathBuf::from("/tmp/perf-sentinel-acks-does-not-exist.toml");
let result = load_from_file(&path).expect("missing file should be Ok");
assert!(result.acknowledged.is_empty());
}
#[test]
fn load_from_file_valid_parses() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("acks.toml");
std::fs::write(
&path,
r#"
[[acknowledged]]
signature = "n_plus_one_sql:svc:GET_/a:abcd1234abcd1234abcd1234abcd1234"
acknowledged_by = "alice@example.com"
acknowledged_at = "2026-04-15"
reason = "documented"
[[acknowledged]]
signature = "redundant_sql:svc:POST_/b:11223344112233441122334411223344"
acknowledged_by = "bob@example.com"
acknowledged_at = "2026-04-20"
reason = "won't fix"
expires_at = "2026-12-31"
"#,
)
.unwrap();
let parsed = load_from_file(&path).expect("valid TOML parses");
assert_eq!(parsed.acknowledged.len(), 2);
assert_eq!(parsed.acknowledged[0].acknowledged_by, "alice@example.com");
assert_eq!(
parsed.acknowledged[1].expires_at.as_deref(),
Some("2026-12-31")
);
}
#[test]
fn load_from_file_missing_signature_field_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("acks.toml");
std::fs::write(
&path,
r#"
[[acknowledged]]
acknowledged_by = "alice@example.com"
acknowledged_at = "2026-04-15"
reason = "missing signature"
"#,
)
.unwrap();
let err = load_from_file(&path).expect_err("missing field must fail");
assert!(matches!(err, AcknowledgmentLoadError::Parse(_)));
}
#[test]
fn load_from_file_invalid_expires_at_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("acks.toml");
std::fs::write(
&path,
r#"
[[acknowledged]]
signature = "redundant_sql:svc:POST_/b:11223344112233441122334411223344"
acknowledged_by = "alice@example.com"
acknowledged_at = "2026-04-15"
reason = "bad date"
expires_at = "not-a-date"
"#,
)
.unwrap();
let err = load_from_file(&path).expect_err("invalid date must fail");
assert!(matches!(
err,
AcknowledgmentLoadError::InvalidDate {
field: "expires_at",
..
}
));
}
#[test]
fn apply_to_report_filters_matching() {
let mut findings = vec![
make_finding(FindingType::NPlusOneSql, Severity::Warning),
make_finding(FindingType::RedundantSql, Severity::Warning),
make_finding(FindingType::SlowSql, Severity::Warning),
];
findings[0].pattern.template = "T1".to_string();
findings[1].pattern.template = "T2".to_string();
findings[2].pattern.template = "T3".to_string();
enrich_with_signatures(&mut findings);
let target_sig = findings[1].signature.clone();
let mut report = empty_report(findings);
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(&target_sig, None)],
};
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert_eq!(report.findings.len(), 2);
assert_eq!(report.acknowledged_findings.len(), 1);
assert_eq!(
report.acknowledged_findings[0].finding.signature,
target_sig
);
}
#[test]
fn apply_to_report_no_match_keeps_all() {
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
enrich_with_signatures(&mut findings);
let mut report = empty_report(findings);
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(
"n_plus_one_sql:nope:nope:00000000000000000000000000000000",
None,
)],
};
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert_eq!(report.findings.len(), 1);
assert!(report.acknowledged_findings.is_empty());
}
#[test]
fn apply_to_report_expired_ack_ignored() {
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
enrich_with_signatures(&mut findings);
let target_sig = findings[0].signature.clone();
let mut report = empty_report(findings);
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(&target_sig, Some("2020-01-01"))],
};
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert_eq!(report.findings.len(), 1);
assert!(report.acknowledged_findings.is_empty());
}
#[test]
fn apply_to_report_future_ack_applied() {
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
enrich_with_signatures(&mut findings);
let target_sig = findings[0].signature.clone();
let mut report = empty_report(findings);
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(&target_sig, Some("2030-01-01"))],
};
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert!(report.findings.is_empty());
assert_eq!(report.acknowledged_findings.len(), 1);
}
#[test]
fn apply_to_report_no_expires_at_permanent() {
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Warning)];
enrich_with_signatures(&mut findings);
let target_sig = findings[0].signature.clone();
let mut report = empty_report(findings);
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(&target_sig, None)],
};
let config = Config::default();
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert_eq!(report.acknowledged_findings.len(), 1);
}
#[test]
fn apply_to_report_reevaluates_quality_gate() {
let mut findings = vec![make_finding(FindingType::NPlusOneSql, Severity::Critical)];
enrich_with_signatures(&mut findings);
let target_sig = findings[0].signature.clone();
let config = Config::default();
let pre_gate = quality_gate::evaluate(&findings, &GreenSummary::disabled(0), &config);
assert!(!pre_gate.passed, "baseline gate must fail before ack");
let mut report = empty_report(findings);
report.quality_gate = pre_gate;
let acks = AcknowledgmentsFile {
acknowledged: vec![ack(&target_sig, None)],
};
apply_to_report(&mut report, &acks, &config, now_2026_05_02());
assert!(
report.quality_gate.passed,
"gate must flip green after the offending finding is acked"
);
}
#[test]
fn enrich_with_signatures_overwrites() {
let mut findings = vec![
make_finding(FindingType::NPlusOneSql, Severity::Warning),
make_finding(FindingType::RedundantSql, Severity::Warning),
];
findings[0].signature = "stale".to_string();
findings[1].signature = "also-stale".to_string();
enrich_with_signatures(&mut findings);
assert_ne!(findings[0].signature, "stale");
assert_ne!(findings[1].signature, "also-stale");
assert!(!findings[0].signature.is_empty());
assert!(!findings[1].signature.is_empty());
}
}