Skip to main content

fallow_output/
sarif.rs

1use serde_json::Value;
2
3use crate::codeclimate::codeclimate_fingerprint_hash;
4
5/// Fingerprint key used in SARIF partialFingerprints and other CI formats.
6pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
7
8/// Conventional SARIF key consumed by GitHub Code Scanning.
9pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
10
11/// Fields needed to build one SARIF result object.
12#[derive(Debug, Clone, Copy)]
13pub struct SarifResultInput<'a> {
14    pub rule_id: &'a str,
15    pub level: &'a str,
16    pub message: &'a str,
17    pub uri: &'a str,
18    pub region: Option<(u32, u32)>,
19    pub snippet: Option<&'a str>,
20}
21
22/// Fields needed to build one SARIF rule object.
23#[derive(Debug, Clone, Copy)]
24pub struct SarifRuleInput<'a> {
25    pub id: &'a str,
26    pub short_description: &'a str,
27    pub level: &'a str,
28    pub full_description: Option<&'a str>,
29    pub help_uri: Option<&'a str>,
30}
31
32/// Fields needed to build a SARIF document envelope.
33#[derive(Debug, Clone, Copy)]
34pub struct SarifDocumentInput<'a> {
35    pub results: &'a [Value],
36    pub rules: &'a [Value],
37    pub tool_version: &'a str,
38}
39
40/// Normalize a source snippet before it contributes to stable SARIF identity.
41#[must_use]
42pub fn normalize_sarif_snippet(snippet: &str) -> String {
43    snippet
44        .lines()
45        .map(str::trim)
46        .filter(|line| !line.is_empty())
47        .collect::<Vec<_>>()
48        .join("\n")
49}
50
51/// Stable SARIF fingerprint for a finding with source snippet evidence.
52#[must_use]
53pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str) -> String {
54    let normalized = normalize_sarif_snippet(snippet);
55    codeclimate_fingerprint_hash(&[rule_id, path, &normalized])
56}
57
58/// Build a single SARIF result object.
59///
60/// When `region` is `Some((line, col))`, a `region` block with 1-based
61/// `startLine` and `startColumn` is included in the physical location.
62#[must_use]
63pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
64    let mut physical_location = serde_json::json!({
65        "artifactLocation": { "uri": input.uri }
66    });
67    if let Some((line, col)) = input.region {
68        physical_location["region"] = serde_json::json!({
69            "startLine": line,
70            "startColumn": col
71        });
72    }
73    let line = input
74        .region
75        .map_or_else(String::new, |(line, _)| line.to_string());
76    let col = input
77        .region
78        .map_or_else(String::new, |(_, col)| col.to_string());
79    let normalized_snippet = input
80        .snippet
81        .map(normalize_sarif_snippet)
82        .filter(|snippet| !snippet.is_empty());
83    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
84        || codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
85        |snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet]),
86    );
87    let partial_fingerprint_ghas = partial_fingerprint.clone();
88    serde_json::json!({
89        "ruleId": input.rule_id,
90        "level": input.level,
91        "message": { "text": input.message },
92        "locations": [{ "physicalLocation": physical_location }],
93        "partialFingerprints": {
94            SARIF_FINGERPRINT_KEY: partial_fingerprint,
95            GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
96        }
97    })
98}
99
100/// Build a SARIF rule object.
101#[must_use]
102pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
103    let mut rule = serde_json::Map::new();
104    rule.insert("id".to_string(), serde_json::json!(input.id));
105    rule.insert(
106        "shortDescription".to_string(),
107        serde_json::json!({ "text": input.short_description }),
108    );
109    if let Some(full_description) = input.full_description {
110        rule.insert(
111            "fullDescription".to_string(),
112            serde_json::json!({ "text": full_description }),
113        );
114    }
115    if let Some(help_uri) = input.help_uri {
116        rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
117    }
118    rule.insert(
119        "defaultConfiguration".to_string(),
120        serde_json::json!({ "level": input.level }),
121    );
122    Value::Object(rule)
123}
124
125/// Build a SARIF 2.1.0 document envelope.
126#[must_use]
127pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
128    serde_json::json!({
129        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
130        "version": "2.1.0",
131        "runs": [{
132            "tool": {
133                "driver": {
134                    "name": "fallow",
135                    "version": input.tool_version,
136                    "informationUri": "https://github.com/fallow-rs/fallow",
137                    "rules": input.rules
138                }
139            },
140            "results": input.results
141        }]
142    })
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn sarif_result_includes_location_and_fingerprints() {
151        let result = build_sarif_result(SarifResultInput {
152            rule_id: "fallow/test",
153            level: "warning",
154            message: "description",
155            uri: "src/app.ts",
156            region: Some((7, 3)),
157            snippet: Some("  export const value = 1;  "),
158        });
159
160        assert_eq!(result["ruleId"], "fallow/test");
161        assert_eq!(
162            result["locations"][0]["physicalLocation"]["region"]["startLine"],
163            7
164        );
165        assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
166        assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
167    }
168
169    #[test]
170    fn sarif_rule_omits_optional_docs_when_absent() {
171        let rule = build_sarif_rule(SarifRuleInput {
172            id: "fallow/test",
173            short_description: "short",
174            level: "warning",
175            full_description: None,
176            help_uri: None,
177        });
178
179        assert!(rule.get("fullDescription").is_none());
180        assert!(rule.get("helpUri").is_none());
181    }
182
183    #[test]
184    fn sarif_document_uses_supplied_version() {
185        let document = build_sarif_document(SarifDocumentInput {
186            results: &[],
187            rules: &[],
188            tool_version: "1.2.3",
189        });
190
191        assert_eq!(document["version"], "2.1.0");
192        assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
193    }
194}