use crate::report::{Category, CheckFinding, CheckReport};
use deps_core::diagnostic::Severity;
use deps_core::osv::{VulnSeverity, is_valid_osv_id, validated_osv_url};
use deps_core::position::Range;
use serde_sarif::sarif::{
ArtifactLocation, Location, MultiformatMessageString, PhysicalLocation, PropertyBag, Region,
ReportingDescriptor, Result as SarifResult, ResultLevel, Run, RunAutomationDetails, SCHEMA_URL,
Sarif, Tool, ToolComponent, Version,
};
use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path};
#[must_use]
pub fn to_sarif(report: &CheckReport) -> Sarif {
let contexts = collect_result_contexts(&report.findings);
let rule_meta = collect_rule_meta(&report.findings, &contexts);
let rule_indices: HashMap<&str, usize> = rule_meta
.keys()
.enumerate()
.map(|(index, id)| (id.as_str(), index))
.collect();
let rules: Vec<ReportingDescriptor> = rule_meta
.iter()
.map(|(id, meta)| build_rule_descriptor(id, meta))
.collect();
let results: Vec<SarifResult> = report
.findings
.iter()
.zip(&contexts)
.map(|(finding, context)| {
let rule_index = rule_indices
.get(context.rule_id.as_str())
.copied()
.unwrap_or_default();
to_sarif_result(finding, rule_index, context)
})
.collect();
let driver = ToolComponent::builder()
.name("deps-cli")
.version(env!("CARGO_PKG_VERSION"))
.information_uri(env!("CARGO_PKG_REPOSITORY"))
.rules(rules)
.build();
let automation_details = RunAutomationDetails::builder().id(automation_id()).build();
let run = Run::builder()
.tool(Tool::from(driver))
.results(results)
.automation_details(automation_details)
.build();
Sarif::builder()
.version(Version::V2_1_0.to_string())
.schema(SCHEMA_URL)
.runs(vec![run])
.build()
}
fn is_advisory_finding(finding: &CheckFinding) -> bool {
finding.category == Category::Vulnerable && finding.code.as_deref().is_some_and(is_valid_osv_id)
}
fn sarif_rule_id(finding: &CheckFinding) -> &str {
if is_advisory_finding(finding) {
finding
.code
.as_deref()
.unwrap_or_else(|| finding.category.as_str())
} else {
finding.category.as_str()
}
}
struct RuleMeta<'a> {
category: Category,
is_advisory: bool,
message: &'a str,
advisory_url: Option<String>,
advisory_severity: Option<VulnSeverity>,
}
fn collect_rule_meta<'a>(
findings: &'a [CheckFinding],
contexts: &[ResultContext],
) -> BTreeMap<String, RuleMeta<'a>> {
let mut rules = BTreeMap::new();
for (finding, context) in findings.iter().zip(contexts) {
rules
.entry(context.rule_id.clone())
.or_insert_with(|| RuleMeta {
category: finding.category,
is_advisory: is_advisory_finding(finding),
message: finding.message.as_str(),
advisory_url: finding.advisory_url.clone(),
advisory_severity: finding.advisory_severity,
});
}
rules
}
fn build_rule_descriptor(id: &str, meta: &RuleMeta<'_>) -> ReportingDescriptor {
let is_advisory = meta.is_advisory;
let short_description = MultiformatMessageString::builder()
.text(meta.category.description())
.build();
let (help_uri, full_description) = if is_advisory {
let help_uri = meta.advisory_url.clone().or_else(|| validated_osv_url(id));
let full_description = MultiformatMessageString::builder()
.text(meta.message.to_string())
.build();
(help_uri, Some(full_description))
} else {
(None, None)
};
let properties = if is_advisory {
meta.advisory_severity
.and_then(security_severity_score)
.map(|score| {
let mut additional_properties = BTreeMap::new();
additional_properties.insert(
"security-severity".to_string(),
serde_json::Value::String(score.to_string()),
);
PropertyBag::builder()
.additional_properties(additional_properties)
.build()
})
} else {
None
};
let name = if is_advisory {
id.to_string()
} else {
meta.category.as_str().to_string()
};
ReportingDescriptor {
default_configuration: None,
deprecated_guids: None,
deprecated_ids: None,
deprecated_names: None,
full_description,
guid: None,
help: None,
help_uri,
id: id.to_string(),
message_strings: None,
name: Some(name),
properties,
relationships: None,
short_description: Some(short_description),
}
}
fn security_severity_score(severity: VulnSeverity) -> Option<&'static str> {
match severity {
VulnSeverity::Malicious => Some("10.0"),
VulnSeverity::Critical => Some("9.5"),
VulnSeverity::High => Some("8.0"),
VulnSeverity::Medium => Some("5.5"),
VulnSeverity::Low => Some("2.0"),
VulnSeverity::Unknown | VulnSeverity::Informational => None,
_ => None,
}
}
fn automation_id() -> String {
automation_id_with_env(|name| std::env::var(name).ok())
}
fn automation_id_with_env(env: impl Fn(&str) -> Option<String>) -> String {
let Some(run_id) = env("GITHUB_RUN_ID") else {
return "deps-cli/local".to_string();
};
let workflow = env("GITHUB_WORKFLOW").unwrap_or_default();
let job = env("GITHUB_JOB").unwrap_or_default();
let run = match env("GITHUB_RUN_ATTEMPT") {
Some(attempt) => format!("{run_id}-{attempt}"),
None => run_id,
};
format!("deps-cli/{workflow}/{job}/{run}")
}
struct ResultContext {
manifest_uri: String,
rule_id: String,
fingerprint: String,
}
fn collect_result_contexts(findings: &[CheckFinding]) -> Vec<ResultContext> {
let mut seen: HashMap<(String, &str, &str), usize> = HashMap::new();
findings
.iter()
.map(|finding| {
let manifest = manifest_uri(&finding.manifest_path);
let dependency = finding.dependency_name.as_deref().unwrap_or("");
let rule_id = sarif_rule_id(finding);
let counter = seen
.entry((manifest.clone(), dependency, rule_id))
.or_insert(0);
let ordinal = *counter;
*counter += 1;
let fingerprint = format!(
"{manifest}|{}|{}|{ordinal}",
urlencoding::encode(dependency),
urlencoding::encode(rule_id),
);
ResultContext {
manifest_uri: manifest,
rule_id: rule_id.to_string(),
fingerprint,
}
})
.collect()
}
fn to_sarif_result(
finding: &CheckFinding,
rule_index: usize,
context: &ResultContext,
) -> SarifResult {
let region = to_sarif_region(finding.range);
let artifact_location = ArtifactLocation::builder()
.uri(context.manifest_uri.as_str())
.build();
let physical_location = PhysicalLocation::builder()
.artifact_location(artifact_location)
.region(region)
.build();
let location = Location::builder()
.physical_location(physical_location)
.build();
let mut partial_fingerprints = BTreeMap::new();
partial_fingerprints.insert("depsCli/v1".to_string(), context.fingerprint.clone());
SarifResult::builder()
.rule_id(context.rule_id.as_str())
.rule_index(i64::try_from(rule_index).unwrap_or(i64::MAX))
.message(finding.message.as_str())
.locations(vec![location])
.level(to_result_level(finding.severity))
.partial_fingerprints(partial_fingerprints)
.build()
}
fn manifest_uri(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => {
Some(urlencoding::encode(&part.to_string_lossy()).into_owned())
}
Component::CurDir => Some(".".to_string()),
Component::ParentDir => Some("..".to_string()),
Component::RootDir | Component::Prefix(_) => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn to_sarif_region(range: Range) -> Region {
Region::builder()
.start_line(i64::from(range.start.line) + 1)
.start_column(i64::from(range.start.character) + 1)
.end_line(i64::from(range.end.line) + 1)
.end_column(i64::from(range.end.character) + 1)
.build()
}
fn to_result_level(severity: Severity) -> ResultLevel {
match severity {
Severity::Error => ResultLevel::Error,
Severity::Warning => ResultLevel::Warning,
Severity::Information | Severity::Hint => ResultLevel::Note,
}
}
pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(&to_sarif(report))
}
#[cfg(test)]
mod tests {
use super::*;
use deps_core::EcosystemId;
use deps_core::position::Position;
use std::path::PathBuf;
fn finding(category: Category, severity: Severity) -> CheckFinding {
CheckFinding {
ecosystem: EcosystemId::Cargo,
manifest_path: PathBuf::from("Cargo.toml"),
dependency_name: Some("serde".to_string()),
requirement: Some("1.0".to_string()),
category,
code: None,
advisory_url: None,
advisory_severity: None,
severity,
range: Range::new(Position::new(4, 0), Position::new(4, 10)),
message: "Newer version available: 1.1.0".to_string(),
}
}
fn finding_with_code(category: Category, code: &str, message: &str) -> CheckFinding {
CheckFinding {
code: Some(code.to_string()),
message: message.to_string(),
..finding(category, Severity::Warning)
}
}
#[test]
fn test_to_sarif_empty_report_has_one_empty_run() {
let sarif = to_sarif(&CheckReport::default());
assert_eq!(sarif.runs.len(), 1);
assert!(sarif.runs[0].results.as_ref().unwrap().is_empty());
assert!(sarif.runs[0].tool.driver.rules.as_ref().unwrap().is_empty());
}
#[test]
fn test_to_sarif_sets_tool_driver_name() {
let sarif = to_sarif(&CheckReport::default());
assert_eq!(sarif.runs[0].tool.driver.name, "deps-cli");
}
#[test]
fn test_to_sarif_rule_id_matches_category_token() {
let report = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "outdated");
let results = sarif.runs[0].results.as_ref().unwrap();
assert_eq!(results[0].rule_id.as_deref(), Some("outdated"));
assert_eq!(results[0].rule_index, Some(0));
}
#[test]
fn test_to_sarif_deduplicates_rules_across_findings() {
let report = CheckReport {
findings: vec![
finding(Category::Outdated, Severity::Hint),
finding(Category::Outdated, Severity::Hint),
finding(Category::Vulnerable, Severity::Error),
],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules.len(), 2);
}
#[test]
fn test_to_sarif_translates_range_to_one_based_region() {
let report = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let sarif = to_sarif(&report);
let locations = sarif.runs[0].results.as_ref().unwrap()[0]
.locations
.as_ref()
.unwrap();
let region = locations[0]
.physical_location
.as_ref()
.unwrap()
.region
.as_ref()
.unwrap();
assert_eq!(region.start_line, Some(5));
assert_eq!(region.start_column, Some(1));
assert_eq!(region.end_line, Some(5));
assert_eq!(region.end_column, Some(11));
}
#[test]
fn test_to_sarif_artifact_uri_matches_manifest_path() {
let report = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let sarif = to_sarif(&report);
let locations = sarif.runs[0].results.as_ref().unwrap()[0]
.locations
.as_ref()
.unwrap();
let artifact_location = locations[0]
.physical_location
.as_ref()
.unwrap()
.artifact_location
.as_ref()
.unwrap();
assert_eq!(artifact_location.uri.as_deref(), Some("Cargo.toml"));
}
#[test]
fn test_manifest_uri_percent_encodes_hash_and_space() {
let path = Path::new("a b#c%20d").join("Cargo.toml");
let uri = manifest_uri(&path);
assert_eq!(uri, "a%20b%23c%2520d/Cargo.toml");
assert!(
!uri.contains('#'),
"a literal '#' would be read as a URI fragment separator"
);
assert!(
!uri.contains(' '),
"a literal space is not valid in a bare URI-reference"
);
}
#[test]
fn test_manifest_uri_joins_nested_components_with_forward_slash() {
let path = Path::new("crates").join("deps-cli").join("Cargo.toml");
assert_eq!(manifest_uri(&path), "crates/deps-cli/Cargo.toml");
}
#[cfg(unix)]
#[test]
fn test_manifest_uri_drops_leading_root_dir_for_an_absolute_unix_path() {
let path = Path::new("/tmp/deps-cli-manual-test/Cargo.toml");
assert!(
path.is_absolute(),
"test setup bug: fixture path must be absolute"
);
let uri = manifest_uri(path);
assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
assert!(
!Path::new(&uri).is_absolute(),
"an absolute manifest_path must not leak into an absolute artifactLocation.uri \
(spec 062 review R1)"
);
}
#[cfg(windows)]
#[test]
fn test_manifest_uri_drops_leading_prefix_and_root_dir_for_an_absolute_windows_path() {
let path = Path::new(r"C:\tmp\deps-cli-manual-test\Cargo.toml");
assert!(
path.is_absolute(),
"test setup bug: fixture path must be absolute"
);
let uri = manifest_uri(path);
assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
assert!(
!Path::new(&uri).is_absolute(),
"an absolute manifest_path must not leak into an absolute artifactLocation.uri \
(spec 062 review R1)"
);
}
#[test]
fn test_to_sarif_artifact_uri_of_nested_path_has_no_fragment_character() {
let mut finding = finding(Category::Outdated, Severity::Hint);
finding.manifest_path = Path::new("a b#c").join("Cargo.toml");
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let locations = sarif.runs[0].results.as_ref().unwrap()[0]
.locations
.as_ref()
.unwrap();
let uri = locations[0]
.physical_location
.as_ref()
.unwrap()
.artifact_location
.as_ref()
.unwrap()
.uri
.as_ref()
.unwrap();
assert!(!uri.contains('#'));
assert!(!uri.contains(' '));
}
#[test]
fn test_to_sarif_severity_level_mapping() {
let report = CheckReport {
findings: vec![
finding(Category::Vulnerable, Severity::Error),
finding(Category::License, Severity::Warning),
finding(Category::Deprecated, Severity::Hint),
],
};
let sarif = to_sarif(&report);
let results = sarif.runs[0].results.as_ref().unwrap();
assert_eq!(results[0].level, Some(ResultLevel::Error));
assert_eq!(results[1].level, Some(ResultLevel::Warning));
assert_eq!(results[2].level, Some(ResultLevel::Note));
}
#[test]
fn test_render_round_trips_through_serde_json() {
let report = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let rendered = render(&report).expect("render must succeed");
let parsed: Sarif = serde_json::from_str(&rendered).expect("must round-trip");
assert_eq!(parsed.version, to_sarif(&report).version);
}
#[test]
fn test_to_sarif_multi_category_snapshot() {
let report = CheckReport {
findings: vec![
finding(Category::Outdated, Severity::Hint),
finding(Category::Vulnerable, Severity::Error),
],
};
insta::assert_json_snapshot!(to_sarif(&report), {
".runs[0].automationDetails.id" => "[automation_id]",
});
}
#[test]
fn test_to_sarif_rule_id_uses_code_when_present() {
let report = CheckReport {
findings: vec![finding_with_code(
Category::Vulnerable,
"RUSTSEC-2020-0071",
"RUSTSEC-2020-0071: Potential segfault in the time crate",
)],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "RUSTSEC-2020-0071");
let results = sarif.runs[0].results.as_ref().unwrap();
assert_eq!(results[0].rule_id.as_deref(), Some("RUSTSEC-2020-0071"));
}
#[test]
fn test_to_sarif_distinct_advisory_codes_produce_distinct_rules() {
let report = CheckReport {
findings: vec![
finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "advisory A"),
finding_with_code(Category::Vulnerable, "GHSA-xxxx-yyyy-zzzz", "advisory B"),
],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(
rules.len(),
2,
"two distinct advisories must not collapse into one rule"
);
}
#[test]
fn test_to_sarif_advisory_overflow_line_falls_back_to_category_rule() {
let report = CheckReport {
findings: vec![finding(Category::Vulnerable, Severity::Information)],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules[0].id, "vulnerable");
}
#[test]
fn test_to_sarif_coded_non_vulnerable_finding_still_uses_category_rule_id() {
let report = CheckReport {
findings: vec![finding_with_code(
Category::Unsatisfiable,
"unsatisfiable-requirement",
"no matching version",
)],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "unsatisfiable");
assert!(rules[0].help_uri.is_none());
assert!(rules[0].full_description.is_none());
let results = sarif.runs[0].results.as_ref().unwrap();
assert_eq!(results[0].rule_id.as_deref(), Some("unsatisfiable"));
}
#[test]
fn test_to_sarif_mutable_ref_pin_does_not_split_on_differing_codes() {
let report = CheckReport {
findings: vec![
finding_with_code(
Category::MutableRefPin,
"mutable-ref-pin",
"pinned to a tag",
),
finding_with_code(
Category::MutableRefPin,
"gitlab-ci-mutable-ref-pin",
"pinned to a tag",
),
],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(
rules.len(),
1,
"MutableRefPin's two internal code constants must not fragment one category into two rules"
);
assert_eq!(rules[0].id, "mutable-ref");
}
#[test]
fn test_build_rule_descriptor_advisory_rule_has_help_uri_and_full_description() {
let report = CheckReport {
findings: vec![finding_with_code(
Category::Vulnerable,
"RUSTSEC-2020-0071",
"RUSTSEC-2020-0071: Potential segfault in the time crate",
)],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
assert_eq!(
rule.help_uri.as_deref(),
Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071")
);
assert_eq!(
rule.full_description.as_ref().unwrap().text,
"RUSTSEC-2020-0071: Potential segfault in the time crate"
);
assert_eq!(
rule.short_description.as_ref().unwrap().text,
Category::Vulnerable.description()
);
assert_eq!(rule.name.as_deref(), Some("RUSTSEC-2020-0071"));
}
#[test]
fn test_build_rule_descriptor_category_only_rule_has_no_help_uri_or_full_description() {
let report = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
assert!(
rule.help_uri.is_none(),
"a category-only rule has no natural URL to fabricate one for"
);
assert!(rule.full_description.is_none());
assert!(rule.properties.is_none());
assert_eq!(
rule.short_description.as_ref().unwrap().text,
Category::Outdated.description()
);
}
#[test]
fn test_build_rule_descriptor_prefers_advisory_url_over_derived_formula() {
let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
finding.advisory_url =
Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x".to_string());
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
assert_eq!(
rule.help_uri.as_deref(),
Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x"),
"the authoritative OSV-provided href must win over the derived formula"
);
}
#[test]
fn test_build_rule_descriptor_omits_help_uri_for_a_malformed_advisory_id() {
let report = CheckReport {
findings: vec![finding_with_code(
Category::Vulnerable,
"RUSTSEC with a space",
"msg",
)],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(
rules.len(),
1,
"a code failing the allowlist must fall back to the category-token rule, not \
become its own rule id (issue #1077 review #2)"
);
assert_eq!(rules[0].id, "vulnerable");
assert!(
rules[0].help_uri.is_none(),
"a malformed advisory id must not become an unvalidated helpUri"
);
let results = sarif.runs[0].results.as_ref().unwrap();
assert_eq!(results[0].rule_id.as_deref(), Some("vulnerable"));
}
#[test]
fn test_build_rule_descriptor_omits_help_uri_for_a_traversal_id() {
let report = CheckReport {
findings: vec![finding_with_code(Category::Vulnerable, "..", "msg")],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules[0].id, "vulnerable");
assert!(rules[0].help_uri.is_none());
}
#[test]
fn test_build_rule_descriptor_omits_help_uri_for_an_embedded_slash_traversal() {
let report = CheckReport {
findings: vec![finding_with_code(Category::Vulnerable, "../evil", "msg")],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules[0].id, "vulnerable");
assert!(rules[0].help_uri.is_none());
}
#[test]
fn test_build_rule_descriptor_advisory_id_equal_to_a_category_token_is_still_advisory() {
let report = CheckReport {
findings: vec![finding_with_code(Category::Vulnerable, "vulnerable", "msg")],
};
let sarif = to_sarif(&report);
let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "vulnerable");
assert_eq!(rules[0].name.as_deref(), Some("vulnerable"));
assert_eq!(
rules[0].help_uri.as_deref(),
Some("https://osv.dev/vulnerability/vulnerable"),
"a valid code equal to the category token must still be trusted as an advisory id"
);
assert_eq!(rules[0].full_description.as_ref().unwrap().text, "msg");
}
#[test]
fn test_build_rule_descriptor_sets_security_severity_from_advisory_bucket() {
let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
finding.advisory_severity = Some(VulnSeverity::Critical);
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
let properties = rule.properties.as_ref().expect("properties must be set");
assert_eq!(
properties.additional_properties.get("security-severity"),
Some(&serde_json::Value::String("9.5".to_string()))
);
}
#[test]
fn test_build_rule_descriptor_omits_security_severity_for_an_ungraded_bucket() {
let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
finding.advisory_severity = Some(VulnSeverity::Unknown);
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
assert!(rule.properties.is_none());
}
#[test]
fn test_build_rule_descriptor_omits_security_severity_without_advisory_severity_data() {
let report = CheckReport {
findings: vec![finding_with_code(
Category::Vulnerable,
"RUSTSEC-2020-0071",
"msg",
)],
};
let sarif = to_sarif(&report);
let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
assert!(rule.properties.is_none());
}
#[test]
fn test_to_sarif_partial_fingerprint_is_stable_across_a_line_shift() {
let mut moved = finding(Category::Outdated, Severity::Hint);
moved.range = Range::new(Position::new(40, 0), Position::new(40, 10));
let report_before = CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
};
let report_after = CheckReport {
findings: vec![moved],
};
let fingerprint_before = sarif_fingerprint(&report_before);
let fingerprint_after = sarif_fingerprint(&report_after);
assert_eq!(
fingerprint_before, fingerprint_after,
"an unrelated line shift must not change the fingerprint"
);
}
#[test]
fn test_to_sarif_partial_fingerprint_differs_across_dependency_and_category() {
let base = sarif_fingerprint(&CheckReport {
findings: vec![finding(Category::Outdated, Severity::Hint)],
});
let mut other_dependency = finding(Category::Outdated, Severity::Hint);
other_dependency.dependency_name = Some("tokio".to_string());
let other_dependency_fp = sarif_fingerprint(&CheckReport {
findings: vec![other_dependency],
});
assert_ne!(base, other_dependency_fp);
let other_category_fp = sarif_fingerprint(&CheckReport {
findings: vec![finding(Category::Vulnerable, Severity::Error)],
});
assert_ne!(base, other_category_fp);
}
#[test]
fn test_to_sarif_partial_fingerprint_disambiguates_duplicate_occurrences_by_ordinal() {
let report = CheckReport {
findings: vec![
finding(Category::Outdated, Severity::Hint),
finding(Category::Outdated, Severity::Hint),
],
};
let sarif = to_sarif(&report);
let results = sarif.runs[0].results.as_ref().unwrap();
let fp0 = result_fingerprint(&results[0]);
let fp1 = result_fingerprint(&results[1]);
assert_ne!(
fp0, fp1,
"two occurrences of the same (manifest, dependency, rule) must not collapse"
);
}
#[test]
fn test_to_sarif_partial_fingerprint_disambiguates_two_document_level_other_notices() {
let mut first = finding(Category::Other, Severity::Information);
first.dependency_name = None;
let mut second = finding(Category::Other, Severity::Information);
second.dependency_name = None;
let report = CheckReport {
findings: vec![first, second],
};
let sarif = to_sarif(&report);
let results = sarif.runs[0].results.as_ref().unwrap();
assert_ne!(
result_fingerprint(&results[0]),
result_fingerprint(&results[1])
);
}
#[test]
fn test_to_sarif_partial_fingerprint_percent_encodes_a_pipe_in_the_dependency_name() {
let mut finding = finding(Category::Outdated, Severity::Hint);
finding.dependency_name = Some("serde|outdated".to_string());
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
assert!(
!fp.contains("serde|outdated"),
"a literal delimiter inside a component must be percent-encoded, not passed through raw"
);
assert!(fp.contains("serde%7Coutdated"));
}
#[test]
fn test_to_sarif_partial_fingerprint_handles_missing_dependency_name() {
let mut finding = finding(Category::Other, Severity::Information);
finding.dependency_name = None;
let report = CheckReport {
findings: vec![finding],
};
let sarif = to_sarif(&report);
let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
assert!(!fp.is_empty());
}
fn sarif_fingerprint(report: &CheckReport) -> String {
result_fingerprint(&to_sarif(report).runs[0].results.as_ref().unwrap()[0])
}
fn result_fingerprint(result: &SarifResult) -> String {
result
.partial_fingerprints
.as_ref()
.unwrap()
.get("depsCli/v1")
.unwrap()
.clone()
}
#[test]
fn test_automation_id_category_is_stable_and_run_id_is_last_segment() {
let id = automation_id_with_env(|name| match name {
"GITHUB_RUN_ID" => Some("12345".to_string()),
"GITHUB_RUN_ATTEMPT" => Some("2".to_string()),
"GITHUB_WORKFLOW" => Some("CI".to_string()),
"GITHUB_JOB" => Some("test".to_string()),
_ => None,
});
assert_eq!(id, "deps-cli/CI/test/12345-2");
let (category, run) = id.rsplit_once('/').expect("id must contain a separator");
assert_eq!(category, "deps-cli/CI/test");
assert_eq!(run, "12345-2");
}
#[test]
fn test_automation_id_category_is_stable_across_two_runs_of_the_same_workflow_and_job() {
let env_for = |run_id: &'static str| {
move |name: &str| match name {
"GITHUB_RUN_ID" => Some(run_id.to_string()),
"GITHUB_WORKFLOW" => Some("CI".to_string()),
"GITHUB_JOB" => Some("test".to_string()),
_ => None,
}
};
let first = automation_id_with_env(env_for("111"));
let second = automation_id_with_env(env_for("222"));
let (first_category, _) = first.rsplit_once('/').unwrap();
let (second_category, _) = second.rsplit_once('/').unwrap();
assert_eq!(
first_category, second_category,
"category must stay stable across runs so a later upload supersedes an earlier one"
);
assert_ne!(first, second, "the run id itself must still vary");
}
#[test]
fn test_automation_id_run_attempt_unset_still_uses_run_id_alone() {
let id = automation_id_with_env(|name| match name {
"GITHUB_RUN_ID" => Some("12345".to_string()),
"GITHUB_WORKFLOW" => Some("CI".to_string()),
"GITHUB_JOB" => Some("test".to_string()),
_ => None,
});
assert_eq!(id, "deps-cli/CI/test/12345");
}
#[test]
fn test_automation_id_falls_back_without_github_run_id() {
let id = automation_id_with_env(|_| None);
assert_eq!(id, "deps-cli/local");
}
#[test]
fn test_to_sarif_sets_automation_details_id() {
let sarif = to_sarif(&CheckReport::default());
assert!(
sarif.runs[0]
.automation_details
.as_ref()
.and_then(|details| details.id.as_deref())
.is_some_and(|id| !id.is_empty())
);
}
}