use std::collections::BTreeMap;
use std::io::{self, Write};
use serde_json::{Value, json};
use crate::models::ComplianceAlert;
use crate::output_schema::{Output, OutputFileInfo};
use super::shared::io_other;
const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
const INFORMATION_URI: &str = "https://github.com/getprovenant/provenant";
pub(crate) fn write_sarif(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
let sarif = build_sarif(output);
serde_json::to_writer_pretty(&mut *writer, &sarif).map_err(io_other)?;
writer.write_all(b"\n")
}
fn level_str(alert: ComplianceAlert) -> &'static str {
match alert {
ComplianceAlert::Error => "error",
ComplianceAlert::Warning => "warning",
}
}
fn build_sarif(output: &Output) -> Value {
let tool_version = output
.headers
.first()
.map(|header| header.tool_version.clone())
.unwrap_or_default();
let mut rule_map: BTreeMap<String, (String, ComplianceAlert)> = BTreeMap::new();
let mut results: Vec<Value> = Vec::new();
for file in &output.files {
let Some(policy_entries) = &file.license_policy else {
continue;
};
for entry in policy_entries {
let Some(alert) = entry.compliance_alert else {
continue;
};
rule_map
.entry(entry.license_key.clone())
.and_modify(|(_, level)| {
if alert > *level {
*level = alert;
}
})
.or_insert((entry.label.clone(), alert));
let mut physical_location = json!({
"artifactLocation": { "uri": path_to_uri(&file.path) }
});
if let Some((start_line, end_line)) = region_for_key(file, &entry.license_key) {
physical_location["region"] = json!({
"startLine": start_line,
"endLine": end_line,
});
}
let label = display_label(&entry.label, &entry.license_key);
results.push(json!({
"ruleId": entry.license_key,
"level": level_str(alert),
"message": {
"text": format!("{label}: license `{}` detected in {}", entry.license_key, file.path)
},
"locations": [ { "physicalLocation": physical_location } ]
}));
}
}
let rules: Vec<Value> = rule_map
.iter()
.map(|(key, (label, level))| {
json!({
"id": key,
"name": key,
"shortDescription": { "text": display_label(label, key) },
"defaultConfiguration": { "level": level_str(*level) }
})
})
.collect();
json!({
"$schema": SARIF_SCHEMA,
"version": "2.1.0",
"runs": [ {
"tool": {
"driver": {
"name": "Provenant",
"informationUri": INFORMATION_URI,
"version": tool_version,
"rules": rules
}
},
"results": results
} ]
})
}
fn display_label<'a>(label: &'a str, license_key: &'a str) -> &'a str {
if label.is_empty() { license_key } else { label }
}
fn path_to_uri(path: &str) -> String {
let mut uri = String::with_capacity(path.len());
for &byte in path.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
uri.push(byte as char);
}
_ => uri.push_str(&format!("%{byte:02X}")),
}
}
uri
}
fn region_for_key(file: &OutputFileInfo, license_key: &str) -> Option<(u64, u64)> {
for detection in &file.license_detections {
for license_match in &detection.matches {
if expression_has_key(&license_match.license_expression, license_key) {
return Some((license_match.start_line, license_match.end_line));
}
}
}
None
}
fn expression_has_key(expression: &str, license_key: &str) -> bool {
expression
.split(|character: char| character.is_whitespace() || character == '(' || character == ')')
.any(|token| token == license_key)
}
#[cfg(test)]
mod tests {
use super::expression_has_key;
#[test]
fn expression_has_key_matches_whole_tokens_only() {
assert!(expression_has_key("gpl-3.0", "gpl-3.0"));
assert!(expression_has_key("gpl-3.0 OR mit", "mit"));
assert!(expression_has_key("(apache-2.0 AND gpl-3.0)", "gpl-3.0"));
assert!(expression_has_key(
"gpl-2.0 WITH classpath-exception-2.0",
"gpl-2.0"
));
assert!(!expression_has_key("lgpl-3.0", "gpl-3.0"));
assert!(!expression_has_key("mit OR apache-2.0", "gpl-3.0"));
}
}