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,
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#[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#[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#[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#[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#[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#[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#[derive(Debug, Default)]
88pub struct SarifSourceSnippetCache {
89 files: FxHashMap<PathBuf, Vec<String>>,
90}
91
92impl SarifSourceSnippetCache {
93 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#[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#[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#[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
191pub 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#[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#[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}