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    pub rule_id: &'a str,
18    pub level: &'a str,
19    pub message: &'a str,
20    pub uri: &'a str,
21    pub region: Option<(u32, u32)>,
22    pub snippet: Option<&'a str>,
23}
24
25/// Normalized finding input for output-owned SARIF result assembly.
26#[derive(Debug, Clone)]
27pub struct SarifFindingInput<'a> {
28    pub issue_code: &'a str,
29    pub rule_id: &'a str,
30    pub level: &'a str,
31    pub message: &'a str,
32    pub uri: &'a str,
33    pub region: Option<(u32, u32)>,
34    pub snippet: Option<&'a str>,
35    pub properties: Option<Value>,
36}
37
38/// Intermediate fields extracted from one issue for SARIF result construction.
39#[derive(Debug, Clone)]
40pub struct SarifFindingFields {
41    pub rule_id: &'static str,
42    pub level: &'static str,
43    pub message: String,
44    pub uri: String,
45    pub region: Option<(u32, u32)>,
46    pub source_path: Option<PathBuf>,
47    pub properties: Option<Value>,
48}
49
50/// Fields needed to build one SARIF rule object.
51#[derive(Debug, Clone, Copy)]
52pub struct SarifRuleInput<'a> {
53    pub id: &'a str,
54    pub short_description: &'a str,
55    pub level: &'a str,
56    pub full_description: Option<&'a str>,
57    pub help_uri: Option<&'a str>,
58}
59
60/// Fields needed to build a SARIF document envelope.
61#[derive(Debug, Clone, Copy)]
62pub struct SarifDocumentInput<'a> {
63    pub results: &'a [Value],
64    pub rules: &'a [Value],
65    pub tool_version: &'a str,
66}
67
68/// Normalize a source snippet before it contributes to stable SARIF identity.
69#[must_use]
70pub fn normalize_sarif_snippet(snippet: &str) -> String {
71    snippet
72        .lines()
73        .map(str::trim)
74        .filter(|line| !line.is_empty())
75        .collect::<Vec<_>>()
76        .join("\n")
77}
78
79/// Stable SARIF fingerprint for a finding with source snippet evidence.
80#[must_use]
81pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str) -> String {
82    let normalized = normalize_sarif_snippet(snippet);
83    codeclimate_fingerprint_hash(&[rule_id, path, &normalized])
84}
85
86/// Lazily reads source files so SARIF result builders can attach stable line snippets.
87#[derive(Debug, Default)]
88pub struct SarifSourceSnippetCache {
89    files: FxHashMap<PathBuf, Vec<String>>,
90}
91
92impl SarifSourceSnippetCache {
93    /// Return the 1-based source line from a file, caching the file contents.
94    pub fn line(&mut self, path: &Path, line: u32) -> Option<String> {
95        if line == 0 {
96            return None;
97        }
98        if !self.files.contains_key(path) {
99            let lines = std::fs::read_to_string(path)
100                .ok()
101                .map(|source| source.lines().map(str::to_owned).collect())
102                .unwrap_or_default();
103            self.files.insert(path.to_path_buf(), lines);
104        }
105        self.files
106            .get(path)
107            .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
108            .cloned()
109    }
110}
111
112/// Build a single SARIF result object.
113///
114/// When `region` is `Some((line, col))`, a `region` block with 1-based
115/// `startLine` and `startColumn` is included in the physical location.
116#[must_use]
117pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
118    let mut physical_location = serde_json::json!({
119        "artifactLocation": { "uri": input.uri }
120    });
121    if let Some((line, col)) = input.region {
122        physical_location["region"] = serde_json::json!({
123            "startLine": line,
124            "startColumn": col
125        });
126    }
127    let line = input
128        .region
129        .map_or_else(String::new, |(line, _)| line.to_string());
130    let col = input
131        .region
132        .map_or_else(String::new, |(_, col)| col.to_string());
133    let normalized_snippet = input
134        .snippet
135        .map(normalize_sarif_snippet)
136        .filter(|snippet| !snippet.is_empty());
137    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
138        || codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
139        |snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet]),
140    );
141    let partial_fingerprint_ghas = partial_fingerprint.clone();
142    serde_json::json!({
143        "ruleId": input.rule_id,
144        "level": input.level,
145        "message": { "text": input.message },
146        "locations": [{ "physicalLocation": physical_location }],
147        "partialFingerprints": {
148            SARIF_FINGERPRINT_KEY: partial_fingerprint,
149            GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
150        }
151    })
152}
153
154/// Build a SARIF result from a normalized finding.
155#[must_use]
156pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
157    let mut result = build_sarif_result(SarifResultInput {
158        rule_id: input.rule_id,
159        level: input.level,
160        message: input.message,
161        uri: input.uri,
162        region: input.region,
163        snippet: input.snippet,
164    });
165    if let Some(properties) = input.properties {
166        result["properties"] = properties;
167    }
168    result
169}
170
171/// Build a single SARIF result object with optional source snippet evidence.
172#[must_use]
173pub fn build_sarif_result_with_snippet(
174    rule_id: &str,
175    level: &str,
176    message: &str,
177    uri: &str,
178    region: Option<(u32, u32)>,
179    snippet: Option<&str>,
180) -> Value {
181    build_sarif_result(SarifResultInput {
182        rule_id,
183        level,
184        message,
185        uri,
186        region,
187        snippet,
188    })
189}
190
191/// Append SARIF findings by extracting normalized fields from typed issues.
192pub fn append_sarif_findings<T>(
193    sarif_results: &mut Vec<Value>,
194    items: &[T],
195    snippets: &mut SarifSourceSnippetCache,
196    mut extract: impl FnMut(&T) -> SarifFindingFields,
197) {
198    for item in items {
199        let fields = extract(item);
200        let source_snippet = fields
201            .source_path
202            .as_deref()
203            .zip(fields.region)
204            .and_then(|(path, (line, _))| snippets.line(path, line));
205        let result = build_sarif_finding(SarifFindingInput {
206            issue_code: issue_code_from_rule_id(fields.rule_id),
207            rule_id: fields.rule_id,
208            level: fields.level,
209            message: &fields.message,
210            uri: &fields.uri,
211            region: fields.region,
212            snippet: source_snippet.as_deref(),
213            properties: fields.properties,
214        });
215        sarif_results.push(result);
216    }
217}
218
219/// Build a SARIF rule object.
220#[must_use]
221pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
222    let mut rule = serde_json::Map::new();
223    rule.insert("id".to_string(), serde_json::json!(input.id));
224    rule.insert(
225        "shortDescription".to_string(),
226        serde_json::json!({ "text": input.short_description }),
227    );
228    if let Some(full_description) = input.full_description {
229        rule.insert(
230            "fullDescription".to_string(),
231            serde_json::json!({ "text": full_description }),
232        );
233    }
234    if let Some(help_uri) = input.help_uri {
235        rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
236    }
237    rule.insert(
238        "defaultConfiguration".to_string(),
239        serde_json::json!({ "level": input.level }),
240    );
241    Value::Object(rule)
242}
243
244fn issue_code_from_rule_id(rule_id: &str) -> &str {
245    rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
246}
247
248/// Build a SARIF 2.1.0 document envelope.
249#[must_use]
250pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
251    serde_json::json!({
252        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
253        "version": "2.1.0",
254        "runs": [{
255            "tool": {
256                "driver": {
257                    "name": "fallow",
258                    "version": input.tool_version,
259                    "informationUri": "https://github.com/fallow-rs/fallow",
260                    "rules": input.rules
261                }
262            },
263            "results": input.results
264        }]
265    })
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn sarif_result_includes_location_and_fingerprints() {
274        let result = build_sarif_result(SarifResultInput {
275            rule_id: "fallow/test",
276            level: "warning",
277            message: "description",
278            uri: "src/app.ts",
279            region: Some((7, 3)),
280            snippet: Some("  export const value = 1;  "),
281        });
282
283        assert_eq!(result["ruleId"], "fallow/test");
284        assert_eq!(
285            result["locations"][0]["physicalLocation"]["region"]["startLine"],
286            7
287        );
288        assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
289        assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
290    }
291
292    #[test]
293    fn sarif_finding_includes_custom_properties() {
294        let finding = build_sarif_finding(SarifFindingInput {
295            issue_code: "unused-export",
296            rule_id: "fallow/unused-export",
297            level: "warning",
298            message: "Export is never imported",
299            uri: "src/app.ts",
300            region: Some((3, 14)),
301            snippet: Some("export const unused = 1;"),
302            properties: Some(serde_json::json!({ "is_re_export": true })),
303        });
304
305        assert_eq!(finding["ruleId"], "fallow/unused-export");
306        assert_eq!(finding["properties"]["is_re_export"], true);
307        assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
308    }
309
310    #[test]
311    fn sarif_finding_omits_empty_properties() {
312        let finding = build_sarif_finding(SarifFindingInput {
313            issue_code: "unused-file",
314            rule_id: "fallow/unused-file",
315            level: "error",
316            message: "File is unreachable",
317            uri: "src/unused.ts",
318            region: None,
319            snippet: None,
320            properties: None,
321        });
322
323        assert!(finding.get("properties").is_none());
324    }
325
326    #[test]
327    fn append_sarif_findings_attaches_snippet_and_properties() {
328        let temp = tempfile::tempdir().expect("tempdir");
329        let source = temp.path().join("src.ts");
330        std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
331        let mut snippets = SarifSourceSnippetCache::default();
332        let mut results = Vec::new();
333
334        append_sarif_findings(
335            &mut results,
336            std::slice::from_ref(&source),
337            &mut snippets,
338            |path| SarifFindingFields {
339                rule_id: "fallow/unused-export",
340                level: "warning",
341                message: "Export is never imported".to_string(),
342                uri: "src.ts".to_string(),
343                region: Some((2, 1)),
344                source_path: Some(path.clone()),
345                properties: Some(serde_json::json!({ "is_re_export": true })),
346            },
347        );
348
349        assert_eq!(results.len(), 1);
350        assert_eq!(results[0]["ruleId"], "fallow/unused-export");
351        assert_eq!(results[0]["properties"]["is_re_export"], true);
352        assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
353    }
354
355    #[test]
356    fn sarif_rule_omits_optional_docs_when_absent() {
357        let rule = build_sarif_rule(SarifRuleInput {
358            id: "fallow/test",
359            short_description: "short",
360            level: "warning",
361            full_description: None,
362            help_uri: None,
363        });
364
365        assert!(rule.get("fullDescription").is_none());
366        assert!(rule.get("helpUri").is_none());
367    }
368
369    #[test]
370    fn sarif_document_uses_supplied_version() {
371        let document = build_sarif_document(SarifDocumentInput {
372            results: &[],
373            rules: &[],
374            tool_version: "1.2.3",
375        });
376
377        assert_eq!(document["version"], "2.1.0");
378        assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
379    }
380}