1use serde_json::Value;
2
3use crate::codeclimate::codeclimate_fingerprint_hash;
4
5pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
7
8pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
10
11#[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#[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#[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#[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#[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#[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#[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#[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}