use serde::{Deserialize, Serialize};
use crate::finding::{Finding, FindingBody, Provenance};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoMessage {
pub reason: String,
pub message: Diagnostic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
pub message: String,
pub level: String,
pub code: Option<DiagnosticCode>,
pub spans: Vec<DiagnosticSpan>,
pub children: Vec<Self>,
pub rendered: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticCode {
pub code: String,
pub explanation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticSpan {
pub file_name: String,
pub line_start: usize,
pub line_end: usize,
pub column_start: usize,
pub column_end: usize,
pub is_primary: bool,
}
#[must_use]
pub fn finding_to_cargo_message(finding: &Finding) -> Option<CargoMessage> {
let class = match &finding.body {
FindingBody::FingerprintMatch { class, .. } => class.clone(),
FindingBody::DialVerdict { .. } | FindingBody::MarkedUnknown { .. } => return None,
};
let span = DiagnosticSpan {
file_name: finding.file.clone(),
line_start: finding.line,
line_end: finding.line,
column_start: 1,
column_end: 1,
is_primary: true,
};
let provenance = provenance_label(finding.class_provenance);
let message = format!(
"antigen: structure matches the `{class}` failure-class fingerprint \
(provenance: {provenance}). This is a fingerprint match to inspect, not an \
audited verdict."
);
let note = Diagnostic {
message: format!(
"fingerprint match only — antigen has not audited a defense for this \
site. Mark it with #[presents({class})] + #[defended_by(...)] to \
record the defense, or #[antigen_tolerance({class}, rationale=...)] to \
accept it."
),
level: "note".to_string(),
code: None,
spans: Vec::new(),
children: Vec::new(),
rendered: None,
};
let rendered = format!("warning: {message}");
let diagnostic = Diagnostic {
message,
level: "warning".to_string(),
code: Some(DiagnosticCode {
code: format!("antigen::{class}"),
explanation: None,
}),
spans: vec![span],
children: vec![note],
rendered: Some(rendered),
};
Some(CargoMessage {
reason: "compiler-message".to_string(),
message: diagnostic,
})
}
pub fn findings_to_cargo_jsonl(findings: &[Finding]) -> Result<String, serde_json::Error> {
let mut out = String::new();
for finding in findings {
if let Some(msg) = finding_to_cargo_message(finding) {
out.push_str(&serde_json::to_string(&msg)?);
out.push('\n');
}
}
Ok(out)
}
const fn provenance_label(p: Provenance) -> &'static str {
match p {
Provenance::Encountered => "encountered (seen in real code)",
Provenance::Constructable => "constructable (a verified minimal case exists)",
Provenance::Heuristic => "heuristic (correlational)",
Provenance::Imagined => "imagined (reasoned, no demo yet)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{DialTier, OriginStage, Presentation, Severity, cluster_key_of};
fn match_finding(class: &str, file: &str, line: usize) -> Finding {
let digest = format!("d-{class}");
Finding {
schema_version: crate::finding::FINDING_SCHEMA_VERSION,
file: file.to_string(),
line,
structural_digest: digest.clone(),
shape_digest: String::new(),
cluster_key: cluster_key_of(&digest, class),
severity: Severity::High,
source: "scan:catalog-match".to_string(),
class_provenance: Provenance::Constructable,
presentation: Presentation::Passive,
timestamp: 0,
origin_stage: OriginStage::Scan,
body: FindingBody::FingerprintMatch {
class: class.to_string(),
tier: DialTier::Suspected,
},
}
}
#[test]
fn emits_a_compiler_message_with_a_primary_span() {
let msg = finding_to_cargo_message(&match_finding("panic-in-drop", "src/a.rs", 12))
.expect("a fingerprint match yields a message");
assert_eq!(msg.reason, "compiler-message");
assert_eq!(msg.message.spans.len(), 1);
let span = &msg.message.spans[0];
assert_eq!(span.file_name, "src/a.rs");
assert_eq!(span.line_start, 12);
assert!(span.is_primary);
}
#[test]
fn antigen_never_emits_error_level_only_warning() {
let msg = finding_to_cargo_message(&match_finding("c", "a.rs", 1)).unwrap();
assert_eq!(msg.message.level, "warning");
assert_ne!(msg.message.level, "error");
}
#[test]
fn code_is_the_antigen_class() {
let msg = finding_to_cargo_message(&match_finding("unbounded-deser", "a.rs", 1)).unwrap();
let code = msg.message.code.expect("a code");
assert_eq!(code.code, "antigen::unbounded-deser");
}
#[test]
fn message_names_it_a_fingerprint_match_not_an_audited_verdict() {
let msg = finding_to_cargo_message(&match_finding("c", "a.rs", 1)).unwrap();
let text = &msg.message.message;
assert!(text.contains("fingerprint match"));
assert!(text.contains("not an audited verdict"));
}
#[test]
fn non_fingerprint_match_findings_are_skipped() {
let mut f = match_finding("c", "a.rs", 1);
f.body = FindingBody::DialVerdict {
class: "c".to_string(),
tier: DialTier::Named,
};
assert!(finding_to_cargo_message(&f).is_none());
}
#[test]
fn jsonl_is_newline_delimited_one_line_per_match() {
let findings = vec![match_finding("a", "x.rs", 1), match_finding("b", "y.rs", 2)];
let jsonl = findings_to_cargo_jsonl(&findings).expect("serializes");
let lines: Vec<&str> = jsonl.lines().collect();
assert_eq!(lines.len(), 2);
for line in lines {
let _: CargoMessage = serde_json::from_str(line).expect("each line is valid JSON");
}
}
#[test]
fn round_trips_through_the_rustc_schema() {
let msg = finding_to_cargo_message(&match_finding("c", "a.rs", 5)).unwrap();
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"reason\":\"compiler-message\""));
assert!(json.contains("\"is_primary\":true"));
assert!(json.contains("\"level\":\"warning\""));
let back: CargoMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back, msg);
}
}