1use std::collections::BTreeSet;
24use std::io::{self, Write};
25
26use serde::Serialize;
27
28use crate::metric_catalog::lookup;
29#[cfg(test)]
30use crate::output::offenders::Severity;
31use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
32
33const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
36const SARIF_VERSION: &str = "2.1.0";
37
38fn is_windows_drive_abs(bytes: &[u8]) -> bool {
41 bytes.len() >= 2
42 && bytes[0].is_ascii_alphabetic()
43 && bytes[1] == b':'
44 && (bytes.len() == 2 || bytes[2] == b'/' || bytes[2] == b'\\')
45}
46
47fn relative_first_segment_has_colon(bytes: &[u8]) -> bool {
52 !is_windows_drive_abs(bytes)
53 && bytes.first() != Some(&b'/')
54 && bytes.first() != Some(&b'\\')
55 && bytes
56 .iter()
57 .take_while(|&&b| b != b'/' && b != b'\\')
58 .any(|&b| b == b':')
59}
60
61fn path_to_uri_reference(path: &str) -> String {
83 let bytes = path.as_bytes();
84 let is_windows_drive_abs = is_windows_drive_abs(bytes);
85 let first_segment_has_colon = relative_first_segment_has_colon(bytes);
86
87 let mut out = String::with_capacity(
88 path.len()
89 + if is_windows_drive_abs { 8 } else { 0 }
90 + usize::from(first_segment_has_colon) * 2,
91 );
92 if is_windows_drive_abs {
93 out.push_str("file:///");
94 } else if first_segment_has_colon {
95 out.push_str("./");
96 }
97 for &b in bytes {
98 match b {
99 b'\\' => out.push('/'),
100 b'A'..=b'Z'
104 | b'a'..=b'z'
105 | b'0'..=b'9'
106 | b'-'
107 | b'.'
108 | b'_'
109 | b'~'
110 | b'/'
111 | b':'
112 | b'@' => out.push(b as char),
113 _ => {
114 let hi = b >> 4;
115 let lo = b & 0xF;
116 out.push('%');
117 out.push(hex_digit(hi));
118 out.push(hex_digit(lo));
119 }
120 }
121 }
122 out
123}
124
125fn hex_digit(nibble: u8) -> char {
126 match nibble {
127 0..=9 => (b'0' + nibble) as char,
128 10..=15 => (b'A' + nibble - 10) as char,
129 _ => '0',
130 }
131}
132
133pub fn write_sarif<W: Write>(offenders: &[OffenderRecord], writer: W) -> io::Result<()> {
147 write_sarif_with_suppressed(offenders, &[], &[], writer)
148}
149
150pub fn write_sarif_with_suppressed<W: Write>(
169 active: &[OffenderRecord],
170 in_source: &[OffenderRecord],
171 baseline: &[OffenderRecord],
172 mut writer: W,
173) -> io::Result<()> {
174 let mut results: Vec<SarifResult<'_>> =
175 Vec::with_capacity(active.len() + in_source.len() + baseline.len());
176 let mut rule_ids: BTreeSet<&str> = BTreeSet::new();
178
179 for (offenders, origin) in [
180 (active, None),
181 (in_source, Some(SuppressionOrigin::InSource)),
182 (baseline, Some(SuppressionOrigin::Baseline)),
183 ] {
184 collect_results(offenders, origin, &mut results, &mut rule_ids);
185 }
186
187 let rules: Vec<Rule<'_>> = rule_ids
188 .iter()
189 .map(|id| Rule {
190 id,
191 short_description: Description {
192 text: lookup(id).map_or(*id, |info| info.long_description),
193 },
194 })
195 .collect();
196
197 let log = SarifLog {
198 schema: SARIF_SCHEMA,
199 version: SARIF_VERSION,
200 runs: vec![Run {
201 tool: Tool {
202 driver: Driver {
203 name: TOOL_ID,
204 version: env!("CARGO_PKG_VERSION"),
205 rules,
206 },
207 },
208 results,
209 }],
210 };
211
212 serde_json::to_writer_pretty(&mut writer, &log)
213 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
214 writer.write_all(b"\n")
218}
219
220fn collect_results<'a>(
224 offenders: &'a [OffenderRecord],
225 origin: Option<SuppressionOrigin>,
226 results: &mut Vec<SarifResult<'a>>,
227 rule_ids: &mut BTreeSet<&'a str>,
228) {
229 for record in offenders {
230 let Some(path_str) = warn_non_utf8_path("SARIF", &record.path) else {
231 continue;
232 };
233 rule_ids.insert(record.metric.as_str());
234
235 let logical_locations = record.function.as_deref().map(|name| {
236 vec![LogicalLocation {
237 fully_qualified_name: name,
238 }]
239 });
240
241 results.push(SarifResult {
242 rule_id: &record.metric,
243 level: record.severity.as_str(),
244 message: Message {
245 text: record.default_message(),
246 },
247 locations: vec![Location {
248 physical_location: PhysicalLocation {
249 artifact_location: ArtifactLocation {
250 uri: path_to_uri_reference(path_str),
251 },
252 region: Region {
253 start_line: record.start_line.max(1),
254 end_line: Some(record.end_line.max(record.start_line.max(1))),
255 start_column: record.start_col.map(|c| c.max(1)),
261 },
262 },
263 logical_locations,
264 }],
265 suppressions: origin.map(|o| {
266 vec![Suppression {
267 kind: o.sarif_kind(),
268 justification: o.justification(&record.metric),
269 }]
270 }),
271 });
272 }
273}
274
275#[derive(Debug, Clone, Copy)]
279enum SuppressionOrigin {
280 InSource,
282 Baseline,
284}
285
286impl SuppressionOrigin {
287 fn sarif_kind(self) -> &'static str {
290 match self {
291 Self::InSource => "inSource",
292 Self::Baseline => "external",
293 }
294 }
295
296 fn justification(self, metric: &str) -> String {
297 match self {
298 Self::InSource => {
299 format!("metric '{metric}' silenced by an in-source bca suppression marker")
300 }
301 Self::Baseline => format!("metric '{metric}' within the recorded bca baseline"),
302 }
303 }
304}
305
306#[derive(Serialize)]
307struct SarifLog<'a> {
308 #[serde(rename = "$schema")]
309 schema: &'a str,
310 version: &'a str,
311 runs: Vec<Run<'a>>,
312}
313
314#[derive(Serialize)]
315struct Run<'a> {
316 tool: Tool<'a>,
317 results: Vec<SarifResult<'a>>,
318}
319
320#[derive(Serialize)]
321struct Tool<'a> {
322 driver: Driver<'a>,
323}
324
325#[derive(Serialize)]
326struct Driver<'a> {
327 name: &'a str,
328 version: &'a str,
329 rules: Vec<Rule<'a>>,
330}
331
332#[derive(Serialize)]
333struct Rule<'a> {
334 id: &'a str,
335 #[serde(rename = "shortDescription")]
336 short_description: Description<'a>,
337}
338
339#[derive(Serialize)]
340struct Description<'a> {
341 text: &'a str,
342}
343
344#[derive(Serialize)]
345#[serde(rename_all = "camelCase")]
346struct SarifResult<'a> {
347 rule_id: &'a str,
348 level: &'static str,
349 message: Message,
350 locations: Vec<Location<'a>>,
351 #[serde(skip_serializing_if = "Option::is_none")]
355 suppressions: Option<Vec<Suppression>>,
356}
357
358#[derive(Serialize)]
362struct Suppression {
363 kind: &'static str,
364 justification: String,
365}
366
367#[derive(Serialize)]
368struct Message {
369 text: String,
370}
371
372#[derive(Serialize)]
373#[serde(rename_all = "camelCase")]
374struct Location<'a> {
375 physical_location: PhysicalLocation,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 logical_locations: Option<Vec<LogicalLocation<'a>>>,
378}
379
380#[derive(Serialize)]
381#[serde(rename_all = "camelCase")]
382struct PhysicalLocation {
383 artifact_location: ArtifactLocation,
384 region: Region,
385}
386
387#[derive(Serialize)]
388struct ArtifactLocation {
389 uri: String,
390}
391
392#[derive(Serialize)]
393#[serde(rename_all = "camelCase")]
394struct Region {
395 start_line: u32,
396 #[serde(skip_serializing_if = "Option::is_none")]
397 end_line: Option<u32>,
398 #[serde(skip_serializing_if = "Option::is_none")]
399 start_column: Option<u32>,
400}
401
402#[derive(Serialize)]
403#[serde(rename_all = "camelCase")]
404struct LogicalLocation<'a> {
405 fully_qualified_name: &'a str,
406}
407
408#[cfg(test)]
409#[allow(
410 clippy::float_cmp,
411 clippy::cast_precision_loss,
412 clippy::cast_possible_truncation,
413 clippy::cast_sign_loss,
414 clippy::similar_names,
415 clippy::doc_markdown,
416 clippy::needless_raw_string_hashes,
417 clippy::too_many_lines
418)]
419mod tests {
420 use super::*;
421 use std::path::PathBuf;
422
423 fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
424 OffenderRecord {
425 path: PathBuf::from(path),
426 function: Some("f".into()),
427 start_line: 42,
428 end_line: 50,
429 start_col: Some(5),
430 metric: metric.into(),
431 value,
432 limit,
433 severity: Severity::Warning,
434 }
435 }
436
437 fn render(offenders: &[OffenderRecord]) -> String {
438 let mut buf = Vec::new();
439 write_sarif(offenders, &mut buf).expect("writing to Vec is infallible");
440 String::from_utf8(buf).expect("output is UTF-8")
441 }
442
443 #[test]
444 fn empty_emits_minimal_valid_run() {
445 let out = render(&[]);
446 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
448 assert_eq!(v["version"], "2.1.0");
449 assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "big-code-analysis");
450 assert!(
451 v["runs"][0]["results"]
452 .as_array()
453 .expect("array")
454 .is_empty()
455 );
456 assert!(
457 v["runs"][0]["tool"]["driver"]["rules"]
458 .as_array()
459 .expect("array")
460 .is_empty()
461 );
462 }
463
464 #[test]
465 fn single_offender_includes_rule_and_result() {
466 let offenders = vec![rec("src/foo.rs", "cyclomatic", 17.0, 15.0)];
467 let out = render(&offenders);
468 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
469 let result = &v["runs"][0]["results"][0];
470 assert_eq!(result["ruleId"], "cyclomatic");
471 assert_eq!(result["level"], "warning");
472 assert_eq!(result["message"]["text"], "cyclomatic 17 exceeds limit 15");
473 let loc = &result["locations"][0];
474 assert_eq!(
475 loc["physicalLocation"]["artifactLocation"]["uri"],
476 "src/foo.rs"
477 );
478 assert_eq!(loc["physicalLocation"]["region"]["startLine"], 42);
479 assert_eq!(loc["physicalLocation"]["region"]["endLine"], 50);
480 assert_eq!(loc["physicalLocation"]["region"]["startColumn"], 5);
481 assert_eq!(loc["logicalLocations"][0]["fullyQualifiedName"], "f");
482
483 let rule = &v["runs"][0]["tool"]["driver"]["rules"][0];
484 assert_eq!(rule["id"], "cyclomatic");
485 assert!(rule["shortDescription"]["text"].is_string());
486 }
487
488 #[test]
489 fn write_sarif_omits_suppressions_for_active_results() {
490 let out = render(&[rec("src/foo.rs", "cyclomatic", 17.0, 15.0)]);
494 assert!(
495 !out.contains("suppressions"),
496 "active result must not carry a suppressions array:\n{out}"
497 );
498 }
499
500 #[test]
501 fn suppressed_offenders_carry_suppressions_with_kind() {
502 let active = vec![rec("src/active.rs", "cyclomatic", 17.0, 15.0)];
503 let in_source = vec![rec("src/marked.rs", "halstead.effort", 9e4, 5e4)];
504 let baseline = vec![rec("src/legacy.rs", "cognitive", 26.0, 25.0)];
505
506 let mut buf = Vec::new();
507 write_sarif_with_suppressed(&active, &in_source, &baseline, &mut buf)
508 .expect("writing to Vec is infallible");
509 let v: serde_json::Value =
510 serde_json::from_str(&String::from_utf8(buf).expect("utf8")).expect("valid JSON");
511 let results = v["runs"][0]["results"].as_array().expect("array");
512 assert_eq!(results.len(), 3);
513
514 let active_r = results
516 .iter()
517 .find(|r| {
518 r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/active.rs"
519 })
520 .expect("active result present");
521 assert!(active_r.get("suppressions").is_none());
522
523 let marked = results
525 .iter()
526 .find(|r| {
527 r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/marked.rs"
528 })
529 .expect("in-source result present");
530 assert_eq!(marked["suppressions"][0]["kind"], "inSource");
531 assert!(
532 marked["suppressions"][0]["justification"]
533 .as_str()
534 .expect("justification string")
535 .contains("in-source")
536 );
537
538 let legacy = results
540 .iter()
541 .find(|r| {
542 r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/legacy.rs"
543 })
544 .expect("baseline result present");
545 assert_eq!(legacy["suppressions"][0]["kind"], "external");
546 }
547
548 #[test]
549 fn error_severity_maps_to_error_level() {
550 let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
551 r.severity = Severity::Error;
552 let out = render(&[r]);
553 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
554 assert_eq!(v["runs"][0]["results"][0]["level"], "error");
555 }
556
557 #[test]
558 fn missing_column_omits_field() {
559 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
560 r.start_col = None;
561 let out = render(&[r]);
562 assert!(!out.contains("startColumn"), "{out}");
563 }
564
565 #[test]
566 fn missing_function_omits_logical_locations() {
567 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
568 r.function = None;
569 let out = render(&[r]);
570 assert!(!out.contains("logicalLocations"), "{out}");
571 }
572
573 #[test]
574 fn start_column_zero_is_clamped_to_one() {
575 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
579 r.start_col = Some(0);
580 let out = render(&[r]);
581 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
582 let region = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
583 assert_eq!(
584 region["startColumn"], 1,
585 "startColumn must clamp 0 -> 1, got: {region}"
586 );
587 }
588
589 #[test]
590 fn rules_deduplicate_per_metric() {
591 let offenders = vec![
592 rec("a.rs", "cyclomatic", 17.0, 15.0),
593 rec("b.rs", "cyclomatic", 20.0, 15.0),
594 rec("a.rs", "loc.lloc", 250.0, 100.0),
595 ];
596 let out = render(&offenders);
597 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
598 let rules = v["runs"][0]["tool"]["driver"]["rules"]
599 .as_array()
600 .expect("array");
601 assert_eq!(rules.len(), 2);
602 assert_eq!(rules[0]["id"], "cyclomatic");
604 assert_eq!(rules[1]["id"], "loc.lloc");
605 }
606
607 #[test]
608 fn unknown_metric_falls_back_to_metric_name_as_description() {
609 let r = rec("a.rs", "made.up.metric", 1.0, 0.0);
610 let out = render(&[r]);
611 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
612 assert_eq!(
613 v["runs"][0]["tool"]["driver"]["rules"][0]["shortDescription"]["text"],
614 "made.up.metric"
615 );
616 }
617
618 #[test]
619 fn start_line_zero_is_clamped_to_one() {
620 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
621 r.start_line = 0;
622 r.end_line = 0;
623 let out = render(&[r]);
624 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
625 assert_eq!(
626 v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"],
627 1
628 );
629 }
630
631 #[test]
632 fn driver_version_matches_pkg_version() {
633 let out = render(&[]);
634 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
635 assert_eq!(
636 v["runs"][0]["tool"]["driver"]["version"],
637 env!("CARGO_PKG_VERSION")
638 );
639 }
640
641 #[test]
642 fn windows_drive_path_becomes_file_uri() {
643 assert_eq!(
646 path_to_uri_reference(r"C:\Users\RUNNER~1\AppData\Local\Temp\fixture.rs"),
647 "file:///C:/Users/RUNNER~1/AppData/Local/Temp/fixture.rs"
648 );
649 }
650
651 #[test]
652 fn posix_relative_path_is_unchanged() {
653 assert_eq!(path_to_uri_reference("src/foo.rs"), "src/foo.rs");
654 }
655
656 #[test]
657 fn posix_absolute_path_keeps_leading_slash() {
658 assert_eq!(path_to_uri_reference("/tmp/foo.rs"), "/tmp/foo.rs");
659 }
660
661 #[test]
662 fn space_is_percent_encoded() {
663 assert_eq!(path_to_uri_reference("src/my file.rs"), "src/my%20file.rs");
664 }
665
666 #[test]
667 fn relative_path_with_colon_in_first_segment_is_not_scheme_ambiguous() {
668 assert_eq!(path_to_uri_reference("a:b/c.rs"), "./a:b/c.rs");
671 assert_eq!(path_to_uri_reference("foo:bar/baz.rs"), "./foo:bar/baz.rs");
672 }
673
674 #[test]
675 fn relative_path_with_colon_after_first_slash_is_unchanged() {
676 assert_eq!(path_to_uri_reference("a/b:c.rs"), "a/b:c.rs");
680 }
681
682 #[test]
683 fn normal_relative_path_keeps_no_dot_slash_prefix() {
684 assert_eq!(path_to_uri_reference("a/b/c.rs"), "a/b/c.rs");
686 }
687
688 #[test]
689 fn empty_snapshot_is_stable() {
690 insta::assert_snapshot!("sarif_empty", render(&[]));
691 }
692
693 #[test]
694 fn multi_offender_snapshot_is_stable() {
695 let mut err = rec("src/zeta.rs", "cognitive", 30.0, 15.0);
696 err.severity = Severity::Error;
697 err.start_col = None;
698 err.function = None;
699 let offenders = vec![
700 rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
701 rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
702 err,
703 ];
704 insta::assert_snapshot!("sarif_multi", render(&offenders));
705 }
706}