Skip to main content

fallow_output/
sarif.rs

1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashMap;
4use serde_json::Value;
5
6use crate::codeclimate::codeclimate_fingerprint_hash;
7
8/// Fingerprint key used in SARIF partialFingerprints and other CI formats.
9pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
10
11/// Conventional SARIF key consumed by GitHub Code Scanning.
12pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
13
14/// Fields needed to build one SARIF result object.
15#[derive(Debug, Clone, Copy)]
16pub struct SarifResultInput<'a> {
17    /// SARIF rule id the result references, e.g. `fallow/unused-file`.
18    pub rule_id: &'a str,
19    /// SARIF level: `error`, `warning`, or `note`.
20    pub level: &'a str,
21    /// Human-readable result message text.
22    pub message: &'a str,
23    /// Artifact URI relative to the analysed root.
24    pub uri: &'a str,
25    /// 1-based `(start_line, start_column)` region, when known.
26    pub region: Option<(u32, u32)>,
27    /// Source snippet that feeds the stable fingerprint and region context.
28    pub snippet: Option<&'a str>,
29}
30
31/// Normalized finding input for output-owned SARIF result assembly.
32#[derive(Debug, Clone)]
33pub struct SarifFindingInput<'a> {
34    /// Fallow issue code the finding originated from, e.g. `unused-file`.
35    pub issue_code: &'a str,
36    /// SARIF rule id the result references.
37    pub rule_id: &'a str,
38    /// SARIF level: `error`, `warning`, or `note`.
39    pub level: &'a str,
40    /// Human-readable result message text.
41    pub message: &'a str,
42    /// Artifact URI relative to the analysed root.
43    pub uri: &'a str,
44    /// 1-based `(start_line, start_column)` region, when known.
45    pub region: Option<(u32, u32)>,
46    /// Source snippet that feeds the stable fingerprint and region context.
47    pub snippet: Option<&'a str>,
48    /// Extra `properties` bag copied onto the SARIF result verbatim.
49    pub properties: Option<Value>,
50}
51
52/// Intermediate fields extracted from one issue for SARIF result construction.
53#[derive(Debug, Clone)]
54pub struct SarifFindingFields {
55    /// SARIF rule id the result references.
56    pub rule_id: &'static str,
57    /// SARIF level: `error`, `warning`, or `note`.
58    pub level: &'static str,
59    /// Human-readable result message text.
60    pub message: String,
61    /// Artifact URI relative to the analysed root.
62    pub uri: String,
63    /// 1-based `(start_line, start_column)` region, when known.
64    pub region: Option<(u32, u32)>,
65    /// Absolute source path used to load the fingerprint snippet.
66    pub source_path: Option<PathBuf>,
67    /// Extra `properties` bag copied onto the SARIF result verbatim.
68    pub properties: Option<Value>,
69}
70
71/// Fields needed to build one SARIF rule object.
72#[derive(Debug, Clone, Copy)]
73pub struct SarifRuleInput<'a> {
74    /// SARIF rule id, e.g. `fallow/unused-file`.
75    pub id: &'a str,
76    /// One-line rule description shown in SARIF viewers.
77    pub short_description: &'a str,
78    /// Default SARIF level for the rule's `defaultConfiguration`.
79    pub level: &'a str,
80    /// Longer rule description, when the rule has one.
81    pub full_description: Option<&'a str>,
82    /// Public documentation URL for the rule.
83    pub help_uri: Option<&'a str>,
84}
85
86/// Fields needed to build a SARIF document envelope.
87#[derive(Debug, Clone, Copy)]
88pub struct SarifDocumentInput<'a> {
89    /// Pre-built SARIF result objects for the single run.
90    pub results: &'a [Value],
91    /// Pre-built tool-driver rule objects for the single run.
92    pub rules: &'a [Value],
93    /// Fallow version reported as the SARIF tool driver version.
94    pub tool_version: &'a str,
95}
96
97/// Normalize a source snippet before it contributes to stable SARIF identity.
98#[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/// Stable SARIF fingerprint for a finding with source snippet evidence.
109///
110/// `col` is the 1-based start column the finding reports, and is what separates
111/// two findings of the same rule that share a source line.
112#[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/// Lazily reads source files so SARIF result builders can attach stable line snippets.
119#[derive(Debug, Default)]
120pub struct SarifSourceSnippetCache {
121    root: Option<PathBuf>,
122    files: FxHashMap<PathBuf, Vec<String>>,
123}
124
125impl SarifSourceSnippetCache {
126    /// Create a snippet cache that resolves relative finding paths against the
127    /// analyzed project root.
128    #[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    /// Return the 1-based source line from a file, caching the file contents.
137    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/// Build a single SARIF result object.
163///
164/// When `region` is `Some((line, col))`, a `region` block with 1-based
165/// `startLine` and `startColumn` is included in the physical location.
166#[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    // The snippet replaces the LINE, which moves under any edit above it, and
188    // not the COLUMN, which is as stable as the snippet itself: two findings on
189    // one line are two alerts, and GitHub code scanning treats one
190    // `partialFingerprints` value as one alert identity. Dropping the column
191    // here collapsed every re-export in a one-line barrel, every member of a
192    // one-line enum, and every dependency in a compact `package.json` into a
193    // single alert.
194    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/// Build a SARIF result from a normalized finding.
212#[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/// Build a single SARIF result object with optional source snippet evidence.
229#[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
248/// Append SARIF findings by extracting normalized fields from typed issues.
249pub 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
276/// Give every result in one run its own `partialFingerprints` value.
277///
278/// GitHub code scanning treats that value as alert identity, so two results
279/// sharing one are one alert and the second finding is never surfaced. Rule id,
280/// URI, snippet, and column already separate findings that differ anywhere a
281/// reader can see; what is left is a file that reports the same rule twice with
282/// byte-identical evidence, such as the same declaration written twice. The
283/// first occurrence keeps the value it computed, so an alert that already exists
284/// is never disturbed, and each repeat mixes in its occurrence index.
285pub 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/// Build a SARIF rule object.
309#[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/// Build a SARIF 2.1.0 document envelope.
338///
339/// Applies [`ensure_unique_result_fingerprints`], so every run this builds
340/// satisfies the one-alert-per-finding property. A caller that replaces
341/// `/runs/0/results` afterwards has to apply it again.
342#[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    /// A one-line re-export barrel, a one-line enum, and a compact
394    /// `package.json` all put two findings of one rule on one source line, so
395    /// the snippet is identical and only the column tells them apart. GitHub
396    /// code scanning keys alert identity on this value, so a shared value is a
397    /// lost alert.
398    #[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    /// The column is the only position in the fingerprint: a snippet that
418    /// survives an edit above it has to keep its identity, or every open alert
419    /// on the file below the edit closes and reopens.
420    #[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    /// Two byte-identical declarations in one file leave the snippet and the
437    /// column identical, so position alone cannot separate them.
438    #[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    /// A run whose results already differ must come out byte-identical, so the
472    /// pass never churns an alert that was already unique.
473    #[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}