1use std::path::{Path, PathBuf};
2
3use fallow_types::identity::IdentifiedFinding;
4use rustc_hash::FxHashMap;
5use serde_json::Value;
6
7use crate::codeclimate::codeclimate_fingerprint_hash;
8
9pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
11
12pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
14
15pub const SARIF_FINDING_ID_KEY: &str = "fallowFinding/v1";
20
21#[derive(Debug, Clone, Copy)]
23pub struct SarifResultInput<'a> {
24 pub rule_id: &'a str,
26 pub level: &'a str,
28 pub message: &'a str,
30 pub uri: &'a str,
32 pub region: Option<(u32, u32)>,
34 pub snippet: Option<&'a str>,
36}
37
38#[derive(Debug, Clone)]
40pub struct SarifFindingInput<'a> {
41 pub issue_code: &'a str,
43 pub rule_id: &'a str,
45 pub level: &'a str,
47 pub message: &'a str,
49 pub uri: &'a str,
51 pub region: Option<(u32, u32)>,
53 pub snippet: Option<&'a str>,
55 pub properties: Option<Value>,
57 pub finding_id: Option<&'a str>,
60}
61
62#[derive(Debug, Clone)]
64pub struct SarifFindingFields {
65 pub rule_id: &'static str,
67 pub level: &'static str,
69 pub message: String,
71 pub uri: String,
73 pub region: Option<(u32, u32)>,
75 pub source_path: Option<PathBuf>,
77 pub properties: Option<Value>,
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct SarifRuleInput<'a> {
84 pub id: &'a str,
86 pub short_description: &'a str,
88 pub level: &'a str,
90 pub full_description: Option<&'a str>,
92 pub help_uri: Option<&'a str>,
94}
95
96#[derive(Debug, Clone, Copy)]
98pub struct SarifDocumentInput<'a> {
99 pub results: &'a [Value],
101 pub rules: &'a [Value],
103 pub tool_version: &'a str,
105}
106
107#[must_use]
109pub fn normalize_sarif_snippet(snippet: &str) -> String {
110 snippet
111 .lines()
112 .map(str::trim)
113 .filter(|line| !line.is_empty())
114 .collect::<Vec<_>>()
115 .join("\n")
116}
117
118#[must_use]
123pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str, col: u32) -> String {
124 let normalized = normalize_sarif_snippet(snippet);
125 codeclimate_fingerprint_hash(&[rule_id, path, &normalized, &col.to_string()])
126}
127
128#[derive(Debug, Default)]
130pub struct SarifSourceSnippetCache {
131 root: Option<PathBuf>,
132 files: FxHashMap<PathBuf, Vec<String>>,
133}
134
135impl SarifSourceSnippetCache {
136 #[must_use]
139 pub fn with_root(root: &Path) -> Self {
140 Self {
141 root: Some(root.to_path_buf()),
142 files: FxHashMap::default(),
143 }
144 }
145
146 pub fn line(&mut self, path: &Path, line: u32) -> Option<String> {
148 if line == 0 {
149 return None;
150 }
151 let resolved = if path.is_relative() {
152 self.root
153 .as_deref()
154 .map_or_else(|| path.to_path_buf(), |root| root.join(path))
155 } else {
156 path.to_path_buf()
157 };
158 if !self.files.contains_key(&resolved) {
159 let lines = std::fs::read_to_string(&resolved)
160 .ok()
161 .map(|source| source.lines().map(str::to_owned).collect())
162 .unwrap_or_default();
163 self.files.insert(resolved.clone(), lines);
164 }
165 self.files
166 .get(&resolved)
167 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
168 .cloned()
169 }
170}
171
172#[must_use]
177pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
178 let mut physical_location = serde_json::json!({
179 "artifactLocation": { "uri": input.uri }
180 });
181 if let Some((line, col)) = input.region {
182 physical_location["region"] = serde_json::json!({
183 "startLine": line,
184 "startColumn": col
185 });
186 }
187 let line = input
188 .region
189 .map_or_else(String::new, |(line, _)| line.to_string());
190 let col = input
191 .region
192 .map_or_else(String::new, |(_, col)| col.to_string());
193 let normalized_snippet = input
194 .snippet
195 .map(normalize_sarif_snippet)
196 .filter(|snippet| !snippet.is_empty());
197 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
205 || codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
206 |snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet, &col]),
207 );
208 let partial_fingerprint_ghas = partial_fingerprint.clone();
209 let locations = if input.uri.is_empty() {
210 Vec::new()
211 } else {
212 vec![serde_json::json!({ "physicalLocation": physical_location })]
213 };
214 serde_json::json!({
215 "ruleId": input.rule_id,
216 "level": input.level,
217 "message": { "text": input.message },
218 "locations": locations,
219 "partialFingerprints": {
220 SARIF_FINGERPRINT_KEY: partial_fingerprint,
221 GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
222 }
223 })
224}
225
226#[must_use]
228pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
229 let mut result = build_sarif_result(SarifResultInput {
230 rule_id: input.rule_id,
231 level: input.level,
232 message: input.message,
233 uri: input.uri,
234 region: input.region,
235 snippet: input.snippet,
236 });
237 if let Some(finding_id) = input.finding_id {
238 result["partialFingerprints"][SARIF_FINDING_ID_KEY] = Value::from(finding_id);
239 }
240 if let Some(properties) = input.properties {
241 result["properties"] = properties;
242 }
243 result
244}
245
246#[must_use]
248pub fn build_sarif_result_with_snippet(
249 rule_id: &str,
250 level: &str,
251 message: &str,
252 uri: &str,
253 region: Option<(u32, u32)>,
254 snippet: Option<&str>,
255) -> Value {
256 build_sarif_result(SarifResultInput {
257 rule_id,
258 level,
259 message,
260 uri,
261 region,
262 snippet,
263 })
264}
265
266pub fn append_sarif_findings<T: IdentifiedFinding>(
271 sarif_results: &mut Vec<Value>,
272 items: &[T],
273 snippets: &mut SarifSourceSnippetCache,
274 mut extract: impl FnMut(&T) -> SarifFindingFields,
275) {
276 for item in items {
277 let fields = extract(item);
278 let source_snippet = fields
279 .source_path
280 .as_deref()
281 .zip(fields.region)
282 .and_then(|(path, (line, _))| snippets.line(path, line));
283 let result = build_sarif_finding(SarifFindingInput {
284 issue_code: issue_code_from_rule_id(fields.rule_id),
285 rule_id: fields.rule_id,
286 level: fields.level,
287 message: &fields.message,
288 uri: &fields.uri,
289 region: fields.region,
290 snippet: source_snippet.as_deref(),
291 properties: fields.properties,
292 finding_id: item.finding_id(),
293 });
294 sarif_results.push(result);
295 }
296}
297
298pub fn ensure_unique_result_fingerprints(results: &mut [Value]) {
308 let mut occurrences: FxHashMap<String, u32> = FxHashMap::default();
309 for result in results {
310 let Some(fingerprint) = result
311 .get("partialFingerprints")
312 .and_then(|prints| prints.get(SARIF_FINGERPRINT_KEY))
313 .and_then(Value::as_str)
314 .map(str::to_owned)
315 else {
316 continue;
317 };
318 let occurrence = occurrences.entry(fingerprint.clone()).or_insert(0);
319 let index = *occurrence;
320 *occurrence += 1;
321 if index == 0 {
322 continue;
323 }
324 let unique = codeclimate_fingerprint_hash(&[&fingerprint, &index.to_string()]);
325 result["partialFingerprints"][SARIF_FINGERPRINT_KEY] = Value::from(unique.clone());
326 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY] = Value::from(unique);
327 }
328}
329
330#[must_use]
332pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
333 let mut rule = serde_json::Map::new();
334 rule.insert("id".to_string(), serde_json::json!(input.id));
335 rule.insert(
336 "shortDescription".to_string(),
337 serde_json::json!({ "text": input.short_description }),
338 );
339 if let Some(full_description) = input.full_description {
340 rule.insert(
341 "fullDescription".to_string(),
342 serde_json::json!({ "text": full_description }),
343 );
344 }
345 if let Some(help_uri) = input.help_uri {
346 rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
347 }
348 rule.insert(
349 "defaultConfiguration".to_string(),
350 serde_json::json!({ "level": input.level }),
351 );
352 Value::Object(rule)
353}
354
355fn issue_code_from_rule_id(rule_id: &str) -> &str {
356 rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
357}
358
359#[must_use]
365pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
366 let mut results = input.results.to_vec();
367 ensure_unique_result_fingerprints(&mut results);
368 serde_json::json!({
369 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
370 "version": "2.1.0",
371 "runs": [{
372 "tool": {
373 "driver": {
374 "name": "fallow",
375 "version": input.tool_version,
376 "informationUri": "https://github.com/fallow-rs/fallow",
377 "rules": input.rules
378 }
379 },
380 "results": results
381 }]
382 })
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn sarif_result_includes_location_and_fingerprints() {
391 let result = build_sarif_result(SarifResultInput {
392 rule_id: "fallow/test",
393 level: "warning",
394 message: "description",
395 uri: "src/app.ts",
396 region: Some((7, 3)),
397 snippet: Some(" export const value = 1; "),
398 });
399
400 assert_eq!(result["ruleId"], "fallow/test");
401 assert_eq!(
402 result["locations"][0]["physicalLocation"]["region"]["startLine"],
403 7
404 );
405 assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
406 assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
407 }
408
409 fn fingerprint_of(result: &Value) -> &str {
410 result["partialFingerprints"][SARIF_FINGERPRINT_KEY]
411 .as_str()
412 .expect("fingerprint")
413 }
414
415 #[test]
421 fn two_findings_on_one_line_get_different_fingerprints() {
422 let at_column = |col: u32| {
423 build_sarif_result(SarifResultInput {
424 rule_id: "fallow/unused-export",
425 level: "warning",
426 message: "Re-export is never imported by other modules",
427 uri: "src/barrel.ts",
428 region: Some((1, col)),
429 snippet: Some("export { alpha, beta } from './m';"),
430 })
431 };
432
433 assert_ne!(
434 fingerprint_of(&at_column(10)),
435 fingerprint_of(&at_column(17))
436 );
437 }
438
439 #[test]
443 fn moving_a_finding_to_another_line_keeps_its_fingerprint() {
444 let at_line = |line: u32| {
445 build_sarif_result(SarifResultInput {
446 rule_id: "fallow/unused-export",
447 level: "warning",
448 message: "Export is never imported by other modules",
449 uri: "src/lib.ts",
450 region: Some((line, 14)),
451 snippet: Some("export const alpha = 1;"),
452 })
453 };
454
455 assert_eq!(fingerprint_of(&at_line(3)), fingerprint_of(&at_line(41)));
456 }
457
458 #[test]
461 fn identical_results_are_separated_by_occurrence() {
462 let result = || {
463 build_sarif_result(SarifResultInput {
464 rule_id: "fallow/duplicate-export",
465 level: "warning",
466 message: "Export 'Video' appears in multiple modules",
467 uri: "src/types.ts",
468 region: Some((309, 18)),
469 snippet: Some("export type Video = {"),
470 })
471 };
472 let mut results = vec![result(), result(), result()];
473 let first_before = fingerprint_of(&results[0]).to_owned();
474
475 ensure_unique_result_fingerprints(&mut results);
476
477 assert_eq!(
478 fingerprint_of(&results[0]),
479 first_before,
480 "the first occurrence keeps the identity an existing alert was opened under"
481 );
482 let unique: std::collections::BTreeSet<&str> = results.iter().map(fingerprint_of).collect();
483 assert_eq!(unique.len(), 3, "{results:?}");
484 for result in &results {
485 assert_eq!(
486 result["partialFingerprints"][SARIF_FINGERPRINT_KEY],
487 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY],
488 "both keys name the same alert"
489 );
490 }
491 }
492
493 #[test]
496 fn the_uniqueness_pass_keeps_the_finding_id_key() {
497 let result = |finding_id: &str| {
498 build_sarif_finding(SarifFindingInput {
499 issue_code: "unused-class-member",
500 rule_id: "fallow/unused-class-member",
501 level: "warning",
502 message: "Class member 'run' is never used",
503 uri: "src/service.ts",
504 region: Some((4, 3)),
505 snippet: Some("run() {}"),
506 properties: None,
507 finding_id: Some(finding_id),
508 })
509 };
510 let ids = [
511 "dc1:unused-class-member:0123456789abcdef",
512 "dc1:unused-class-member:0123456789abcdef~1",
513 ];
514 let mut results = ids.map(result).to_vec();
515
516 ensure_unique_result_fingerprints(&mut results);
517
518 assert_ne!(
519 fingerprint_of(&results[0]),
520 fingerprint_of(&results[1]),
521 "the pass must still separate the location-based keys"
522 );
523 for (result, id) in results.iter().zip(ids) {
524 assert_eq!(result["partialFingerprints"][SARIF_FINDING_ID_KEY], id);
525 }
526 }
527
528 #[test]
531 fn a_finding_id_adds_one_key_and_keeps_the_rest() {
532 let result = |finding_id: Option<&str>| {
533 build_sarif_finding(SarifFindingInput {
534 issue_code: "unused-export",
535 rule_id: "fallow/unused-export",
536 level: "warning",
537 message: "Export 'helper' is never imported by other modules",
538 uri: "src/utils.ts",
539 region: Some((3, 14)),
540 snippet: Some("export const helper = 1;"),
541 properties: None,
542 finding_id,
543 })
544 };
545 let without = result(None);
546 let mut with = result(Some("dc1:unused-export:0123456789abcdef"));
547
548 assert!(
549 without["partialFingerprints"]
550 .get(SARIF_FINDING_ID_KEY)
551 .is_none()
552 );
553 let removed = with["partialFingerprints"]
554 .as_object_mut()
555 .and_then(|prints| prints.remove(SARIF_FINDING_ID_KEY));
556 assert_eq!(
557 removed,
558 Some(Value::from("dc1:unused-export:0123456789abcdef"))
559 );
560 assert_eq!(with, without);
561 }
562
563 #[test]
566 fn distinct_results_are_left_alone() {
567 let mut results = vec![
568 build_sarif_result(SarifResultInput {
569 rule_id: "fallow/unused-export",
570 level: "warning",
571 message: "Export 'alpha' is never imported by other modules",
572 uri: "src/lib.ts",
573 region: Some((1, 14)),
574 snippet: Some("export const alpha = 1;"),
575 }),
576 build_sarif_result(SarifResultInput {
577 rule_id: "fallow/unused-export",
578 level: "warning",
579 message: "Export 'beta' is never imported by other modules",
580 uri: "src/lib.ts",
581 region: Some((2, 14)),
582 snippet: Some("export const beta = 2;"),
583 }),
584 ];
585 let before = results.clone();
586
587 ensure_unique_result_fingerprints(&mut results);
588
589 assert_eq!(results, before);
590 }
591
592 #[test]
593 fn sarif_finding_includes_custom_properties() {
594 let finding = build_sarif_finding(SarifFindingInput {
595 issue_code: "unused-export",
596 rule_id: "fallow/unused-export",
597 level: "warning",
598 message: "Export is never imported",
599 uri: "src/app.ts",
600 region: Some((3, 14)),
601 snippet: Some("export const unused = 1;"),
602 properties: Some(serde_json::json!({ "is_re_export": true })),
603 finding_id: None,
604 });
605
606 assert_eq!(finding["ruleId"], "fallow/unused-export");
607 assert_eq!(finding["properties"]["is_re_export"], true);
608 assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
609 }
610
611 #[test]
612 fn sarif_finding_omits_empty_properties() {
613 let finding = build_sarif_finding(SarifFindingInput {
614 issue_code: "unused-file",
615 rule_id: "fallow/unused-file",
616 level: "error",
617 message: "File is unreachable",
618 uri: "src/unused.ts",
619 region: None,
620 snippet: None,
621 properties: None,
622 finding_id: None,
623 });
624
625 assert!(finding.get("properties").is_none());
626 }
627
628 #[test]
629 fn append_sarif_findings_attaches_snippet_and_properties() {
630 let temp = tempfile::tempdir().expect("tempdir");
631 let source = temp.path().join("src.ts");
632 std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
633 let mut snippets = SarifSourceSnippetCache::default();
634 let mut results = Vec::new();
635 let mut finding = fallow_types::output_dead_code::UnusedFileFinding::with_actions(
636 fallow_types::results::UnusedFile { path: source },
637 );
638 finding.finding_id = Some("dc1:unused-file:0123456789abcdef".to_owned());
639
640 append_sarif_findings(
641 &mut results,
642 std::slice::from_ref(&finding),
643 &mut snippets,
644 |finding| SarifFindingFields {
645 rule_id: "fallow/unused-export",
646 level: "warning",
647 message: "Export is never imported".to_string(),
648 uri: "src.ts".to_string(),
649 region: Some((2, 1)),
650 source_path: Some(finding.file.path.clone()),
651 properties: Some(serde_json::json!({ "is_re_export": true })),
652 },
653 );
654
655 assert_eq!(results.len(), 1);
656 assert_eq!(results[0]["ruleId"], "fallow/unused-export");
657 assert_eq!(results[0]["properties"]["is_re_export"], true);
658 assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
659 assert_eq!(
660 results[0]["partialFingerprints"][SARIF_FINDING_ID_KEY],
661 "dc1:unused-file:0123456789abcdef"
662 );
663 }
664
665 #[test]
666 fn sarif_rule_omits_optional_docs_when_absent() {
667 let rule = build_sarif_rule(SarifRuleInput {
668 id: "fallow/test",
669 short_description: "short",
670 level: "warning",
671 full_description: None,
672 help_uri: None,
673 });
674
675 assert!(rule.get("fullDescription").is_none());
676 assert!(rule.get("helpUri").is_none());
677 }
678
679 #[test]
680 fn sarif_document_uses_supplied_version() {
681 let document = build_sarif_document(SarifDocumentInput {
682 results: &[],
683 rules: &[],
684 tool_version: "1.2.3",
685 });
686
687 assert_eq!(document["version"], "2.1.0");
688 assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
689 }
690}