use serde::Serialize;
use serde_json::Value;
use crate::types::{AuditReport, Finding, RuleInfo, Severity};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLog {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub runs: Vec<SarifRun>,
}
#[derive(Debug, Serialize)]
pub struct SarifRun {
pub tool: SarifTool,
pub results: Vec<SarifResult>,
}
#[derive(Debug, Serialize)]
pub struct SarifTool {
pub driver: SarifDriver,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifDriver {
pub name: String,
pub version: String,
pub information_uri: String,
pub rules: Vec<SarifRule>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRule {
pub id: String,
pub name: String,
pub short_description: SarifMessage,
pub default_configuration: SarifConfiguration,
}
#[derive(Debug, Serialize)]
pub struct SarifConfiguration {
pub level: String,
}
#[derive(Debug, Serialize)]
pub struct SarifMessage {
pub text: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult {
pub rule_id: String,
pub level: String,
pub message: SarifMessage,
pub locations: Vec<SarifLocation>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLocation {
pub physical_location: SarifPhysicalLocation,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifPhysicalLocation {
pub artifact_location: SarifArtifactLocation,
}
#[derive(Debug, Serialize)]
pub struct SarifArtifactLocation {
pub uri: String,
#[serde(rename = "uriBaseId", skip_serializing_if = "Option::is_none")]
pub uri_base_id: Option<String>,
}
fn severity_to_level(s: Severity) -> &'static str {
match s {
Severity::Critical => "error",
Severity::Warning => "warning",
Severity::Info => "note",
}
}
fn is_absolute_source_path(source_file: &str) -> bool {
if source_file.starts_with('/') {
return true;
}
if source_file.starts_with("\\\\") || source_file.starts_with("//") {
return true;
}
let bytes = source_file.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
{
return true;
}
false
}
fn encode_path_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for byte in segment.bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'.'
| b'_'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b':'
| b'@' => out.push(byte as char),
b => {
use std::fmt::Write as _;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
fn relative_uri_from_path(path: &str) -> String {
let normalised = path.replace('\\', "/");
normalised
.split('/')
.map(encode_path_segment)
.collect::<Vec<_>>()
.join("/")
}
fn file_uri_from_path(path: &str) -> String {
let forward = path.replace('\\', "/");
let encoded = forward
.split('/')
.map(|seg| {
if seg.is_empty() || (seg.len() == 2 && seg.as_bytes()[1] == b':') {
seg.to_string()
} else {
encode_path_segment(seg)
}
})
.collect::<Vec<_>>()
.join("/");
if forward.starts_with("//") && !forward.starts_with("///") {
let unc_part = encoded.trim_start_matches('/');
format!("file://{}", unc_part)
} else {
let path_part = encoded.trim_start_matches('/');
format!("file:///{}", path_part)
}
}
pub const TOOL_NAME: &str = "sdkt";
pub const TOOL_URI: &str = "https://github.com/SaboLabs/soroban-devkit";
pub const SARIF_SCHEMA: &str =
"https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json";
pub const SARIF_VERSION: &str = "2.1.0";
pub fn to_sarif(
report: &AuditReport,
source_file: &str,
sdkt_version: &str,
rules_info: &[RuleInfo],
) -> SarifLog {
let rules = sarif_rules(report, rules_info);
let results: Vec<SarifResult> = report
.findings
.iter()
.map(|f| finding_to_result(f, source_file))
.collect();
SarifLog {
schema: SARIF_SCHEMA.to_string(),
version: SARIF_VERSION.to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: TOOL_NAME.to_string(),
version: sdkt_version.to_string(),
information_uri: TOOL_URI.to_string(),
rules,
},
},
results,
}],
}
}
pub fn to_sarif_multi_file(
report: &AuditReport,
sdkt_version: &str,
rules_info: &[RuleInfo],
) -> SarifLog {
let rules = sarif_rules(report, rules_info);
let results: Vec<SarifResult> = report
.findings
.iter()
.map(finding_to_result_for_file)
.collect();
SarifLog {
schema: SARIF_SCHEMA.to_string(),
version: SARIF_VERSION.to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: TOOL_NAME.to_string(),
version: sdkt_version.to_string(),
information_uri: TOOL_URI.to_string(),
rules,
},
},
results,
}],
}
}
fn sarif_rules(report: &AuditReport, rules_info: &[RuleInfo]) -> Vec<SarifRule> {
let mut seen_ids: Vec<String> = Vec::new();
for f in &report.findings {
if !seen_ids.contains(&f.rule_id) {
seen_ids.push(f.rule_id.clone());
}
}
seen_ids
.iter()
.map(|id| {
let info = rules_info.iter().find(|r| &r.id == id);
let (description, severity) = match info {
Some(ri) => (ri.description.clone(), ri.severity),
None => (id.clone(), Severity::Warning),
};
SarifRule {
id: id.clone(),
name: id.clone(),
short_description: SarifMessage { text: description },
default_configuration: SarifConfiguration {
level: severity_to_level(severity).to_string(),
},
}
})
.collect()
}
pub fn report_to_sarif_string(
report: &AuditReport,
source_file: &str,
sdkt_version: &str,
rules_info: &[RuleInfo],
) -> Result<String, serde_json::Error> {
let log = to_sarif(report, source_file, sdkt_version, rules_info);
serde_json::to_string(&log)
}
pub fn report_to_sarif_string_multi_file(
report: &AuditReport,
sdkt_version: &str,
rules_info: &[RuleInfo],
) -> Result<String, serde_json::Error> {
let log = to_sarif_multi_file(report, sdkt_version, rules_info);
serde_json::to_string(&log)
}
fn finding_to_result(f: &Finding, source_file: &str) -> SarifResult {
let message_text = match &f.location {
Some(loc) => format!("{} ({})", f.message, loc),
None => f.message.clone(),
};
let is_absolute = is_absolute_source_path(source_file);
let (uri, uri_base_id) = if is_absolute {
(file_uri_from_path(source_file), None)
} else {
(
relative_uri_from_path(source_file),
Some("%SRCROOT%".to_string()),
)
};
SarifResult {
rule_id: f.rule_id.clone(),
level: severity_to_level(f.severity).to_string(),
message: SarifMessage { text: message_text },
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation { uri, uri_base_id },
},
}],
}
}
fn finding_to_result_for_file(f: &Finding) -> SarifResult {
let source_file = f
.file
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("unknown.rs");
let message_text = match &f.location {
Some(loc) => format!("{} ({})", f.message, loc),
None => f.message.clone(),
};
let is_absolute = is_absolute_source_path(source_file);
let (uri, uri_base_id) = if is_absolute {
(file_uri_from_path(source_file), None)
} else {
(
relative_uri_from_path(source_file),
Some("%SRCROOT%".to_string()),
)
};
SarifResult {
rule_id: f.rule_id.clone(),
level: severity_to_level(f.severity).to_string(),
message: SarifMessage { text: message_text },
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation { uri, uri_base_id },
},
}],
}
}
pub fn sarif_to_value(log: &SarifLog) -> Result<Value, serde_json::Error> {
serde_json::to_value(log)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AuditReport, Finding, RuleInfo, Severity};
fn rule_info(id: &str, sev: Severity, desc: &str) -> RuleInfo {
RuleInfo {
id: id.to_string(),
severity: sev,
description: desc.to_string(),
}
}
fn finding(rule_id: &str, sev: Severity, msg: &str, loc: Option<&str>) -> Finding {
Finding {
rule_id: rule_id.to_string(),
severity: sev,
message: msg.to_string(),
file: None,
location: loc.map(str::to_string),
}
}
fn make_rules() -> Vec<RuleInfo> {
vec![
rule_info(
"AUTH-001",
Severity::Critical,
"Missing require_auth() in privileged function",
),
rule_info(
"AUTH-003",
Severity::Warning,
"Unguarded initialize function",
),
rule_info("MOVE-001", Severity::Info, "Suspicious move-after-use"),
rule_info(
"MATH-001",
Severity::Warning,
"Division before multiplication may truncate numeric precision",
),
]
}
#[test]
fn critical_maps_to_error() {
assert_eq!(severity_to_level(Severity::Critical), "error");
}
#[test]
fn warning_maps_to_warning() {
assert_eq!(severity_to_level(Severity::Warning), "warning");
}
#[test]
fn info_maps_to_note() {
assert_eq!(severity_to_level(Severity::Info), "note");
}
#[test]
fn empty_report_produces_valid_sarif_with_zero_results() {
let report = AuditReport::default();
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
assert_eq!(log.version, "2.1.0");
assert_eq!(log.schema, SARIF_SCHEMA);
assert_eq!(log.runs.len(), 1);
let run = &log.runs[0];
assert!(run.results.is_empty(), "no results for empty report");
assert!(
run.tool.driver.rules.is_empty(),
"no rules section for empty report"
);
}
#[test]
fn empty_report_serialises_to_valid_json() {
let report = AuditReport::default();
let json = report_to_sarif_string(&report, "src/lib.rs", "2.5.0", &make_rules())
.expect("serialization succeeds");
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(v["version"], "2.1.0");
let results = v["runs"][0]["results"].as_array().unwrap();
assert!(results.is_empty());
}
#[test]
fn critical_finding_emits_error_level() {
let mut report = AuditReport::default();
report.add(finding(
"AUTH-001",
Severity::Critical,
"Missing require_auth",
Some("mint_token"),
));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
let result = &log.runs[0].results[0];
assert_eq!(result.rule_id, "AUTH-001");
assert_eq!(result.level, "error");
}
#[test]
fn warning_finding_emits_warning_level() {
let mut report = AuditReport::default();
report.add(finding(
"AUTH-003",
Severity::Warning,
"Unguarded initialize",
None,
));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
assert_eq!(log.runs[0].results[0].level, "warning");
}
#[test]
fn info_finding_emits_note_level() {
let mut report = AuditReport::default();
report.add(finding("MOVE-001", Severity::Info, "Possible reuse", None));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
assert_eq!(log.runs[0].results[0].level, "note");
}
#[test]
fn rules_section_contains_each_unique_rule_id() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
report.add(finding("AUTH-001", Severity::Critical, "msg2", None)); report.add(finding("AUTH-003", Severity::Warning, "msg3", None));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
let rule_ids: Vec<&str> = log.runs[0]
.tool
.driver
.rules
.iter()
.map(|r| r.id.as_str())
.collect();
assert_eq!(rule_ids, vec!["AUTH-001", "AUTH-003"]);
}
#[test]
fn rule_description_comes_from_rules_info() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
let rule = &log.runs[0].tool.driver.rules[0];
assert_eq!(
rule.short_description.text,
"Missing require_auth() in privileged function"
);
}
#[test]
fn unknown_rule_id_gets_synthetic_entry() {
let mut report = AuditReport::default();
report.add(finding("CUSTOM-999", Severity::Warning, "custom msg", None));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &[]);
let rule = &log.runs[0].tool.driver.rules[0];
assert_eq!(rule.id, "CUSTOM-999");
assert_eq!(rule.short_description.text, "CUSTOM-999");
}
#[test]
fn result_artifact_uri_relative_path_has_srcroot_base_id() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let log = to_sarif(
&report,
"contracts/token/src/lib.rs",
"2.5.0",
&make_rules(),
);
let loc = &log.runs[0].results[0].locations[0];
assert_eq!(
loc.physical_location.artifact_location.uri,
"contracts/token/src/lib.rs"
);
assert_eq!(
loc.physical_location.artifact_location.uri_base_id,
Some("%SRCROOT%".to_string())
);
}
#[test]
fn multi_file_result_artifact_uris_follow_each_finding_file() {
let mut report = AuditReport::default();
report.add(Finding {
rule_id: "AUTH-001".to_string(),
severity: Severity::Critical,
message: "msg a".to_string(),
file: Some("a.rs".to_string()),
location: None,
});
report.add(Finding {
rule_id: "AUTH-003".to_string(),
severity: Severity::Warning,
message: "msg b".to_string(),
file: Some("b.rs".to_string()),
location: None,
});
let log = to_sarif_multi_file(&report, "2.5.0", &make_rules());
let uris: Vec<&str> = log.runs[0]
.results
.iter()
.map(|r| {
r.locations[0]
.physical_location
.artifact_location
.uri
.as_str()
})
.collect();
assert!(uris.iter().any(|uri| uri.contains("a.rs")));
assert!(uris.iter().any(|uri| uri.contains("b.rs")));
}
#[test]
fn result_artifact_uri_unix_absolute_path_becomes_file_uri_no_base_id() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let log = to_sarif(&report, "/home/user/contracts/lib.rs", "2.5.0", &[]);
let loc = &log.runs[0].results[0].locations[0];
assert_eq!(
loc.physical_location.artifact_location.uri,
"file:///home/user/contracts/lib.rs"
);
assert_eq!(
loc.physical_location.artifact_location.uri_base_id, None,
"absolute paths must not carry uriBaseId"
);
}
#[test]
fn result_artifact_uri_windows_absolute_path_becomes_file_uri_no_base_id() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let log = to_sarif(&report, "C:\\Users\\dev\\contracts\\lib.rs", "2.5.0", &[]);
let loc = &log.runs[0].results[0].locations[0];
let uri = &loc.physical_location.artifact_location.uri;
assert!(
uri.starts_with("file://"),
"Windows path must become file: URI"
);
assert!(
uri.contains("contracts/lib.rs"),
"path segments must be preserved"
);
assert_eq!(
loc.physical_location.artifact_location.uri_base_id, None,
"absolute paths must not carry uriBaseId"
);
}
#[test]
fn encode_path_segment_passthrough_unreserved() {
assert_eq!(encode_path_segment("lib.rs"), "lib.rs");
assert_eq!(encode_path_segment("my-contract_v1~"), "my-contract_v1~");
}
#[test]
fn encode_path_segment_encodes_space_and_percent() {
assert_eq!(encode_path_segment("my file"), "my%20file");
assert_eq!(encode_path_segment("100%"), "100%25");
}
#[test]
fn encode_path_segment_encodes_hash_query_brackets() {
assert_eq!(encode_path_segment("a#b"), "a%23b");
assert_eq!(encode_path_segment("a?b"), "a%3Fb");
assert_eq!(encode_path_segment("a[b]"), "a%5Bb%5D");
}
#[test]
fn relative_uri_from_path_preserves_slashes() {
assert_eq!(
relative_uri_from_path("contracts/token/src/lib.rs"),
"contracts/token/src/lib.rs"
);
}
#[test]
fn relative_uri_from_path_encodes_spaces_in_segments() {
assert_eq!(
relative_uri_from_path("my contracts/token lib.rs"),
"my%20contracts/token%20lib.rs"
);
}
#[test]
fn relative_uri_from_path_normalises_backslashes() {
assert_eq!(
relative_uri_from_path("contracts\\token\\lib.rs"),
"contracts/token/lib.rs"
);
}
#[test]
fn is_absolute_source_path_unix() {
assert!(is_absolute_source_path("/home/user/lib.rs"));
assert!(!is_absolute_source_path("contracts/lib.rs"));
}
#[test]
fn is_absolute_source_path_windows() {
assert!(is_absolute_source_path("C:\\Users\\dev\\lib.rs"));
assert!(is_absolute_source_path("D:/projects/lib.rs"));
assert!(!is_absolute_source_path("contracts/lib.rs"));
}
#[test]
fn file_uri_from_path_unix_encodes_spaces() {
assert_eq!(
file_uri_from_path("/home/my user/lib.rs"),
"file:///home/my%20user/lib.rs"
);
}
#[test]
fn file_uri_from_path_windows_produces_triple_slash() {
let uri = file_uri_from_path("C:\\Users\\dev\\lib.rs");
assert!(
uri.starts_with("file:///"),
"drive path needs empty authority"
);
assert!(uri.contains("C:"));
assert!(!uri.contains('\\'), "backslashes must be converted");
}
#[test]
fn is_absolute_source_path_unc() {
assert!(is_absolute_source_path("\\\\server\\share\\file.rs"));
assert!(is_absolute_source_path("//server/share/file.rs"));
assert!(!is_absolute_source_path("contracts/lib.rs"));
}
#[test]
fn file_uri_from_path_unc_preserves_server_as_authority() {
assert_eq!(
file_uri_from_path("\\\\server\\share\\a b.rs"),
"file://server/share/a%20b.rs"
);
}
#[test]
fn file_uri_from_path_unc_forward_slash_form() {
assert_eq!(
file_uri_from_path("//server/share/lib.rs"),
"file://server/share/lib.rs"
);
}
#[test]
fn file_uri_from_path_triple_slash_unix_is_local_not_unc() {
assert_eq!(
file_uri_from_path("///home/user/lib.rs"),
"file:///home/user/lib.rs"
);
}
#[test]
fn result_artifact_uri_unc_path_becomes_file_uri_no_base_id() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let log = to_sarif(&report, "\\\\srv\\share\\lib.rs", "2.5.0", &[]);
let loc = &log.runs[0].results[0].locations[0];
let uri = &loc.physical_location.artifact_location.uri;
assert!(
uri.starts_with("file://srv"),
"UNC server must be URI authority"
);
assert_eq!(
loc.physical_location.artifact_location.uri_base_id, None,
"UNC paths must not carry uriBaseId"
);
}
#[test]
fn location_string_appended_to_message_text() {
let mut report = AuditReport::default();
report.add(finding(
"AUTH-001",
Severity::Critical,
"Missing auth",
Some("mint_token"),
));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
let msg = &log.runs[0].results[0].message.text;
assert!(msg.contains("Missing auth"), "base message present");
assert!(msg.contains("mint_token"), "location appended");
}
#[test]
fn no_location_message_unchanged() {
let mut report = AuditReport::default();
report.add(finding(
"AUTH-001",
Severity::Critical,
"Missing auth",
None,
));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
assert_eq!(log.runs[0].results[0].message.text, "Missing auth");
}
#[test]
fn all_severities_map_correctly_in_one_report() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "c", None));
report.add(finding("AUTH-003", Severity::Warning, "w", None));
report.add(finding("MOVE-001", Severity::Info, "i", None));
let log = to_sarif(&report, "src/lib.rs", "2.5.0", &make_rules());
let levels: Vec<&str> = log.runs[0]
.results
.iter()
.map(|r| r.level.as_str())
.collect();
assert_eq!(levels, vec!["error", "warning", "note"]);
}
#[test]
fn tool_driver_name_and_version() {
let report = AuditReport::default();
let log = to_sarif(&report, "src/lib.rs", "1.2.3", &[]);
let driver = &log.runs[0].tool.driver;
assert_eq!(driver.name, "sdkt");
assert_eq!(driver.version, "1.2.3");
assert_eq!(driver.information_uri, TOOL_URI);
}
#[test]
fn json_field_names_are_camel_case() {
let mut report = AuditReport::default();
report.add(finding("AUTH-001", Severity::Critical, "msg", None));
let json = report_to_sarif_string(&report, "src/lib.rs", "2.5.0", &make_rules())
.expect("serialization");
assert!(json.contains("\"ruleId\""), "ruleId field");
assert!(
json.contains("\"physicalLocation\""),
"physicalLocation field"
);
assert!(
json.contains("\"artifactLocation\""),
"artifactLocation field"
);
assert!(json.contains("\"uriBaseId\""), "uriBaseId field");
assert!(json.contains("\"informationUri\""), "informationUri field");
assert!(
json.contains("\"shortDescription\""),
"shortDescription field"
);
assert!(
json.contains("\"defaultConfiguration\""),
"defaultConfiguration field"
);
}
#[test]
fn schema_and_version_present_in_json() {
let report = AuditReport::default();
let json = report_to_sarif_string(&report, "src/lib.rs", "2.5.0", &[]).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["$schema"], SARIF_SCHEMA);
assert_eq!(v["version"], "2.1.0");
}
}