1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashMap;
4use serde_json::Value;
5
6use crate::codeclimate::codeclimate_fingerprint_hash;
7
8pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
10
11pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
13
14#[derive(Debug, Clone, Copy)]
16pub struct SarifResultInput<'a> {
17 pub rule_id: &'a str,
19 pub level: &'a str,
21 pub message: &'a str,
23 pub uri: &'a str,
25 pub region: Option<(u32, u32)>,
27 pub snippet: Option<&'a str>,
29}
30
31#[derive(Debug, Clone)]
33pub struct SarifFindingInput<'a> {
34 pub issue_code: &'a str,
36 pub rule_id: &'a str,
38 pub level: &'a str,
40 pub message: &'a str,
42 pub uri: &'a str,
44 pub region: Option<(u32, u32)>,
46 pub snippet: Option<&'a str>,
48 pub properties: Option<Value>,
50}
51
52#[derive(Debug, Clone)]
54pub struct SarifFindingFields {
55 pub rule_id: &'static str,
57 pub level: &'static str,
59 pub message: String,
61 pub uri: String,
63 pub region: Option<(u32, u32)>,
65 pub source_path: Option<PathBuf>,
67 pub properties: Option<Value>,
69}
70
71#[derive(Debug, Clone, Copy)]
73pub struct SarifRuleInput<'a> {
74 pub id: &'a str,
76 pub short_description: &'a str,
78 pub level: &'a str,
80 pub full_description: Option<&'a str>,
82 pub help_uri: Option<&'a str>,
84}
85
86#[derive(Debug, Clone, Copy)]
88pub struct SarifDocumentInput<'a> {
89 pub results: &'a [Value],
91 pub rules: &'a [Value],
93 pub tool_version: &'a str,
95}
96
97#[must_use]
99pub fn normalize_sarif_snippet(snippet: &str) -> String {
100 snippet
101 .lines()
102 .map(str::trim)
103 .filter(|line| !line.is_empty())
104 .collect::<Vec<_>>()
105 .join("\n")
106}
107
108#[must_use]
113pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str, col: u32) -> String {
114 let normalized = normalize_sarif_snippet(snippet);
115 codeclimate_fingerprint_hash(&[rule_id, path, &normalized, &col.to_string()])
116}
117
118#[derive(Debug, Default)]
120pub struct SarifSourceSnippetCache {
121 root: Option<PathBuf>,
122 files: FxHashMap<PathBuf, Vec<String>>,
123}
124
125impl SarifSourceSnippetCache {
126 #[must_use]
129 pub fn with_root(root: &Path) -> Self {
130 Self {
131 root: Some(root.to_path_buf()),
132 files: FxHashMap::default(),
133 }
134 }
135
136 pub fn line(&mut self, path: &Path, line: u32) -> Option<String> {
138 if line == 0 {
139 return None;
140 }
141 let resolved = if path.is_relative() {
142 self.root
143 .as_deref()
144 .map_or_else(|| path.to_path_buf(), |root| root.join(path))
145 } else {
146 path.to_path_buf()
147 };
148 if !self.files.contains_key(&resolved) {
149 let lines = std::fs::read_to_string(&resolved)
150 .ok()
151 .map(|source| source.lines().map(str::to_owned).collect())
152 .unwrap_or_default();
153 self.files.insert(resolved.clone(), lines);
154 }
155 self.files
156 .get(&resolved)
157 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
158 .cloned()
159 }
160}
161
162#[must_use]
167pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
168 let mut physical_location = serde_json::json!({
169 "artifactLocation": { "uri": input.uri }
170 });
171 if let Some((line, col)) = input.region {
172 physical_location["region"] = serde_json::json!({
173 "startLine": line,
174 "startColumn": col
175 });
176 }
177 let line = input
178 .region
179 .map_or_else(String::new, |(line, _)| line.to_string());
180 let col = input
181 .region
182 .map_or_else(String::new, |(_, col)| col.to_string());
183 let normalized_snippet = input
184 .snippet
185 .map(normalize_sarif_snippet)
186 .filter(|snippet| !snippet.is_empty());
187 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
195 || codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
196 |snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet, &col]),
197 );
198 let partial_fingerprint_ghas = partial_fingerprint.clone();
199 serde_json::json!({
200 "ruleId": input.rule_id,
201 "level": input.level,
202 "message": { "text": input.message },
203 "locations": [{ "physicalLocation": physical_location }],
204 "partialFingerprints": {
205 SARIF_FINGERPRINT_KEY: partial_fingerprint,
206 GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
207 }
208 })
209}
210
211#[must_use]
213pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
214 let mut result = build_sarif_result(SarifResultInput {
215 rule_id: input.rule_id,
216 level: input.level,
217 message: input.message,
218 uri: input.uri,
219 region: input.region,
220 snippet: input.snippet,
221 });
222 if let Some(properties) = input.properties {
223 result["properties"] = properties;
224 }
225 result
226}
227
228#[must_use]
230pub fn build_sarif_result_with_snippet(
231 rule_id: &str,
232 level: &str,
233 message: &str,
234 uri: &str,
235 region: Option<(u32, u32)>,
236 snippet: Option<&str>,
237) -> Value {
238 build_sarif_result(SarifResultInput {
239 rule_id,
240 level,
241 message,
242 uri,
243 region,
244 snippet,
245 })
246}
247
248pub fn append_sarif_findings<T>(
250 sarif_results: &mut Vec<Value>,
251 items: &[T],
252 snippets: &mut SarifSourceSnippetCache,
253 mut extract: impl FnMut(&T) -> SarifFindingFields,
254) {
255 for item in items {
256 let fields = extract(item);
257 let source_snippet = fields
258 .source_path
259 .as_deref()
260 .zip(fields.region)
261 .and_then(|(path, (line, _))| snippets.line(path, line));
262 let result = build_sarif_finding(SarifFindingInput {
263 issue_code: issue_code_from_rule_id(fields.rule_id),
264 rule_id: fields.rule_id,
265 level: fields.level,
266 message: &fields.message,
267 uri: &fields.uri,
268 region: fields.region,
269 snippet: source_snippet.as_deref(),
270 properties: fields.properties,
271 });
272 sarif_results.push(result);
273 }
274}
275
276pub fn ensure_unique_result_fingerprints(results: &mut [Value]) {
286 let mut occurrences: FxHashMap<String, u32> = FxHashMap::default();
287 for result in results {
288 let Some(fingerprint) = result
289 .get("partialFingerprints")
290 .and_then(|prints| prints.get(SARIF_FINGERPRINT_KEY))
291 .and_then(Value::as_str)
292 .map(str::to_owned)
293 else {
294 continue;
295 };
296 let occurrence = occurrences.entry(fingerprint.clone()).or_insert(0);
297 let index = *occurrence;
298 *occurrence += 1;
299 if index == 0 {
300 continue;
301 }
302 let unique = codeclimate_fingerprint_hash(&[&fingerprint, &index.to_string()]);
303 result["partialFingerprints"][SARIF_FINGERPRINT_KEY] = Value::from(unique.clone());
304 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY] = Value::from(unique);
305 }
306}
307
308#[must_use]
310pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
311 let mut rule = serde_json::Map::new();
312 rule.insert("id".to_string(), serde_json::json!(input.id));
313 rule.insert(
314 "shortDescription".to_string(),
315 serde_json::json!({ "text": input.short_description }),
316 );
317 if let Some(full_description) = input.full_description {
318 rule.insert(
319 "fullDescription".to_string(),
320 serde_json::json!({ "text": full_description }),
321 );
322 }
323 if let Some(help_uri) = input.help_uri {
324 rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
325 }
326 rule.insert(
327 "defaultConfiguration".to_string(),
328 serde_json::json!({ "level": input.level }),
329 );
330 Value::Object(rule)
331}
332
333fn issue_code_from_rule_id(rule_id: &str) -> &str {
334 rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
335}
336
337#[must_use]
343pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
344 let mut results = input.results.to_vec();
345 ensure_unique_result_fingerprints(&mut results);
346 serde_json::json!({
347 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
348 "version": "2.1.0",
349 "runs": [{
350 "tool": {
351 "driver": {
352 "name": "fallow",
353 "version": input.tool_version,
354 "informationUri": "https://github.com/fallow-rs/fallow",
355 "rules": input.rules
356 }
357 },
358 "results": results
359 }]
360 })
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn sarif_result_includes_location_and_fingerprints() {
369 let result = build_sarif_result(SarifResultInput {
370 rule_id: "fallow/test",
371 level: "warning",
372 message: "description",
373 uri: "src/app.ts",
374 region: Some((7, 3)),
375 snippet: Some(" export const value = 1; "),
376 });
377
378 assert_eq!(result["ruleId"], "fallow/test");
379 assert_eq!(
380 result["locations"][0]["physicalLocation"]["region"]["startLine"],
381 7
382 );
383 assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
384 assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
385 }
386
387 fn fingerprint_of(result: &Value) -> &str {
388 result["partialFingerprints"][SARIF_FINGERPRINT_KEY]
389 .as_str()
390 .expect("fingerprint")
391 }
392
393 #[test]
399 fn two_findings_on_one_line_get_different_fingerprints() {
400 let at_column = |col: u32| {
401 build_sarif_result(SarifResultInput {
402 rule_id: "fallow/unused-export",
403 level: "warning",
404 message: "Re-export is never imported by other modules",
405 uri: "src/barrel.ts",
406 region: Some((1, col)),
407 snippet: Some("export { alpha, beta } from './m';"),
408 })
409 };
410
411 assert_ne!(
412 fingerprint_of(&at_column(10)),
413 fingerprint_of(&at_column(17))
414 );
415 }
416
417 #[test]
421 fn moving_a_finding_to_another_line_keeps_its_fingerprint() {
422 let at_line = |line: u32| {
423 build_sarif_result(SarifResultInput {
424 rule_id: "fallow/unused-export",
425 level: "warning",
426 message: "Export is never imported by other modules",
427 uri: "src/lib.ts",
428 region: Some((line, 14)),
429 snippet: Some("export const alpha = 1;"),
430 })
431 };
432
433 assert_eq!(fingerprint_of(&at_line(3)), fingerprint_of(&at_line(41)));
434 }
435
436 #[test]
439 fn identical_results_are_separated_by_occurrence() {
440 let result = || {
441 build_sarif_result(SarifResultInput {
442 rule_id: "fallow/duplicate-export",
443 level: "warning",
444 message: "Export 'Video' appears in multiple modules",
445 uri: "src/types.ts",
446 region: Some((309, 18)),
447 snippet: Some("export type Video = {"),
448 })
449 };
450 let mut results = vec![result(), result(), result()];
451 let first_before = fingerprint_of(&results[0]).to_owned();
452
453 ensure_unique_result_fingerprints(&mut results);
454
455 assert_eq!(
456 fingerprint_of(&results[0]),
457 first_before,
458 "the first occurrence keeps the identity an existing alert was opened under"
459 );
460 let unique: std::collections::BTreeSet<&str> = results.iter().map(fingerprint_of).collect();
461 assert_eq!(unique.len(), 3, "{results:?}");
462 for result in &results {
463 assert_eq!(
464 result["partialFingerprints"][SARIF_FINGERPRINT_KEY],
465 result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY],
466 "both keys name the same alert"
467 );
468 }
469 }
470
471 #[test]
474 fn distinct_results_are_left_alone() {
475 let mut results = vec![
476 build_sarif_result(SarifResultInput {
477 rule_id: "fallow/unused-export",
478 level: "warning",
479 message: "Export 'alpha' is never imported by other modules",
480 uri: "src/lib.ts",
481 region: Some((1, 14)),
482 snippet: Some("export const alpha = 1;"),
483 }),
484 build_sarif_result(SarifResultInput {
485 rule_id: "fallow/unused-export",
486 level: "warning",
487 message: "Export 'beta' is never imported by other modules",
488 uri: "src/lib.ts",
489 region: Some((2, 14)),
490 snippet: Some("export const beta = 2;"),
491 }),
492 ];
493 let before = results.clone();
494
495 ensure_unique_result_fingerprints(&mut results);
496
497 assert_eq!(results, before);
498 }
499
500 #[test]
501 fn sarif_finding_includes_custom_properties() {
502 let finding = build_sarif_finding(SarifFindingInput {
503 issue_code: "unused-export",
504 rule_id: "fallow/unused-export",
505 level: "warning",
506 message: "Export is never imported",
507 uri: "src/app.ts",
508 region: Some((3, 14)),
509 snippet: Some("export const unused = 1;"),
510 properties: Some(serde_json::json!({ "is_re_export": true })),
511 });
512
513 assert_eq!(finding["ruleId"], "fallow/unused-export");
514 assert_eq!(finding["properties"]["is_re_export"], true);
515 assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
516 }
517
518 #[test]
519 fn sarif_finding_omits_empty_properties() {
520 let finding = build_sarif_finding(SarifFindingInput {
521 issue_code: "unused-file",
522 rule_id: "fallow/unused-file",
523 level: "error",
524 message: "File is unreachable",
525 uri: "src/unused.ts",
526 region: None,
527 snippet: None,
528 properties: None,
529 });
530
531 assert!(finding.get("properties").is_none());
532 }
533
534 #[test]
535 fn append_sarif_findings_attaches_snippet_and_properties() {
536 let temp = tempfile::tempdir().expect("tempdir");
537 let source = temp.path().join("src.ts");
538 std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
539 let mut snippets = SarifSourceSnippetCache::default();
540 let mut results = Vec::new();
541
542 append_sarif_findings(
543 &mut results,
544 std::slice::from_ref(&source),
545 &mut snippets,
546 |path| SarifFindingFields {
547 rule_id: "fallow/unused-export",
548 level: "warning",
549 message: "Export is never imported".to_string(),
550 uri: "src.ts".to_string(),
551 region: Some((2, 1)),
552 source_path: Some(path.clone()),
553 properties: Some(serde_json::json!({ "is_re_export": true })),
554 },
555 );
556
557 assert_eq!(results.len(), 1);
558 assert_eq!(results[0]["ruleId"], "fallow/unused-export");
559 assert_eq!(results[0]["properties"]["is_re_export"], true);
560 assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
561 }
562
563 #[test]
564 fn sarif_rule_omits_optional_docs_when_absent() {
565 let rule = build_sarif_rule(SarifRuleInput {
566 id: "fallow/test",
567 short_description: "short",
568 level: "warning",
569 full_description: None,
570 help_uri: None,
571 });
572
573 assert!(rule.get("fullDescription").is_none());
574 assert!(rule.get("helpUri").is_none());
575 }
576
577 #[test]
578 fn sarif_document_uses_supplied_version() {
579 let document = build_sarif_document(SarifDocumentInput {
580 results: &[],
581 rules: &[],
582 tool_version: "1.2.3",
583 });
584
585 assert_eq!(document["version"], "2.1.0");
586 assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
587 }
588}