use std::path::{Path, PathBuf};
use rustc_hash::FxHashMap;
use serde_json::Value;
use crate::codeclimate::codeclimate_fingerprint_hash;
pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
#[derive(Debug, Clone, Copy)]
pub struct SarifResultInput<'a> {
pub rule_id: &'a str,
pub level: &'a str,
pub message: &'a str,
pub uri: &'a str,
pub region: Option<(u32, u32)>,
pub snippet: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct SarifFindingInput<'a> {
pub issue_code: &'a str,
pub rule_id: &'a str,
pub level: &'a str,
pub message: &'a str,
pub uri: &'a str,
pub region: Option<(u32, u32)>,
pub snippet: Option<&'a str>,
pub properties: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct SarifFindingFields {
pub rule_id: &'static str,
pub level: &'static str,
pub message: String,
pub uri: String,
pub region: Option<(u32, u32)>,
pub source_path: Option<PathBuf>,
pub properties: Option<Value>,
}
#[derive(Debug, Clone, Copy)]
pub struct SarifRuleInput<'a> {
pub id: &'a str,
pub short_description: &'a str,
pub level: &'a str,
pub full_description: Option<&'a str>,
pub help_uri: Option<&'a str>,
}
#[derive(Debug, Clone, Copy)]
pub struct SarifDocumentInput<'a> {
pub results: &'a [Value],
pub rules: &'a [Value],
pub tool_version: &'a str,
}
#[must_use]
pub fn normalize_sarif_snippet(snippet: &str) -> String {
snippet
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str) -> String {
let normalized = normalize_sarif_snippet(snippet);
codeclimate_fingerprint_hash(&[rule_id, path, &normalized])
}
#[derive(Debug, Default)]
pub struct SarifSourceSnippetCache {
files: FxHashMap<PathBuf, Vec<String>>,
}
impl SarifSourceSnippetCache {
pub fn line(&mut self, path: &Path, line: u32) -> Option<String> {
if line == 0 {
return None;
}
if !self.files.contains_key(path) {
let lines = std::fs::read_to_string(path)
.ok()
.map(|source| source.lines().map(str::to_owned).collect())
.unwrap_or_default();
self.files.insert(path.to_path_buf(), lines);
}
self.files
.get(path)
.and_then(|lines| lines.get(line.saturating_sub(1) as usize))
.cloned()
}
}
#[must_use]
pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
let mut physical_location = serde_json::json!({
"artifactLocation": { "uri": input.uri }
});
if let Some((line, col)) = input.region {
physical_location["region"] = serde_json::json!({
"startLine": line,
"startColumn": col
});
}
let line = input
.region
.map_or_else(String::new, |(line, _)| line.to_string());
let col = input
.region
.map_or_else(String::new, |(_, col)| col.to_string());
let normalized_snippet = input
.snippet
.map(normalize_sarif_snippet)
.filter(|snippet| !snippet.is_empty());
let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
|| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
|snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet]),
);
let partial_fingerprint_ghas = partial_fingerprint.clone();
serde_json::json!({
"ruleId": input.rule_id,
"level": input.level,
"message": { "text": input.message },
"locations": [{ "physicalLocation": physical_location }],
"partialFingerprints": {
SARIF_FINGERPRINT_KEY: partial_fingerprint,
GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
}
})
}
#[must_use]
pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
let mut result = build_sarif_result(SarifResultInput {
rule_id: input.rule_id,
level: input.level,
message: input.message,
uri: input.uri,
region: input.region,
snippet: input.snippet,
});
if let Some(properties) = input.properties {
result["properties"] = properties;
}
result
}
#[must_use]
pub fn build_sarif_result_with_snippet(
rule_id: &str,
level: &str,
message: &str,
uri: &str,
region: Option<(u32, u32)>,
snippet: Option<&str>,
) -> Value {
build_sarif_result(SarifResultInput {
rule_id,
level,
message,
uri,
region,
snippet,
})
}
pub fn append_sarif_findings<T>(
sarif_results: &mut Vec<Value>,
items: &[T],
snippets: &mut SarifSourceSnippetCache,
mut extract: impl FnMut(&T) -> SarifFindingFields,
) {
for item in items {
let fields = extract(item);
let source_snippet = fields
.source_path
.as_deref()
.zip(fields.region)
.and_then(|(path, (line, _))| snippets.line(path, line));
let result = build_sarif_finding(SarifFindingInput {
issue_code: issue_code_from_rule_id(fields.rule_id),
rule_id: fields.rule_id,
level: fields.level,
message: &fields.message,
uri: &fields.uri,
region: fields.region,
snippet: source_snippet.as_deref(),
properties: fields.properties,
});
sarif_results.push(result);
}
}
#[must_use]
pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
let mut rule = serde_json::Map::new();
rule.insert("id".to_string(), serde_json::json!(input.id));
rule.insert(
"shortDescription".to_string(),
serde_json::json!({ "text": input.short_description }),
);
if let Some(full_description) = input.full_description {
rule.insert(
"fullDescription".to_string(),
serde_json::json!({ "text": full_description }),
);
}
if let Some(help_uri) = input.help_uri {
rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
}
rule.insert(
"defaultConfiguration".to_string(),
serde_json::json!({ "level": input.level }),
);
Value::Object(rule)
}
fn issue_code_from_rule_id(rule_id: &str) -> &str {
rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
}
#[must_use]
pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
serde_json::json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "fallow",
"version": input.tool_version,
"informationUri": "https://github.com/fallow-rs/fallow",
"rules": input.rules
}
},
"results": input.results
}]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sarif_result_includes_location_and_fingerprints() {
let result = build_sarif_result(SarifResultInput {
rule_id: "fallow/test",
level: "warning",
message: "description",
uri: "src/app.ts",
region: Some((7, 3)),
snippet: Some(" export const value = 1; "),
});
assert_eq!(result["ruleId"], "fallow/test");
assert_eq!(
result["locations"][0]["physicalLocation"]["region"]["startLine"],
7
);
assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
}
#[test]
fn sarif_finding_includes_custom_properties() {
let finding = build_sarif_finding(SarifFindingInput {
issue_code: "unused-export",
rule_id: "fallow/unused-export",
level: "warning",
message: "Export is never imported",
uri: "src/app.ts",
region: Some((3, 14)),
snippet: Some("export const unused = 1;"),
properties: Some(serde_json::json!({ "is_re_export": true })),
});
assert_eq!(finding["ruleId"], "fallow/unused-export");
assert_eq!(finding["properties"]["is_re_export"], true);
assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
}
#[test]
fn sarif_finding_omits_empty_properties() {
let finding = build_sarif_finding(SarifFindingInput {
issue_code: "unused-file",
rule_id: "fallow/unused-file",
level: "error",
message: "File is unreachable",
uri: "src/unused.ts",
region: None,
snippet: None,
properties: None,
});
assert!(finding.get("properties").is_none());
}
#[test]
fn append_sarif_findings_attaches_snippet_and_properties() {
let temp = tempfile::tempdir().expect("tempdir");
let source = temp.path().join("src.ts");
std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
let mut snippets = SarifSourceSnippetCache::default();
let mut results = Vec::new();
append_sarif_findings(
&mut results,
std::slice::from_ref(&source),
&mut snippets,
|path| SarifFindingFields {
rule_id: "fallow/unused-export",
level: "warning",
message: "Export is never imported".to_string(),
uri: "src.ts".to_string(),
region: Some((2, 1)),
source_path: Some(path.clone()),
properties: Some(serde_json::json!({ "is_re_export": true })),
},
);
assert_eq!(results.len(), 1);
assert_eq!(results[0]["ruleId"], "fallow/unused-export");
assert_eq!(results[0]["properties"]["is_re_export"], true);
assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
}
#[test]
fn sarif_rule_omits_optional_docs_when_absent() {
let rule = build_sarif_rule(SarifRuleInput {
id: "fallow/test",
short_description: "short",
level: "warning",
full_description: None,
help_uri: None,
});
assert!(rule.get("fullDescription").is_none());
assert!(rule.get("helpUri").is_none());
}
#[test]
fn sarif_document_uses_supplied_version() {
let document = build_sarif_document(SarifDocumentInput {
results: &[],
rules: &[],
tool_version: "1.2.3",
});
assert_eq!(document["version"], "2.1.0");
assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
}
}