1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4use sha2::{Digest, Sha256};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Finding {
8 pub id: String,
9 pub kind: FindingKind,
10 pub severity: Severity,
11 pub tool: String,
12 pub message: String,
13 pub details: String,
14 pub repro: ReproInfo,
15 pub timestamp: DateTime<Utc>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum FindingKind {
21 Crash,
22 Hang {
23 ms: u64,
24 },
25 SchemaViolation,
26 PropertyFailure {
27 invariant: String,
28 },
29 ProtocolError,
30 StateLeak,
31 SequenceFailure {
35 sequence: String,
37 step_index: usize,
39 step_call: String,
41 },
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Severity {
47 Low,
48 Medium,
49 High,
50 Critical,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ReproInfo {
55 pub seed: u64,
56 pub tool_call: Value,
57 pub transport: String,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub composition_trail: Vec<String>,
64}
65
66impl Finding {
67 pub fn new(
68 kind: FindingKind,
69 tool: impl Into<String>,
70 message: impl Into<String>,
71 details: impl Into<String>,
72 repro: ReproInfo,
73 ) -> Self {
74 let tool = tool.into();
75 let severity = kind.default_severity();
76 let id = finding_id(&tool, &kind, &repro.tool_call);
77
78 Self {
79 id,
80 kind,
81 severity,
82 tool,
83 message: message.into(),
84 details: details.into(),
85 repro,
86 timestamp: Utc::now(),
87 }
88 }
89}
90
91impl FindingKind {
92 pub fn default_severity(&self) -> Severity {
93 match self {
94 FindingKind::Crash => Severity::Critical,
95 FindingKind::Hang { .. } => Severity::High,
96 FindingKind::ProtocolError => Severity::High,
97 FindingKind::SchemaViolation => Severity::Medium,
98 FindingKind::PropertyFailure { .. } => Severity::Medium,
99 FindingKind::StateLeak => Severity::High,
100 FindingKind::SequenceFailure { .. } => Severity::High,
101 }
102 }
103
104 pub fn keyword(&self) -> &'static str {
107 match self {
108 FindingKind::Crash => "crash",
109 FindingKind::Hang { .. } => "hang",
110 FindingKind::ProtocolError => "protocol_error",
111 FindingKind::SchemaViolation => "schema_violation",
112 FindingKind::PropertyFailure { .. } => "property_failure",
113 FindingKind::StateLeak => "state_leak",
114 FindingKind::SequenceFailure { .. } => "sequence_failure",
115 }
116 }
117}
118
119impl Finding {
120 #[must_use]
124 pub fn with_severity(mut self, severity: Severity) -> Self {
125 self.severity = severity;
126 self
127 }
128}
129
130pub fn finding_id(tool: &str, kind: &FindingKind, tool_call: &Value) -> String {
131 let canonical = canonical_json(tool_call);
132 let material = format!("{tool}{kind:?}{canonical}");
133 let hash = Sha256::digest(material.as_bytes());
134 hex::encode(hash)[..16].to_string()
135}
136
137pub fn canonical_json(value: &Value) -> String {
138 serde_json::to_string(&canonicalize(value)).unwrap_or_else(|_| "{}".to_string())
142}
143
144fn canonicalize(value: &Value) -> Value {
145 match value {
146 Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
147 Value::Object(map) => {
148 let mut entries = map.iter().collect::<Vec<_>>();
149 entries.sort_by_key(|(left, _)| *left);
150
151 let mut ordered = Map::new();
152 for (key, value) in entries {
153 ordered.insert(key.clone(), canonicalize(value));
154 }
155 Value::Object(ordered)
156 }
157 other => other.clone(),
158 }
159}