Skip to main content

dev_report/
sarif.rs

1//! SARIF 2.1.0 export for [`Report`] and [`MultiReport`].
2//!
3//! Maps fail-verdict and warn-verdict [`CheckResult`]s to SARIF results.
4//! Pass and skip checks are intentionally NOT emitted — SARIF is a defect
5//! report format, not a test-result format. Use [`crate::junit`] (or the
6//! native JSON schema) when you need every check.
7//!
8//! Severity → SARIF level:
9//!
10//! | [`Severity`]         | SARIF `level` |
11//! |----------------------|---------------|
12//! | `Critical`, `Error`  | `error`       |
13//! | `Warning`            | `warning`     |
14//! | `Info`               | `note`        |
15//! | `None` (unreachable for fail/warn) | `none` |
16//!
17//! [`Evidence`] payloads of kind [`EvidenceData::FileRef`] become SARIF
18//! `physicalLocation` entries with `region.startLine` / `region.endLine`
19//! when the [`FileRef`] carries a line range. Line numbers below `1` are
20//! not valid SARIF and are left out.
21//!
22//! SARIF requires `artifactLocation.uri` to be a URI reference, so the
23//! file path is converted: backslashes become `/`, absolute paths become
24//! `file://` URIs (`C:\src\lib.rs` becomes `file:///C:/src/lib.rs`,
25//! `\\server\share\x.rs` becomes `file://server/share/x.rs`, `/home/x.rs`
26//! becomes `file:///home/x.rs`), relative paths stay relative, and
27//! characters that are not allowed in a URI (spaces, `%`, non-ASCII) are
28//! percent-encoded.
29//!
30//! For a [`MultiReport`], each constituent [`Report`] becomes a separate
31//! SARIF `run`, so consumers can tell which producer emitted which finding.
32//!
33//! Available with the `sarif` feature.
34//!
35//! [`Evidence`]: crate::Evidence
36//! [`EvidenceData::FileRef`]: crate::EvidenceData::FileRef
37//! [`FileRef`]: crate::FileRef
38//! [`Severity`]: crate::Severity
39//! [`CheckResult`]: crate::CheckResult
40
41use serde_json::{json, Value};
42
43use crate::{CheckResult, EvidenceData, MultiReport, Report, Severity, Verdict};
44
45const SARIF_VERSION: &str = "2.1.0";
46const SARIF_SCHEMA_URI: &str =
47    "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
48const TOOL_INFO_URI: &str = "https://github.com/jamesgober/dev-report";
49
50/// Render `report` as a SARIF 2.1.0 document.
51///
52/// # Example
53///
54/// ```
55/// use dev_report::{CheckResult, Report, Severity};
56///
57/// let mut r = Report::new("crate", "0.1.0").with_producer("dev-bench");
58/// r.push(CheckResult::fail("oops", Severity::Error).with_detail("explodes"));
59///
60/// let sarif = dev_report::sarif::to_sarif(&r);
61/// assert!(sarif.contains("\"version\": \"2.1.0\""));
62/// assert!(sarif.contains("\"ruleId\": \"oops\""));
63/// ```
64pub fn to_sarif(report: &Report) -> String {
65    let log = json!({
66        "version": SARIF_VERSION,
67        "$schema": SARIF_SCHEMA_URI,
68        "runs": [run_for(report)]
69    });
70    serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
71}
72
73/// Render `multi` as a SARIF 2.1.0 document with one `run` per constituent
74/// [`Report`].
75///
76/// # Example
77///
78/// ```
79/// use dev_report::{CheckResult, MultiReport, Report, Severity};
80///
81/// let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
82/// bench.push(CheckResult::fail("a", Severity::Error));
83/// let mut chaos = Report::new("crate", "0.1.0").with_producer("dev-chaos");
84/// chaos.push(CheckResult::warn("b", Severity::Warning));
85///
86/// let mut multi = MultiReport::new("crate", "0.1.0");
87/// multi.push(bench);
88/// multi.push(chaos);
89///
90/// let sarif = dev_report::sarif::multi_to_sarif(&multi);
91/// assert!(sarif.contains("\"version\": \"2.1.0\""));
92/// ```
93pub fn multi_to_sarif(multi: &MultiReport) -> String {
94    let runs: Vec<Value> = multi.reports.iter().map(run_for).collect();
95    let log = json!({
96        "version": SARIF_VERSION,
97        "$schema": SARIF_SCHEMA_URI,
98        "runs": runs
99    });
100    serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
101}
102
103fn run_for(report: &Report) -> Value {
104    let driver_name = report.producer.as_deref().unwrap_or("dev-report");
105    let results: Vec<Value> = report
106        .checks
107        .iter()
108        .filter(|c| matches!(c.verdict, Verdict::Fail | Verdict::Warn))
109        .map(result_for)
110        .collect();
111    json!({
112        "tool": {
113            "driver": {
114                "name": driver_name,
115                "informationUri": TOOL_INFO_URI
116            }
117        },
118        "results": results
119    })
120}
121
122fn result_for(check: &CheckResult) -> Value {
123    let level = level_for(check.severity);
124    let message_text = check.detail.clone().unwrap_or_else(|| check.name.clone());
125    let mut result = json!({
126        "ruleId": check.name,
127        "level": level,
128        "message": { "text": message_text }
129    });
130    let locations: Vec<Value> = check
131        .evidence
132        .iter()
133        .filter_map(|e| match &e.data {
134            EvidenceData::FileRef(f) => Some(location_for(f)),
135            _ => None,
136        })
137        .collect();
138    if !locations.is_empty() {
139        result["locations"] = Value::Array(locations);
140    }
141    result
142}
143
144fn level_for(severity: Option<Severity>) -> &'static str {
145    match severity {
146        Some(Severity::Critical) | Some(Severity::Error) => "error",
147        Some(Severity::Warning) => "warning",
148        Some(Severity::Info) => "note",
149        None => "none",
150    }
151}
152
153fn location_for(file_ref: &crate::FileRef) -> Value {
154    let mut physical = serde_json::Map::new();
155    physical.insert(
156        "artifactLocation".into(),
157        json!({ "uri": artifact_uri(&file_ref.path) }),
158    );
159    // SARIF line numbers are 1-based, so 0 is dropped. An `endLine` is
160    // only meaningful next to a `startLine` and must not precede it.
161    if let Some(s) = file_ref.line_start.filter(|&n| n >= 1) {
162        let mut region = serde_json::Map::new();
163        region.insert("startLine".into(), Value::Number(s.into()));
164        if let Some(e) = file_ref.line_end.filter(|&e| e >= s) {
165            region.insert("endLine".into(), Value::Number(e.into()));
166        }
167        physical.insert("region".into(), Value::Object(region));
168    }
169    json!({ "physicalLocation": Value::Object(physical) })
170}
171
172/// Convert a filesystem path (Windows or Unix, absolute or relative)
173/// into a URI reference that SARIF consumers accept.
174fn artifact_uri(path: &str) -> String {
175    let normalized = path.replace('\\', "/");
176    let bytes = normalized.as_bytes();
177    let is_drive = bytes.len() >= 2
178        && bytes[0].is_ascii_alphabetic()
179        && bytes[1] == b':'
180        && (bytes.len() == 2 || bytes[2] == b'/');
181
182    let prefix = if normalized.starts_with("//") {
183        // UNC path: //server/share/x -> file://server/share/x
184        "file:"
185    } else if is_drive {
186        "file:///"
187    } else if normalized.starts_with('/') {
188        "file://"
189    } else {
190        ""
191    };
192
193    let mut out = String::with_capacity(prefix.len() + normalized.len());
194    out.push_str(prefix);
195    for (i, b) in normalized.bytes().enumerate() {
196        // The colon after a drive letter is part of the path. Anywhere
197        // else (notably in the first segment of a relative path, where it
198        // would read as a URI scheme separator) it is encoded.
199        let drive_colon = is_drive && i == 1;
200        let keep = drive_colon
201            || b.is_ascii_alphanumeric()
202            || matches!(
203                b,
204                b'-' | b'.'
205                    | b'_'
206                    | b'~'
207                    | b'/'
208                    | b'!'
209                    | b'$'
210                    | b'&'
211                    | b'\''
212                    | b'('
213                    | b')'
214                    | b'*'
215                    | b'+'
216                    | b','
217                    | b';'
218                    | b'='
219                    | b'@'
220            );
221        if keep {
222            out.push(char::from(b));
223        } else {
224            const HEX: &[u8; 16] = b"0123456789ABCDEF";
225            out.push('%');
226            out.push(char::from(HEX[usize::from(b >> 4)]));
227            out.push(char::from(HEX[usize::from(b & 0x0f)]));
228        }
229    }
230    out
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::{Evidence, FileRef};
237
238    #[test]
239    fn skips_pass_and_skip_checks() {
240        let mut r = Report::new("c", "0.1.0").with_producer("p");
241        r.push(CheckResult::pass("ok"));
242        r.push(CheckResult::skip("not_applicable"));
243        r.push(CheckResult::fail("oops", Severity::Error));
244        let sarif = to_sarif(&r);
245        let v: Value = serde_json::from_str(&sarif).unwrap();
246        let results = v["runs"][0]["results"].as_array().unwrap();
247        assert_eq!(results.len(), 1);
248        assert_eq!(results[0]["ruleId"], "oops");
249    }
250
251    #[test]
252    fn severity_maps_to_sarif_level() {
253        let mut r = Report::new("c", "0.1.0").with_producer("p");
254        r.push(CheckResult::fail("a", Severity::Critical));
255        r.push(CheckResult::fail("b", Severity::Error));
256        r.push(CheckResult::warn("c", Severity::Warning));
257        r.push(CheckResult::warn("d", Severity::Info));
258        let sarif = to_sarif(&r);
259        let v: Value = serde_json::from_str(&sarif).unwrap();
260        let results = v["runs"][0]["results"].as_array().unwrap();
261        assert_eq!(results[0]["level"], "error");
262        assert_eq!(results[1]["level"], "error");
263        assert_eq!(results[2]["level"], "warning");
264        assert_eq!(results[3]["level"], "note");
265    }
266
267    #[test]
268    fn file_ref_evidence_becomes_location() {
269        let mut r = Report::new("c", "0.1.0").with_producer("p");
270        r.push(
271            CheckResult::fail("oops", Severity::Error)
272                .with_evidence(Evidence::file_ref_lines("site", "src/lib.rs", 10, 20))
273                .with_evidence(Evidence::numeric("ignored", 1.0)),
274        );
275        let sarif = to_sarif(&r);
276        let v: Value = serde_json::from_str(&sarif).unwrap();
277        let locs = v["runs"][0]["results"][0]["locations"].as_array().unwrap();
278        assert_eq!(locs.len(), 1);
279        let phys = &locs[0]["physicalLocation"];
280        assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
281        assert_eq!(phys["region"]["startLine"], 10);
282        assert_eq!(phys["region"]["endLine"], 20);
283    }
284
285    #[test]
286    fn file_ref_without_line_range_omits_region() {
287        let mut r = Report::new("c", "0.1.0").with_producer("p");
288        r.push(
289            CheckResult::fail("oops", Severity::Error).with_evidence(Evidence {
290                label: "src".into(),
291                data: EvidenceData::FileRef(FileRef::new("src/lib.rs")),
292            }),
293        );
294        let sarif = to_sarif(&r);
295        let v: Value = serde_json::from_str(&sarif).unwrap();
296        let phys = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
297        assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
298        assert!(phys.get("region").is_none());
299    }
300
301    fn uri_of(path: &str) -> String {
302        let mut r = Report::new("c", "0.1.0").with_producer("p");
303        r.push(
304            CheckResult::fail("x", Severity::Error).with_evidence(Evidence::file_ref("f", path)),
305        );
306        let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
307        v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
308            .as_str()
309            .unwrap()
310            .to_string()
311    }
312
313    #[test]
314    fn relative_paths_stay_relative() {
315        assert_eq!(uri_of("src/lib.rs"), "src/lib.rs");
316        assert_eq!(uri_of("./src/lib.rs"), "./src/lib.rs");
317        assert_eq!(uri_of(r"src\parse\mod.rs"), "src/parse/mod.rs");
318    }
319
320    #[test]
321    fn absolute_paths_become_file_uris() {
322        assert_eq!(uri_of("/home/me/x.rs"), "file:///home/me/x.rs");
323        assert_eq!(
324            uri_of(r"C:\Dev\crate\src\lib.rs"),
325            "file:///C:/Dev/crate/src/lib.rs"
326        );
327        assert_eq!(uri_of("d:/a/b.rs"), "file:///d:/a/b.rs");
328        assert_eq!(uri_of(r"\\server\share\x.rs"), "file://server/share/x.rs");
329    }
330
331    #[test]
332    fn uri_unsafe_characters_are_percent_encoded() {
333        assert_eq!(uri_of("my dir/a%b.rs"), "my%20dir/a%25b.rs");
334        assert_eq!(uri_of("src/caf\u{e9}.rs"), "src/caf%C3%A9.rs");
335        assert_eq!(
336            uri_of(r"C:\Program Files\x.rs"),
337            "file:///C:/Program%20Files/x.rs"
338        );
339        // A colon in a relative path would read as a URI scheme.
340        assert_eq!(uri_of("a:b/c.rs"), "a%3Ab/c.rs");
341        assert_eq!(uri_of(r"C:\a:b.rs"), "file:///C:/a%3Ab.rs");
342    }
343
344    #[test]
345    fn invalid_line_numbers_are_dropped_from_region() {
346        let mut r = Report::new("c", "0.1.0").with_producer("p");
347        r.push(
348            CheckResult::fail("a", Severity::Error)
349                .with_evidence(Evidence::file_ref_lines("f", "x.rs", 0, 0)),
350        );
351        r.push(
352            CheckResult::fail("b", Severity::Error)
353                .with_evidence(Evidence::file_ref_lines("f", "x.rs", 9, 3)),
354        );
355        let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
356        let results = v["runs"][0]["results"].as_array().unwrap();
357        assert!(results[0]["locations"][0]["physicalLocation"]
358            .get("region")
359            .is_none());
360        let region = &results[1]["locations"][0]["physicalLocation"]["region"];
361        assert_eq!(region["startLine"], 9);
362        assert!(region.get("endLine").is_none());
363    }
364
365    #[test]
366    fn multi_emits_one_run_per_constituent_report() {
367        let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
368        bench.push(CheckResult::fail("a", Severity::Error));
369        let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
370        chaos.push(CheckResult::warn("b", Severity::Warning));
371        let mut multi = MultiReport::new("c", "0.1.0");
372        multi.push(bench);
373        multi.push(chaos);
374        let sarif = multi_to_sarif(&multi);
375        let v: Value = serde_json::from_str(&sarif).unwrap();
376        let runs = v["runs"].as_array().unwrap();
377        assert_eq!(runs.len(), 2);
378        assert_eq!(runs[0]["tool"]["driver"]["name"], "dev-bench");
379        assert_eq!(runs[1]["tool"]["driver"]["name"], "dev-chaos");
380    }
381
382    #[test]
383    fn output_is_deterministic() {
384        let mut r = Report::new("c", "0.1.0").with_producer("p");
385        r.push(CheckResult::fail("a", Severity::Error).with_detail("bad"));
386        r.push(CheckResult::warn("b", Severity::Warning));
387        let s1 = to_sarif(&r);
388        let s2 = to_sarif(&r);
389        assert_eq!(s1, s2);
390    }
391
392    #[test]
393    fn empty_report_emits_empty_results() {
394        let r = Report::new("c", "0.1.0").with_producer("p");
395        let sarif = to_sarif(&r);
396        let v: Value = serde_json::from_str(&sarif).unwrap();
397        assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
398    }
399
400    #[test]
401    fn detail_becomes_message_text() {
402        let mut r = Report::new("c", "0.1.0").with_producer("p");
403        r.push(CheckResult::fail("a", Severity::Error).with_detail("the exact reason"));
404        let sarif = to_sarif(&r);
405        let v: Value = serde_json::from_str(&sarif).unwrap();
406        assert_eq!(
407            v["runs"][0]["results"][0]["message"]["text"],
408            "the exact reason"
409        );
410    }
411
412    #[test]
413    fn missing_detail_falls_back_to_name() {
414        let mut r = Report::new("c", "0.1.0").with_producer("p");
415        r.push(CheckResult::fail("the_check", Severity::Error));
416        let sarif = to_sarif(&r);
417        let v: Value = serde_json::from_str(&sarif).unwrap();
418        assert_eq!(v["runs"][0]["results"][0]["message"]["text"], "the_check");
419    }
420}