1use 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#[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
117fn 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
133pub 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
186pub 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}