Skip to main content

wallfacer_core/
finding.rs

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 { ms: u64 },
23    SchemaViolation,
24    PropertyFailure { invariant: String },
25    ProtocolError,
26    StateLeak,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Severity {
32    Low,
33    Medium,
34    High,
35    Critical,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ReproInfo {
40    pub seed: u64,
41    pub tool_call: Value,
42    pub transport: String,
43    /// Human-readable trail of composition choices made when generating this
44    /// payload (e.g. `oneOf[2/3]`, `allOf merged 2 schemas`). Empty / absent
45    /// when the schema had no composition keywords. Not part of the finding
46    /// id: a deterministic seed reproduces the same trail.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub composition_trail: Vec<String>,
49}
50
51impl Finding {
52    pub fn new(
53        kind: FindingKind,
54        tool: impl Into<String>,
55        message: impl Into<String>,
56        details: impl Into<String>,
57        repro: ReproInfo,
58    ) -> Self {
59        let tool = tool.into();
60        let severity = kind.default_severity();
61        let id = finding_id(&tool, &kind, &repro.tool_call);
62
63        Self {
64            id,
65            kind,
66            severity,
67            tool,
68            message: message.into(),
69            details: details.into(),
70            repro,
71            timestamp: Utc::now(),
72        }
73    }
74}
75
76impl FindingKind {
77    pub fn default_severity(&self) -> Severity {
78        match self {
79            FindingKind::Crash => Severity::Critical,
80            FindingKind::Hang { .. } => Severity::High,
81            FindingKind::ProtocolError => Severity::High,
82            FindingKind::SchemaViolation => Severity::Medium,
83            FindingKind::PropertyFailure { .. } => Severity::Medium,
84            FindingKind::StateLeak => Severity::High,
85        }
86    }
87
88    /// Stable keyword used as a config key in `[severity]` overrides.
89    /// Lower-case snake_case form matching the kind tag.
90    pub fn keyword(&self) -> &'static str {
91        match self {
92            FindingKind::Crash => "crash",
93            FindingKind::Hang { .. } => "hang",
94            FindingKind::ProtocolError => "protocol_error",
95            FindingKind::SchemaViolation => "schema_violation",
96            FindingKind::PropertyFailure { .. } => "property_failure",
97            FindingKind::StateLeak => "state_leak",
98        }
99    }
100}
101
102impl Finding {
103    /// Returns a copy of this finding with `severity` replaced. Used by
104    /// the run plans to layer `[severity]` config overrides on top of
105    /// the per-kind defaults.
106    #[must_use]
107    pub fn with_severity(mut self, severity: Severity) -> Self {
108        self.severity = severity;
109        self
110    }
111}
112
113pub fn finding_id(tool: &str, kind: &FindingKind, tool_call: &Value) -> String {
114    let canonical = canonical_json(tool_call);
115    let material = format!("{tool}{kind:?}{canonical}");
116    let hash = Sha256::digest(material.as_bytes());
117    hex::encode(hash)[..16].to_string()
118}
119
120pub fn canonical_json(value: &Value) -> String {
121    // Serializing a `serde_json::Value` cannot fail: the type only holds
122    // representable JSON. A failure here is unreachable; falling back to the
123    // empty object keeps `finding_id` stable rather than panicking.
124    serde_json::to_string(&canonicalize(value)).unwrap_or_else(|_| "{}".to_string())
125}
126
127fn canonicalize(value: &Value) -> Value {
128    match value {
129        Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
130        Value::Object(map) => {
131            let mut entries = map.iter().collect::<Vec<_>>();
132            entries.sort_by_key(|(left, _)| *left);
133
134            let mut ordered = Map::new();
135            for (key, value) in entries {
136                ordered.insert(key.clone(), canonicalize(value));
137            }
138            Value::Object(ordered)
139        }
140        other => other.clone(),
141    }
142}