Skip to main content

provable_contracts/lint/
sarif.rs

1//! SARIF v2.1.0 output for `pv lint`.
2//!
3//! Converts `LintReport` findings into OASIS SARIF format for
4//! GitHub Code Scanning, VS Code SARIF Viewer, and CI toolchains.
5//!
6//! Spec: `docs/specifications/sub/lint.md` Section 2
7//! Reference: OASIS SARIF v2.1.0 (2020), arXiv:2403.05986
8
9use serde::Serialize;
10
11use super::finding::LintFinding;
12use super::rules::{RuleSeverity, RULES};
13
14const SARIF_SCHEMA: &str =
15    "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
16const SARIF_VERSION: &str = "2.1.0";
17const TOOL_NAME: &str = "pv-lint";
18const TOOL_URI: &str = "https://github.com/paiml/provable-contracts";
19
20/// Top-level SARIF log.
21#[derive(Debug, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct SarifLog {
24    #[serde(rename = "$schema")]
25    pub schema: String,
26    pub version: String,
27    pub runs: Vec<SarifRun>,
28}
29
30#[derive(Debug, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SarifRun {
33    pub tool: SarifTool,
34    pub results: Vec<SarifResult>,
35}
36
37#[derive(Debug, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct SarifTool {
40    pub driver: SarifDriver,
41}
42
43#[derive(Debug, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct SarifDriver {
46    pub name: String,
47    pub version: String,
48    pub information_uri: String,
49    pub rules: Vec<SarifRuleDescriptor>,
50}
51
52#[derive(Debug, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct SarifRuleDescriptor {
55    pub id: String,
56    pub short_description: SarifMessage,
57    pub default_configuration: SarifConfiguration,
58}
59
60#[derive(Debug, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct SarifConfiguration {
63    pub level: String,
64}
65
66#[derive(Debug, Serialize)]
67pub struct SarifMessage {
68    pub text: String,
69}
70
71#[derive(Debug, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct SarifResult {
74    pub rule_id: String,
75    pub level: String,
76    pub message: SarifMessage,
77    pub locations: Vec<SarifLocation>,
78    #[serde(skip_serializing_if = "Vec::is_empty")]
79    pub suppressions: Vec<SarifSuppression>,
80}
81
82#[derive(Debug, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct SarifLocation {
85    pub physical_location: SarifPhysicalLocation,
86}
87
88#[derive(Debug, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct SarifPhysicalLocation {
91    pub artifact_location: SarifArtifactLocation,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub region: Option<SarifRegion>,
94}
95
96#[derive(Debug, Serialize)]
97#[serde(rename_all = "camelCase")]
98pub struct SarifArtifactLocation {
99    pub uri: String,
100}
101
102#[derive(Debug, Serialize)]
103#[serde(rename_all = "camelCase")]
104pub struct SarifRegion {
105    pub start_line: u32,
106    pub start_column: u32,
107}
108
109#[derive(Debug, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct SarifSuppression {
112    pub kind: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub justification: Option<String>,
115}
116
117/// Build rule descriptors from the static rule catalog.
118fn build_rule_descriptors() -> Vec<SarifRuleDescriptor> {
119    RULES
120        .iter()
121        .map(|r| SarifRuleDescriptor {
122            id: r.id.to_string(),
123            short_description: SarifMessage {
124                text: r.description.to_string(),
125            },
126            default_configuration: SarifConfiguration {
127                level: r.default_severity.sarif_level().to_string(),
128            },
129        })
130        .collect()
131}
132
133/// Convert a list of lint findings to a SARIF log.
134pub fn findings_to_sarif(findings: &[LintFinding], tool_version: &str) -> SarifLog {
135    let results: Vec<SarifResult> = findings
136        .iter()
137        .filter(|f| f.severity != RuleSeverity::Off)
138        .map(|f| {
139            let suppressions = if f.suppressed {
140                vec![SarifSuppression {
141                    kind: "inSource".to_string(),
142                    justification: f.suppression_reason.clone(),
143                }]
144            } else {
145                vec![]
146            };
147            SarifResult {
148                rule_id: f.rule_id.clone(),
149                level: f.severity.sarif_level().to_string(),
150                message: SarifMessage {
151                    text: f.message.clone(),
152                },
153                locations: vec![SarifLocation {
154                    physical_location: SarifPhysicalLocation {
155                        artifact_location: SarifArtifactLocation {
156                            uri: f.file.clone(),
157                        },
158                        region: Some(SarifRegion {
159                            start_line: f.line.unwrap_or(1),
160                            start_column: 1,
161                        }),
162                    },
163                }],
164                suppressions,
165            }
166        })
167        .collect();
168
169    SarifLog {
170        schema: SARIF_SCHEMA.to_string(),
171        version: SARIF_VERSION.to_string(),
172        runs: vec![SarifRun {
173            tool: SarifTool {
174                driver: SarifDriver {
175                    name: TOOL_NAME.to_string(),
176                    version: tool_version.to_string(),
177                    information_uri: TOOL_URI.to_string(),
178                    rules: build_rule_descriptors(),
179                },
180            },
181            results,
182        }],
183    }
184}
185
186/// Serialize SARIF log to JSON string.
187pub fn sarif_to_json(log: &SarifLog, pretty: bool) -> String {
188    if pretty {
189        serde_json::to_string_pretty(log).unwrap_or_default()
190    } else {
191        serde_json::to_string(log).unwrap_or_default()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::lint::finding::LintFinding;
199    use crate::lint::rules::RuleSeverity;
200
201    fn sample_finding() -> LintFinding {
202        LintFinding {
203            rule_id: "PV-VAL-001".into(),
204            severity: RuleSeverity::Error,
205            message: "Missing proof_obligations".into(),
206            file: "contracts/example-v1.yaml".into(),
207            line: Some(1),
208            contract_stem: Some("example-v1".into()),
209            suppressed: false,
210            suppression_reason: None,
211            is_new: false,
212            snippet: None,
213            suggestion: None,
214            evidence: None,
215        }
216    }
217
218    #[test]
219    fn sarif_log_has_schema() {
220        let log = findings_to_sarif(&[sample_finding()], "0.1.0");
221        assert!(log.schema.contains("sarif-schema-2.1.0"));
222        assert_eq!(log.version, "2.1.0");
223    }
224
225    #[test]
226    fn sarif_log_has_tool_info() {
227        let log = findings_to_sarif(&[], "0.2.0");
228        assert_eq!(log.runs.len(), 1);
229        assert_eq!(log.runs[0].tool.driver.name, "pv-lint");
230        assert_eq!(log.runs[0].tool.driver.version, "0.2.0");
231    }
232
233    #[test]
234    fn sarif_log_has_rules() {
235        let log = findings_to_sarif(&[], "0.1.0");
236        assert!(!log.runs[0].tool.driver.rules.is_empty());
237        let rule_ids: Vec<&str> = log.runs[0]
238            .tool
239            .driver
240            .rules
241            .iter()
242            .map(|r| r.id.as_str())
243            .collect();
244        assert!(rule_ids.contains(&"PV-VAL-001"));
245        assert!(rule_ids.contains(&"PV-PRV-001"));
246    }
247
248    #[test]
249    fn sarif_result_maps_finding() {
250        let log = findings_to_sarif(&[sample_finding()], "0.1.0");
251        assert_eq!(log.runs[0].results.len(), 1);
252        let result = &log.runs[0].results[0];
253        assert_eq!(result.rule_id, "PV-VAL-001");
254        assert_eq!(result.level, "error");
255        assert!(result.message.text.contains("Missing proof_obligations"));
256        assert_eq!(
257            result.locations[0].physical_location.artifact_location.uri,
258            "contracts/example-v1.yaml"
259        );
260    }
261
262    #[test]
263    fn sarif_suppressed_finding() {
264        let mut f = sample_finding();
265        f.suppressed = true;
266        f.suppression_reason = Some("Known gap".into());
267        let log = findings_to_sarif(&[f], "0.1.0");
268        let result = &log.runs[0].results[0];
269        assert_eq!(result.suppressions.len(), 1);
270        assert_eq!(result.suppressions[0].kind, "inSource");
271        assert_eq!(
272            result.suppressions[0].justification.as_deref(),
273            Some("Known gap")
274        );
275    }
276
277    #[test]
278    fn sarif_off_severity_filtered() {
279        let mut f = sample_finding();
280        f.severity = RuleSeverity::Off;
281        let log = findings_to_sarif(&[f], "0.1.0");
282        assert!(log.runs[0].results.is_empty());
283    }
284
285    #[test]
286    fn sarif_to_json_pretty() {
287        let log = findings_to_sarif(&[sample_finding()], "0.1.0");
288        let json = sarif_to_json(&log, true);
289        assert!(json.contains('\n'));
290        assert!(json.contains("$schema"));
291    }
292
293    #[test]
294    fn sarif_to_json_compact() {
295        let log = findings_to_sarif(&[sample_finding()], "0.1.0");
296        let json = sarif_to_json(&log, false);
297        assert!(!json.contains('\n'));
298        assert!(json.contains("$schema"));
299    }
300
301    #[test]
302    fn sarif_valid_json() {
303        let log = findings_to_sarif(&[sample_finding()], "0.1.0");
304        let json = sarif_to_json(&log, true);
305        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
306        assert_eq!(parsed["version"], "2.1.0");
307        assert!(parsed["runs"].is_array());
308    }
309
310    #[test]
311    fn sarif_multiple_findings() {
312        let mut f2 = sample_finding();
313        f2.rule_id = "PV-AUD-001".into();
314        f2.severity = RuleSeverity::Warning;
315        f2.message = "Obligation without test".into();
316        let log = findings_to_sarif(&[sample_finding(), f2], "0.1.0");
317        assert_eq!(log.runs[0].results.len(), 2);
318    }
319}