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 serde_json::json!({
210 "ruleId": input.rule_id,
211 "level": input.level,
212 "message": { "text": input.message },
213 "locations": [{ "physicalLocation": physical_location }],
214 "partialFingerprints": {
215 SARIF_FINGERPRINT_KEY: partial_fingerprint,
216 GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
217 }
218 })
219}
220
221#[must_use]
223pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
224 let mut result = build_sarif_result(SarifResultInput {
225 rule_id: input.rule_id,
226 level: input.level,
227 message: input.message,
228 uri: input.uri,
229 region: input.region,
230 snippet: input.snippet,
231 });
232 if let Some(finding_id) = input.finding_id {
233 result["partialFingerprints"][SARIF_FINDING_ID_KEY] = Value::from(finding_id);
234 }
235 if let Some(properties) = input.properties {
236 result["properties"] = properties;
237 }
238 result
239}
240
241#[must_use]
243pub fn build_sarif_result_with_snippet(
244 rule_id: &str,
245 level: &str,
246 message: &str,
247 uri: &str,
248 region: Option<(u32, u32)>,
249 snippet: Option<&str>,
250) -> Value {
251 build_sarif_result(SarifResultInput {
252 rule_id,
253 level,
254 message,
255 uri,
256 region,
257 snippet,
258 })
259}
260
261pub fn append_sarif_findings<T: IdentifiedFinding>(
266 sarif_results: &mut Vec<Value>,
267 items: &[T],
268 snippets: &mut SarifSourceSnippetCache,
269 mut extract: impl FnMut(&T) -> SarifFindingFields,
270) {
271 for item in items {
272 let fields = extract(item);
273 let source_snippet = fields
274 .source_path
275 .as_deref()
276 .zip(fields.region)
277 .and_then(|(path, (line, _))| snippets.line(path, line));
278 let result = build_sarif_finding(SarifFindingInput {
279 issue_code: issue_code_from_rule_id(fields.rule_id),
280 rule_id: fields.rule_id,
281 level: fields.level,
282 message: &fields.message,
283 uri: &fields.uri,
284 region: fields.region,
285 snippet: source_snippet.as_deref(),
286 properties: fields.properties,
287 finding_id: item.finding_id(),
288 });
289 sarif_results.push(result);
290 }
291}
292
293pub fn ensure_unique_result_fingerprints(results: &mut [Value]) {
303 let mut occurrences: FxHashMap<String, u32> = FxHashMap::default();
304 for result in results {
305 let Some(fingerprint) = result
306 .get("partialFingerprints")
307 .and_then(|prints| prints.get(SARIF_FINGERPRINT_KEY))
308 .and_then(Value::as_str)
309 .map(str::to_owned)
310 else {
311 continue;
312 };
313 let occurrence = occurrences.entry(fingerprint.clone()).or_insert(0);
314 let index = *occurrence;
315 *occurrence += 1;
316 if index == 0 {
317 continue;
318 }
319 let unique = codeclimate_fingerprint_hash(&[&fingerprint, &index.to_string()]);
320 result["partialFingerprints"][SARIF_FINGERPRINT_KEY] = Value::from(unique.clone());
321 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY] = Value::from(unique);
322 }
323}
324
325#[must_use]
327pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
328 let mut rule = serde_json::Map::new();
329 rule.insert("id".to_string(), serde_json::json!(input.id));
330 rule.insert(
331 "shortDescription".to_string(),
332 serde_json::json!({ "text": input.short_description }),
333 );
334 if let Some(full_description) = input.full_description {
335 rule.insert(
336 "fullDescription".to_string(),
337 serde_json::json!({ "text": full_description }),
338 );
339 }
340 if let Some(help_uri) = input.help_uri {
341 rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
342 }
343 rule.insert(
344 "defaultConfiguration".to_string(),
345 serde_json::json!({ "level": input.level }),
346 );
347 Value::Object(rule)
348}
349
350fn issue_code_from_rule_id(rule_id: &str) -> &str {
351 rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
352}
353
354#[must_use]
360pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
361 let mut results = input.results.to_vec();
362 ensure_unique_result_fingerprints(&mut results);
363 serde_json::json!({
364 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
365 "version": "2.1.0",
366 "runs": [{
367 "tool": {
368 "driver": {
369 "name": "fallow",
370 "version": input.tool_version,
371 "informationUri": "https://github.com/fallow-rs/fallow",
372 "rules": input.rules
373 }
374 },
375 "results": results
376 }]
377 })
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn sarif_result_includes_location_and_fingerprints() {
386 let result = build_sarif_result(SarifResultInput {
387 rule_id: "fallow/test",
388 level: "warning",
389 message: "description",
390 uri: "src/app.ts",
391 region: Some((7, 3)),
392 snippet: Some(" export const value = 1; "),
393 });
394
395 assert_eq!(result["ruleId"], "fallow/test");
396 assert_eq!(
397 result["locations"][0]["physicalLocation"]["region"]["startLine"],
398 7
399 );
400 assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
401 assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
402 }
403
404 fn fingerprint_of(result: &Value) -> &str {
405 result["partialFingerprints"][SARIF_FINGERPRINT_KEY]
406 .as_str()
407 .expect("fingerprint")
408 }
409
410 #[test]
416 fn two_findings_on_one_line_get_different_fingerprints() {
417 let at_column = |col: u32| {
418 build_sarif_result(SarifResultInput {
419 rule_id: "fallow/unused-export",
420 level: "warning",
421 message: "Re-export is never imported by other modules",
422 uri: "src/barrel.ts",
423 region: Some((1, col)),
424 snippet: Some("export { alpha, beta } from './m';"),
425 })
426 };
427
428 assert_ne!(
429 fingerprint_of(&at_column(10)),
430 fingerprint_of(&at_column(17))
431 );
432 }
433
434 #[test]
438 fn moving_a_finding_to_another_line_keeps_its_fingerprint() {
439 let at_line = |line: u32| {
440 build_sarif_result(SarifResultInput {
441 rule_id: "fallow/unused-export",
442 level: "warning",
443 message: "Export is never imported by other modules",
444 uri: "src/lib.ts",
445 region: Some((line, 14)),
446 snippet: Some("export const alpha = 1;"),
447 })
448 };
449
450 assert_eq!(fingerprint_of(&at_line(3)), fingerprint_of(&at_line(41)));
451 }
452
453 #[test]
456 fn identical_results_are_separated_by_occurrence() {
457 let result = || {
458 build_sarif_result(SarifResultInput {
459 rule_id: "fallow/duplicate-export",
460 level: "warning",
461 message: "Export 'Video' appears in multiple modules",
462 uri: "src/types.ts",
463 region: Some((309, 18)),
464 snippet: Some("export type Video = {"),
465 })
466 };
467 let mut results = vec![result(), result(), result()];
468 let first_before = fingerprint_of(&results[0]).to_owned();
469
470 ensure_unique_result_fingerprints(&mut results);
471
472 assert_eq!(
473 fingerprint_of(&results[0]),
474 first_before,
475 "the first occurrence keeps the identity an existing alert was opened under"
476 );
477 let unique: std::collections::BTreeSet<&str> = results.iter().map(fingerprint_of).collect();
478 assert_eq!(unique.len(), 3, "{results:?}");
479 for result in &results {
480 assert_eq!(
481 result["partialFingerprints"][SARIF_FINGERPRINT_KEY],
482 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY],
483 "both keys name the same alert"
484 );
485 }
486 }
487
488 #[test]
491 fn the_uniqueness_pass_keeps_the_finding_id_key() {
492 let result = |finding_id: &str| {
493 build_sarif_finding(SarifFindingInput {
494 issue_code: "unused-class-member",
495 rule_id: "fallow/unused-class-member",
496 level: "warning",
497 message: "Class member 'run' is never used",
498 uri: "src/service.ts",
499 region: Some((4, 3)),
500 snippet: Some("run() {}"),
501 properties: None,
502 finding_id: Some(finding_id),
503 })
504 };
505 let ids = [
506 "dc1:unused-class-member:0123456789abcdef",
507 "dc1:unused-class-member:0123456789abcdef~1",
508 ];
509 let mut results = ids.map(result).to_vec();
510
511 ensure_unique_result_fingerprints(&mut results);
512
513 assert_ne!(
514 fingerprint_of(&results[0]),
515 fingerprint_of(&results[1]),
516 "the pass must still separate the location-based keys"
517 );
518 for (result, id) in results.iter().zip(ids) {
519 assert_eq!(result["partialFingerprints"][SARIF_FINDING_ID_KEY], id);
520 }
521 }
522
523 #[test]
526 fn a_finding_id_adds_one_key_and_keeps_the_rest() {
527 let result = |finding_id: Option<&str>| {
528 build_sarif_finding(SarifFindingInput {
529 issue_code: "unused-export",
530 rule_id: "fallow/unused-export",
531 level: "warning",
532 message: "Export 'helper' is never imported by other modules",
533 uri: "src/utils.ts",
534 region: Some((3, 14)),
535 snippet: Some("export const helper = 1;"),
536 properties: None,
537 finding_id,
538 })
539 };
540 let without = result(None);
541 let mut with = result(Some("dc1:unused-export:0123456789abcdef"));
542
543 assert!(
544 without["partialFingerprints"]
545 .get(SARIF_FINDING_ID_KEY)
546 .is_none()
547 );
548 let removed = with["partialFingerprints"]
549 .as_object_mut()
550 .and_then(|prints| prints.remove(SARIF_FINDING_ID_KEY));
551 assert_eq!(
552 removed,
553 Some(Value::from("dc1:unused-export:0123456789abcdef"))
554 );
555 assert_eq!(with, without);
556 }
557
558 #[test]
561 fn distinct_results_are_left_alone() {
562 let mut results = vec![
563 build_sarif_result(SarifResultInput {
564 rule_id: "fallow/unused-export",
565 level: "warning",
566 message: "Export 'alpha' is never imported by other modules",
567 uri: "src/lib.ts",
568 region: Some((1, 14)),
569 snippet: Some("export const alpha = 1;"),
570 }),
571 build_sarif_result(SarifResultInput {
572 rule_id: "fallow/unused-export",
573 level: "warning",
574 message: "Export 'beta' is never imported by other modules",
575 uri: "src/lib.ts",
576 region: Some((2, 14)),
577 snippet: Some("export const beta = 2;"),
578 }),
579 ];
580 let before = results.clone();
581
582 ensure_unique_result_fingerprints(&mut results);
583
584 assert_eq!(results, before);
585 }
586
587 #[test]
588 fn sarif_finding_includes_custom_properties() {
589 let finding = build_sarif_finding(SarifFindingInput {
590 issue_code: "unused-export",
591 rule_id: "fallow/unused-export",
592 level: "warning",
593 message: "Export is never imported",
594 uri: "src/app.ts",
595 region: Some((3, 14)),
596 snippet: Some("export const unused = 1;"),
597 properties: Some(serde_json::json!({ "is_re_export": true })),
598 finding_id: None,
599 });
600
601 assert_eq!(finding["ruleId"], "fallow/unused-export");
602 assert_eq!(finding["properties"]["is_re_export"], true);
603 assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
604 }
605
606 #[test]
607 fn sarif_finding_omits_empty_properties() {
608 let finding = build_sarif_finding(SarifFindingInput {
609 issue_code: "unused-file",
610 rule_id: "fallow/unused-file",
611 level: "error",
612 message: "File is unreachable",
613 uri: "src/unused.ts",
614 region: None,
615 snippet: None,
616 properties: None,
617 finding_id: None,
618 });
619
620 assert!(finding.get("properties").is_none());
621 }
622
623 #[test]
624 fn append_sarif_findings_attaches_snippet_and_properties() {
625 let temp = tempfile::tempdir().expect("tempdir");
626 let source = temp.path().join("src.ts");
627 std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
628 let mut snippets = SarifSourceSnippetCache::default();
629 let mut results = Vec::new();
630 let mut finding = fallow_types::output_dead_code::UnusedFileFinding::with_actions(
631 fallow_types::results::UnusedFile { path: source },
632 );
633 finding.finding_id = Some("dc1:unused-file:0123456789abcdef".to_owned());
634
635 append_sarif_findings(
636 &mut results,
637 std::slice::from_ref(&finding),
638 &mut snippets,
639 |finding| SarifFindingFields {
640 rule_id: "fallow/unused-export",
641 level: "warning",
642 message: "Export is never imported".to_string(),
643 uri: "src.ts".to_string(),
644 region: Some((2, 1)),
645 source_path: Some(finding.file.path.clone()),
646 properties: Some(serde_json::json!({ "is_re_export": true })),
647 },
648 );
649
650 assert_eq!(results.len(), 1);
651 assert_eq!(results[0]["ruleId"], "fallow/unused-export");
652 assert_eq!(results[0]["properties"]["is_re_export"], true);
653 assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
654 assert_eq!(
655 results[0]["partialFingerprints"][SARIF_FINDING_ID_KEY],
656 "dc1:unused-file:0123456789abcdef"
657 );
658 }
659
660 #[test]
661 fn sarif_rule_omits_optional_docs_when_absent() {
662 let rule = build_sarif_rule(SarifRuleInput {
663 id: "fallow/test",
664 short_description: "short",
665 level: "warning",
666 full_description: None,
667 help_uri: None,
668 });
669
670 assert!(rule.get("fullDescription").is_none());
671 assert!(rule.get("helpUri").is_none());
672 }
673
674 #[test]
675 fn sarif_document_uses_supplied_version() {
676 let document = build_sarif_document(SarifDocumentInput {
677 results: &[],
678 rules: &[],
679 tool_version: "1.2.3",
680 });
681
682 assert_eq!(document["version"], "2.1.0");
683 assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
684 }
685}