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 {
23        ms: u64,
24    },
25    SchemaViolation,
26    PropertyFailure {
27        invariant: String,
28    },
29    ProtocolError,
30    StateLeak,
31    /// Phase L — a multi-step [`crate::property::dsl::Sequence`]
32    /// failed at step `step_index`. Carries enough metadata to point a
33    /// human at which step of which sequence broke.
34    SequenceFailure {
35        /// Sequence name as declared in YAML.
36        sequence: String,
37        /// Zero-based index of the offending step within the sequence.
38        step_index: usize,
39        /// Tool the offending step called.
40        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    /// Human-readable trail of composition choices made when generating this
59    /// payload (e.g. `oneOf[2/3]`, `allOf merged 2 schemas`). Empty / absent
60    /// when the schema had no composition keywords. Not part of the finding
61    /// id: a deterministic seed reproduces the same trail.
62    #[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    /// Stable keyword used as a config key in `[severity]` overrides.
105    /// Lower-case snake_case form matching the kind tag.
106    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    /// Returns a copy of this finding with `severity` replaced. Used by
121    /// the run plans to layer `[severity]` config overrides on top of
122    /// the per-kind defaults.
123    #[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    // Serializing a `serde_json::Value` cannot fail: the type only holds
139    // representable JSON. A failure here is unreachable; falling back to the
140    // empty object keeps `finding_id` stable rather than panicking.
141    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}