Skip to main content

deps_cli/format/
sarif.rs

1//! SARIF 2.1.0 output (FR-008, US-002) for GitHub code scanning and other SARIF consumers.
2//!
3//! Each distinct SARIF rule id becomes one `tool.driver.rules` entry, and each
4//! [`CheckFinding`] becomes one `run.results` entry with its [`Range`] translated to a
5//! SARIF physical location region. A rule id is [`CheckFinding::code`] when the finding is
6//! [`Category::Vulnerable`] *and* `code` passes [`deps_core::osv::is_valid_osv_id`]
7//! (`is_advisory_finding`), falling back to its [`Category`] wire token otherwise (issue
8//! #1075, spec 062 review S3: FR-008 says "each existing diagnostic code becomes a SARIF rule
9//! id"; issue #1077 review #2: `code` is never trusted as a rule id/name verbatim without that
10//! validity check). A vulnerability finding's `code` is its OSV advisory id
11//! (`RUSTSEC-...`/`GHSA-...`), so distinct advisories now produce distinct rules instead of
12//! collapsing into one shared `vulnerable` rule. `code` is deliberately *not* used for the
13//! other coded categories (`Unsatisfiable`/`License`/`Deprecated`/`MutableRefPin`): their
14//! diagnostic-code constants are already 1:1 with a `Category` (no finer granularity to gain),
15//! and `MutableRefPin` alone has two internal code constants (GitHub Actions' and GitLab CI's)
16//! that would otherwise fragment one category into two rules for zero benefit (issue #1077
17//! S1 review). `Outdated`/`Yanked`/`Other` findings, and the "+N more advisories" overflow
18//! line, carry no code at all and always use the category token.
19//!
20//! Rule metadata (issue #1077): every rule gets a `shortDescription` from
21//! [`Category::description`]. An advisory rule additionally gets:
22//! - `helpUri`, preferring [`CheckFinding::advisory_url`] — the authoritative
23//!   `https://osv.dev/vulnerability/{id}` page OSV itself gave the diagnostic — and falling
24//!   back to [`deps_core::osv::validated_osv_url`] (the same validated-construction path
25//!   `deps-core`'s own OSV client uses for [`deps_core::osv::Advisory::url`]) only when that is
26//!   unavailable;
27//! - `fullDescription`, built from the finding's own message, which already embeds the
28//!   advisory's OSV-provided summary (`push_vulnerability_diagnostics`);
29//! - `properties["security-severity"]`, from `security_severity_score`, when
30//!   [`CheckFinding::advisory_severity`] names a graded bucket.
31//!
32//! A category-only rule gets none of the three: it has no natural per-rule URL, no
33//! single-advisory description, and no per-advisory severity to report.
34//!
35//! Each result also carries a `partialFingerprints` entry (`collect_result_contexts`, issue
36//! #1077) derived from (manifest path, percent-encoded dependency identity, percent-encoded
37//! rule id, an occurrence ordinal) — deliberately excluding the finding's range, so an
38//! unrelated line shift elsewhere in the manifest does not change it and make GitHub treat an
39//! existing alert as new. See that function's doc for why percent-encoding and the ordinal are
40//! both necessary (issue #1077 S2 review: a raw `|` join can collide across components, and
41//! same-manifest/same-dependency/same-rule findings — e.g. one package declared in both
42//! `[dependencies]` and `[dev-dependencies]` — would otherwise collapse onto one fingerprint).
43//!
44//! `run.automationDetails.id` (`automation_id`, issue #1077) disambiguates repeated SARIF
45//! uploads for the same commit — see that function's doc for the category/run-id split GitHub
46//! expects it to follow.
47
48use crate::report::{Category, CheckFinding, CheckReport};
49use deps_core::diagnostic::Severity;
50use deps_core::osv::{VulnSeverity, is_valid_osv_id, validated_osv_url};
51use deps_core::position::Range;
52use serde_sarif::sarif::{
53    ArtifactLocation, Location, MultiformatMessageString, PhysicalLocation, PropertyBag, Region,
54    ReportingDescriptor, Result as SarifResult, ResultLevel, Run, RunAutomationDetails, SCHEMA_URL,
55    Sarif, Tool, ToolComponent, Version,
56};
57use std::collections::{BTreeMap, HashMap};
58use std::path::{Component, Path};
59
60/// Builds the [`Sarif`] document for `report`.
61///
62/// # Examples
63///
64/// ```
65/// use deps_cli::format::sarif::to_sarif;
66/// use deps_cli::report::CheckReport;
67///
68/// let sarif = to_sarif(&CheckReport::default());
69/// assert_eq!(sarif.runs.len(), 1);
70/// assert!(sarif.runs[0].results.as_ref().unwrap().is_empty());
71/// ```
72#[must_use]
73pub fn to_sarif(report: &CheckReport) -> Sarif {
74    let contexts = collect_result_contexts(&report.findings);
75    let rule_meta = collect_rule_meta(&report.findings, &contexts);
76    let rule_indices: HashMap<&str, usize> = rule_meta
77        .keys()
78        .enumerate()
79        .map(|(index, id)| (id.as_str(), index))
80        .collect();
81
82    let rules: Vec<ReportingDescriptor> = rule_meta
83        .iter()
84        .map(|(id, meta)| build_rule_descriptor(id, meta))
85        .collect();
86
87    let results: Vec<SarifResult> = report
88        .findings
89        .iter()
90        .zip(&contexts)
91        .map(|(finding, context)| {
92            let rule_index = rule_indices
93                .get(context.rule_id.as_str())
94                .copied()
95                .unwrap_or_default();
96            to_sarif_result(finding, rule_index, context)
97        })
98        .collect();
99
100    let driver = ToolComponent::builder()
101        .name("deps-cli")
102        .version(env!("CARGO_PKG_VERSION"))
103        .information_uri(env!("CARGO_PKG_REPOSITORY"))
104        .rules(rules)
105        .build();
106
107    let automation_details = RunAutomationDetails::builder().id(automation_id()).build();
108
109    let run = Run::builder()
110        .tool(Tool::from(driver))
111        .results(results)
112        .automation_details(automation_details)
113        .build();
114
115    Sarif::builder()
116        .version(Version::V2_1_0.to_string())
117        .schema(SCHEMA_URL)
118        .runs(vec![run])
119        .build()
120}
121
122/// Whether `finding` is eligible for an advisory-keyed rule: [`Category::Vulnerable`] with a
123/// `code` that also passes [`is_valid_osv_id`] (issue #1077 review #2) — a `code` that fails
124/// the allowlist falls back to the plain category-token rule instead of being trusted as a
125/// rule id/name verbatim. `classify` (`crate::report`) documents that its "any unrecognized
126/// diagnostic code -> `Vulnerable`" fallback is only sound as long as the known-code list it
127/// maintains stays exhaustive; this is the independent, defense-in-depth check on the `code`
128/// value itself for exactly the case where that fallback let something unexpected through.
129///
130/// This is the single source of truth both [`sarif_rule_id`] and [`collect_rule_meta`] use —
131/// carried into [`RuleMeta::is_advisory`] rather than re-derived later by comparing the rule id
132/// string against the category token (issue #1077 review #3): a *valid* advisory id that
133/// happens to equal a category token verbatim (e.g. an OSV id literally spelled `"license"` —
134/// contrived, but within the allowlist) would otherwise silently misclassify as category-only,
135/// dropping `helpUri`/`fullDescription`/`security-severity` for a finding that legitimately
136/// earned them.
137fn is_advisory_finding(finding: &CheckFinding) -> bool {
138    finding.category == Category::Vulnerable && finding.code.as_deref().is_some_and(is_valid_osv_id)
139}
140
141/// The SARIF rule id `finding` belongs to — see the module doc for the code-first,
142/// category-fallback rule, and [`is_advisory_finding`] for exactly when `code` is trusted.
143fn sarif_rule_id(finding: &CheckFinding) -> &str {
144    if is_advisory_finding(finding) {
145        // `is_advisory_finding` already confirmed `finding.code` is `Some`.
146        finding
147            .code
148            .as_deref()
149            .unwrap_or_else(|| finding.category.as_str())
150    } else {
151        finding.category.as_str()
152    }
153}
154
155/// The data [`build_rule_descriptor`] needs for one distinct rule id, collected from whichever
156/// [`CheckFinding`] first produced that id — see [`collect_rule_meta`] for why "first wins" is
157/// deliberate here.
158struct RuleMeta<'a> {
159    category: Category,
160    /// See [`is_advisory_finding`] — carried from the finding that first produced this rule id,
161    /// not re-derived from the id string in [`build_rule_descriptor`].
162    is_advisory: bool,
163    message: &'a str,
164    advisory_url: Option<String>,
165    advisory_severity: Option<VulnSeverity>,
166}
167
168/// Collects one [`RuleMeta`] per distinct rule id across `findings` (reusing each finding's
169/// already-computed [`ResultContext::rule_id`] rather than recomputing [`sarif_rule_id`] a
170/// second time), keyed — and thus sorted, for a deterministic `tool.driver.rules` order — by
171/// the rule id itself.
172///
173/// "First finding for a given rule id wins" (`or_insert_with`, issue #1077 S4 review) is a
174/// deliberate, not incidental, choice: every finding sharing one rule id is expected to
175/// describe the same advisory (an advisory-keyed rule) or the same kind of issue (a
176/// category-only rule), so the first one's message/url/severity is as representative as any
177/// other — this is rule-*level* metadata, not a per-result field, and `run.results[].message`
178/// (set in [`to_sarif_result`], one per finding) still carries each finding's own text
179/// regardless of which one seeded the rule description.
180fn collect_rule_meta<'a>(
181    findings: &'a [CheckFinding],
182    contexts: &[ResultContext],
183) -> BTreeMap<String, RuleMeta<'a>> {
184    let mut rules = BTreeMap::new();
185    for (finding, context) in findings.iter().zip(contexts) {
186        rules
187            .entry(context.rule_id.clone())
188            .or_insert_with(|| RuleMeta {
189                category: finding.category,
190                is_advisory: is_advisory_finding(finding),
191                message: finding.message.as_str(),
192                advisory_url: finding.advisory_url.clone(),
193                advisory_severity: finding.advisory_severity,
194            });
195    }
196    rules
197}
198
199/// Builds `id`'s `tool.driver.rules` entry from `meta` — see the module doc for which fields a
200/// category-only rule gets versus an advisory rule. Constructed as a plain struct literal
201/// (every field is `pub`, and the type is not `#[non_exhaustive]`) rather than through
202/// [`ReportingDescriptor::builder`]: the builder's per-field type-state generics make it
203/// impossible to assign the same builder variable conditionally across an `if`/`else`, and
204/// this rule has three independent optional pieces (`help_uri`, `full_description`,
205/// `properties`) to fill in.
206fn build_rule_descriptor(id: &str, meta: &RuleMeta<'_>) -> ReportingDescriptor {
207    let is_advisory = meta.is_advisory;
208
209    let short_description = MultiformatMessageString::builder()
210        .text(meta.category.description())
211        .build();
212
213    let (help_uri, full_description) = if is_advisory {
214        let help_uri = meta.advisory_url.clone().or_else(|| validated_osv_url(id));
215        let full_description = MultiformatMessageString::builder()
216            .text(meta.message.to_string())
217            .build();
218        (help_uri, Some(full_description))
219    } else {
220        (None, None)
221    };
222
223    let properties = if is_advisory {
224        meta.advisory_severity
225            .and_then(security_severity_score)
226            .map(|score| {
227                let mut additional_properties = BTreeMap::new();
228                additional_properties.insert(
229                    "security-severity".to_string(),
230                    serde_json::Value::String(score.to_string()),
231                );
232                PropertyBag::builder()
233                    .additional_properties(additional_properties)
234                    .build()
235            })
236    } else {
237        None
238    };
239
240    // Advisory rules use the advisory id as `name`; the shared category token would make two
241    // advisory rules indistinguishable.
242    let name = if is_advisory {
243        id.to_string()
244    } else {
245        meta.category.as_str().to_string()
246    };
247
248    ReportingDescriptor {
249        default_configuration: None,
250        deprecated_guids: None,
251        deprecated_ids: None,
252        deprecated_names: None,
253        full_description,
254        guid: None,
255        help: None,
256        help_uri,
257        id: id.to_string(),
258        message_strings: None,
259        name: Some(name),
260        properties,
261        relationships: None,
262        short_description: Some(short_description),
263    }
264}
265
266/// Maps a `deps-core` [`VulnSeverity`] bucket to a representative `security-severity` string
267/// (issue #1077 C2 review), using the midpoint of GitHub's own documented CVSS bands for code
268/// scanning (critical 9.0-10.0, high 7.0-8.9, medium 4.0-6.9, low 0.1-3.9).
269///
270/// `VulnSeverity` is itself a bucket `deps-core` already derived from real advisory data, not
271/// a per-advisory CVSS score (`deps_core::osv::Advisory::cvss_vector` is a raw vector string
272/// this crate has no parser for) — this is the closest honest representative value, never a
273/// fabricated precise score. [`VulnSeverity::Unknown`] ("no severity field was present or
274/// recognized") and [`VulnSeverity::Informational`] ("this is a maintenance notice, not a
275/// vulnerability") return `None`: neither should be dressed up as a numeric severity that
276/// GitHub's alert sort would then treat as graded fact.
277fn security_severity_score(severity: VulnSeverity) -> Option<&'static str> {
278    match severity {
279        VulnSeverity::Malicious => Some("10.0"),
280        VulnSeverity::Critical => Some("9.5"),
281        VulnSeverity::High => Some("8.0"),
282        VulnSeverity::Medium => Some("5.5"),
283        VulnSeverity::Low => Some("2.0"),
284        VulnSeverity::Unknown | VulnSeverity::Informational => None,
285        // `VulnSeverity` is `#[non_exhaustive]` — never guess a score for an unrecognized bucket.
286        _ => None,
287    }
288}
289
290/// `run.automationDetails.id` — disambiguates repeated SARIF uploads for the same commit
291/// (issue #1077). GitHub's own code-scanning ingestion splits this id at the *last* `/`:
292/// everything before it is the "category" (identifies *which* analysis line a run belongs to
293/// — must stay stable across repeated runs of the same workflow+job), everything after is the
294/// "run id" (must vary, so a later upload supersedes an earlier one instead of accumulating a
295/// forever-open duplicate alert). Reading `GITHUB_WORKFLOW`/`GITHUB_JOB` (stable per
296/// workflow+job, ambient in every Actions job environment) for the category and
297/// `GITHUB_RUN_ID`-`GITHUB_RUN_ATTEMPT` (varies every run/retry) for the run id keeps that
298/// split correct — issue #1077 C1 review: an earlier version of this function put the run id
299/// in the *middle* (`deps-cli/{run_id}/{attempt}`), which made the category itself vary every
300/// run and meant no upload ever superseded a previous one. A direct CLI invocation outside
301/// Actions has no such context and falls back to the fixed `"deps-cli/local"` — category
302/// `"deps-cli"`, run `"local"` — so repeated local runs intentionally share one category too
303/// (do not add a timestamp/PID here: the whole point is for a later local run to supersede an
304/// earlier one, the same as in Actions).
305fn automation_id() -> String {
306    automation_id_with_env(|name| std::env::var(name).ok())
307}
308
309/// [`automation_id`], but reading environment variables through `env` instead of
310/// [`std::env::var`] directly — lets tests inject a fake environment instead of mutating the
311/// real process environment (this workspace forbids `unsafe`, and Rust 2024 made
312/// `std::env::set_var`/`remove_var` `unsafe fn`s, so a test cannot do that mutation at all).
313fn automation_id_with_env(env: impl Fn(&str) -> Option<String>) -> String {
314    let Some(run_id) = env("GITHUB_RUN_ID") else {
315        return "deps-cli/local".to_string();
316    };
317    let workflow = env("GITHUB_WORKFLOW").unwrap_or_default();
318    let job = env("GITHUB_JOB").unwrap_or_default();
319    let run = match env("GITHUB_RUN_ATTEMPT") {
320        Some(attempt) => format!("{run_id}-{attempt}"),
321        None => run_id,
322    };
323    format!("deps-cli/{workflow}/{job}/{run}")
324}
325
326/// Per-finding data derived once up front rather than recomputed per field or per call site:
327/// [`manifest_uri`] is otherwise built twice per finding (once for `artifactLocation.uri`,
328/// once inside the fingerprint), and [`sarif_rule_id`] is otherwise recomputed independently
329/// in [`collect_rule_meta`], here, and in [`to_sarif`]'s results-mapping step.
330struct ResultContext {
331    manifest_uri: String,
332    rule_id: String,
333    fingerprint: String,
334}
335
336/// Builds one [`ResultContext`] per finding, in `findings` order.
337///
338/// The fingerprint is (manifest path, percent-encoded dependency identity, percent-encoded
339/// rule id, an occurrence ordinal) — deliberately excluding the finding's own range, so an
340/// unrelated line shift elsewhere in the manifest does not change it and make GitHub treat an
341/// existing alert as new (the whole point of `partialFingerprints`).
342///
343/// Percent-encoding the dependency and rule-id components (`manifest_uri` already encodes the
344/// manifest path) closes a raw-`|`-delimiter collision: without it, a dependency literally
345/// named e.g. `serde|outdated` could produce the same joined string as a different
346/// (dependency, rule) pair (issue #1077 security review).
347///
348/// The ordinal — this occurrence's 0-based rank among every finding sharing the same
349/// (manifest, dependency, rule id) triple, in `findings` order — disambiguates
350/// same-manifest/same-dependency/same-rule findings that would otherwise collapse onto one
351/// fingerprint (issue #1077 S2 review, corroborated independently by the critic and security
352/// reviews): two document-level [`Category::Other`] notices with no dependency (e.g. an
353/// offline notice and a truncation notice), or the same package declared in both
354/// `[dependencies]` and `[dev-dependencies]` (`deps-core`'s #394 S2 deliberately keeps such
355/// occurrences distinct; collapsing them here would silently undo that). It is intentionally
356/// not the finding's range: an ordinal only shifts when an occurrence of the *same*
357/// (manifest, dependency, rule) triple is added, removed, or reordered — a far narrower and
358/// rarer edit than "any line shifted anywhere in the file."
359fn collect_result_contexts(findings: &[CheckFinding]) -> Vec<ResultContext> {
360    // `manifest_uri` is a fresh String per iteration (must own it); `dependency`/`rule_id` borrow
361    // from `finding`, which outlives this function, so no clone is needed (issue #1077 review #8).
362    let mut seen: HashMap<(String, &str, &str), usize> = HashMap::new();
363    findings
364        .iter()
365        .map(|finding| {
366            let manifest = manifest_uri(&finding.manifest_path);
367            let dependency = finding.dependency_name.as_deref().unwrap_or("");
368            let rule_id = sarif_rule_id(finding);
369
370            let counter = seen
371                .entry((manifest.clone(), dependency, rule_id))
372                .or_insert(0);
373            let ordinal = *counter;
374            *counter += 1;
375
376            let fingerprint = format!(
377                "{manifest}|{}|{}|{ordinal}",
378                urlencoding::encode(dependency),
379                urlencoding::encode(rule_id),
380            );
381
382            ResultContext {
383                manifest_uri: manifest,
384                rule_id: rule_id.to_string(),
385                fingerprint,
386            }
387        })
388        .collect()
389}
390
391/// Builds one `run.results` entry for `finding`, whose rule is at `rule_index` within
392/// [`to_sarif`]'s `tool.driver.rules`, using `context` for the fields
393/// [`collect_result_contexts`] already computed once (including `context.rule_id` itself).
394fn to_sarif_result(
395    finding: &CheckFinding,
396    rule_index: usize,
397    context: &ResultContext,
398) -> SarifResult {
399    let region = to_sarif_region(finding.range);
400    let artifact_location = ArtifactLocation::builder()
401        .uri(context.manifest_uri.as_str())
402        .build();
403    let physical_location = PhysicalLocation::builder()
404        .artifact_location(artifact_location)
405        .region(region)
406        .build();
407    let location = Location::builder()
408        .physical_location(physical_location)
409        .build();
410
411    let mut partial_fingerprints = BTreeMap::new();
412    partial_fingerprints.insert("depsCli/v1".to_string(), context.fingerprint.clone());
413
414    SarifResult::builder()
415        .rule_id(context.rule_id.as_str())
416        .rule_index(i64::try_from(rule_index).unwrap_or(i64::MAX))
417        .message(finding.message.as_str())
418        .locations(vec![location])
419        .level(to_result_level(finding.severity))
420        .partial_fingerprints(partial_fingerprints)
421        .build()
422}
423
424/// Renders `path` as a percent-encoded, `/`-separated, always-relative SARIF
425/// `artifactLocation.uri` (RFC 3986 URI-reference).
426///
427/// `manifest_path.display().to_string()` is not safe to use directly here (spec 062 review
428/// S2/B3): it can emit a literal `#`, which a SARIF/URI consumer reads as a fragment
429/// separator rather than part of the path (a directory named `a b#c` would silently point a
430/// consumer at a different, wrong location); a literal space, which is not valid in a bare
431/// URI-reference; and, on Windows, `\`-separated components, which are not URI path
432/// separators at all. Building the URI from [`Path::components`] instead of the platform's
433/// own `Display` avoids all three: each component is percent-encoded independently and
434/// joined with `/`, regardless of the host platform's native separator.
435///
436/// `Component::RootDir`/`Component::Prefix` (a leading `/` on Unix, or a `C:`-style drive
437/// prefix on Windows) are dropped rather than encoded (spec 062 review R1): `CheckFinding`
438/// carries an absolute `manifest_path` whenever a manifest was named as an explicit file
439/// argument rather than discovered under a walked directory root (`walk::walk`'s
440/// `root.is_file()` branch passes the path through unchanged) — this formatter has no walked
441/// root to relativize against, so it drops the absolute-path marker rather than either
442/// leaking local machine path structure into a document meant for GitHub's Security tab, or
443/// emitting a URI that `Path::is_absolute()` still reports as absolute.
444fn manifest_uri(path: &Path) -> String {
445    path.components()
446        .filter_map(|component| match component {
447            Component::Normal(part) => {
448                Some(urlencoding::encode(&part.to_string_lossy()).into_owned())
449            }
450            Component::CurDir => Some(".".to_string()),
451            Component::ParentDir => Some("..".to_string()),
452            Component::RootDir | Component::Prefix(_) => None,
453        })
454        .collect::<Vec<_>>()
455        .join("/")
456}
457
458/// Translates a [`Range`] (zero-based line/character) into a SARIF [`Region`]
459/// (one-based line/column, per the SARIF 2.1.0 spec).
460fn to_sarif_region(range: Range) -> Region {
461    Region::builder()
462        .start_line(i64::from(range.start.line) + 1)
463        .start_column(i64::from(range.start.character) + 1)
464        .end_line(i64::from(range.end.line) + 1)
465        .end_column(i64::from(range.end.character) + 1)
466        .build()
467}
468
469/// Maps a [`Severity`] to the closest SARIF [`ResultLevel`].
470fn to_result_level(severity: Severity) -> ResultLevel {
471    match severity {
472        Severity::Error => ResultLevel::Error,
473        Severity::Warning => ResultLevel::Warning,
474        Severity::Information | Severity::Hint => ResultLevel::Note,
475    }
476}
477
478/// Renders `report` as a pretty-printed SARIF 2.1.0 JSON string.
479///
480/// # Errors
481///
482/// Returns an error only if [`Sarif`]'s `Serialize` impl fails, which does not happen for the
483/// plain-data document [`to_sarif`] builds.
484pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
485    serde_json::to_string_pretty(&to_sarif(report))
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use deps_core::EcosystemId;
492    use deps_core::position::Position;
493    use std::path::PathBuf;
494
495    fn finding(category: Category, severity: Severity) -> CheckFinding {
496        CheckFinding {
497            ecosystem: EcosystemId::Cargo,
498            manifest_path: PathBuf::from("Cargo.toml"),
499            dependency_name: Some("serde".to_string()),
500            requirement: Some("1.0".to_string()),
501            category,
502            code: None,
503            advisory_url: None,
504            advisory_severity: None,
505            severity,
506            range: Range::new(Position::new(4, 0), Position::new(4, 10)),
507            message: "Newer version available: 1.1.0".to_string(),
508        }
509    }
510
511    fn finding_with_code(category: Category, code: &str, message: &str) -> CheckFinding {
512        CheckFinding {
513            code: Some(code.to_string()),
514            message: message.to_string(),
515            ..finding(category, Severity::Warning)
516        }
517    }
518
519    #[test]
520    fn test_to_sarif_empty_report_has_one_empty_run() {
521        let sarif = to_sarif(&CheckReport::default());
522        assert_eq!(sarif.runs.len(), 1);
523        assert!(sarif.runs[0].results.as_ref().unwrap().is_empty());
524        assert!(sarif.runs[0].tool.driver.rules.as_ref().unwrap().is_empty());
525    }
526
527    #[test]
528    fn test_to_sarif_sets_tool_driver_name() {
529        let sarif = to_sarif(&CheckReport::default());
530        assert_eq!(sarif.runs[0].tool.driver.name, "deps-cli");
531    }
532
533    #[test]
534    fn test_to_sarif_rule_id_matches_category_token() {
535        let report = CheckReport {
536            findings: vec![finding(Category::Outdated, Severity::Hint)],
537        };
538        let sarif = to_sarif(&report);
539        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
540        assert_eq!(rules.len(), 1);
541        assert_eq!(rules[0].id, "outdated");
542
543        let results = sarif.runs[0].results.as_ref().unwrap();
544        assert_eq!(results[0].rule_id.as_deref(), Some("outdated"));
545        assert_eq!(results[0].rule_index, Some(0));
546    }
547
548    #[test]
549    fn test_to_sarif_deduplicates_rules_across_findings() {
550        let report = CheckReport {
551            findings: vec![
552                finding(Category::Outdated, Severity::Hint),
553                finding(Category::Outdated, Severity::Hint),
554                finding(Category::Vulnerable, Severity::Error),
555            ],
556        };
557        let sarif = to_sarif(&report);
558        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
559        assert_eq!(rules.len(), 2);
560    }
561
562    #[test]
563    fn test_to_sarif_translates_range_to_one_based_region() {
564        let report = CheckReport {
565            findings: vec![finding(Category::Outdated, Severity::Hint)],
566        };
567        let sarif = to_sarif(&report);
568        let locations = sarif.runs[0].results.as_ref().unwrap()[0]
569            .locations
570            .as_ref()
571            .unwrap();
572        let region = locations[0]
573            .physical_location
574            .as_ref()
575            .unwrap()
576            .region
577            .as_ref()
578            .unwrap();
579        assert_eq!(region.start_line, Some(5));
580        assert_eq!(region.start_column, Some(1));
581        assert_eq!(region.end_line, Some(5));
582        assert_eq!(region.end_column, Some(11));
583    }
584
585    #[test]
586    fn test_to_sarif_artifact_uri_matches_manifest_path() {
587        let report = CheckReport {
588            findings: vec![finding(Category::Outdated, Severity::Hint)],
589        };
590        let sarif = to_sarif(&report);
591        let locations = sarif.runs[0].results.as_ref().unwrap()[0]
592            .locations
593            .as_ref()
594            .unwrap();
595        let artifact_location = locations[0]
596            .physical_location
597            .as_ref()
598            .unwrap()
599            .artifact_location
600            .as_ref()
601            .unwrap();
602        assert_eq!(artifact_location.uri.as_deref(), Some("Cargo.toml"));
603    }
604
605    #[test]
606    fn test_manifest_uri_percent_encodes_hash_and_space() {
607        let path = Path::new("a b#c%20d").join("Cargo.toml");
608        let uri = manifest_uri(&path);
609        assert_eq!(uri, "a%20b%23c%2520d/Cargo.toml");
610        assert!(
611            !uri.contains('#'),
612            "a literal '#' would be read as a URI fragment separator"
613        );
614        assert!(
615            !uri.contains(' '),
616            "a literal space is not valid in a bare URI-reference"
617        );
618    }
619
620    #[test]
621    fn test_manifest_uri_joins_nested_components_with_forward_slash() {
622        let path = Path::new("crates").join("deps-cli").join("Cargo.toml");
623        assert_eq!(manifest_uri(&path), "crates/deps-cli/Cargo.toml");
624    }
625
626    // `/tmp/...` is not `is_absolute()` on Windows (no drive prefix), hence the separate
627    // `cfg(windows)` fixture below instead of gating this test to `cfg(unix)` (spec 062 review R1).
628    #[cfg(unix)]
629    #[test]
630    fn test_manifest_uri_drops_leading_root_dir_for_an_absolute_unix_path() {
631        let path = Path::new("/tmp/deps-cli-manual-test/Cargo.toml");
632        assert!(
633            path.is_absolute(),
634            "test setup bug: fixture path must be absolute"
635        );
636        let uri = manifest_uri(path);
637        assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
638        assert!(
639            !Path::new(&uri).is_absolute(),
640            "an absolute manifest_path must not leak into an absolute artifactLocation.uri \
641             (spec 062 review R1)"
642        );
643    }
644
645    #[cfg(windows)]
646    #[test]
647    fn test_manifest_uri_drops_leading_prefix_and_root_dir_for_an_absolute_windows_path() {
648        let path = Path::new(r"C:\tmp\deps-cli-manual-test\Cargo.toml");
649        assert!(
650            path.is_absolute(),
651            "test setup bug: fixture path must be absolute"
652        );
653        let uri = manifest_uri(path);
654        assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
655        assert!(
656            !Path::new(&uri).is_absolute(),
657            "an absolute manifest_path must not leak into an absolute artifactLocation.uri \
658             (spec 062 review R1)"
659        );
660    }
661
662    #[test]
663    fn test_to_sarif_artifact_uri_of_nested_path_has_no_fragment_character() {
664        let mut finding = finding(Category::Outdated, Severity::Hint);
665        finding.manifest_path = Path::new("a b#c").join("Cargo.toml");
666        let report = CheckReport {
667            findings: vec![finding],
668        };
669        let sarif = to_sarif(&report);
670        let locations = sarif.runs[0].results.as_ref().unwrap()[0]
671            .locations
672            .as_ref()
673            .unwrap();
674        let uri = locations[0]
675            .physical_location
676            .as_ref()
677            .unwrap()
678            .artifact_location
679            .as_ref()
680            .unwrap()
681            .uri
682            .as_ref()
683            .unwrap();
684        assert!(!uri.contains('#'));
685        assert!(!uri.contains(' '));
686    }
687
688    #[test]
689    fn test_to_sarif_severity_level_mapping() {
690        let report = CheckReport {
691            findings: vec![
692                finding(Category::Vulnerable, Severity::Error),
693                finding(Category::License, Severity::Warning),
694                finding(Category::Deprecated, Severity::Hint),
695            ],
696        };
697        let sarif = to_sarif(&report);
698        let results = sarif.runs[0].results.as_ref().unwrap();
699        assert_eq!(results[0].level, Some(ResultLevel::Error));
700        assert_eq!(results[1].level, Some(ResultLevel::Warning));
701        assert_eq!(results[2].level, Some(ResultLevel::Note));
702    }
703
704    #[test]
705    fn test_render_round_trips_through_serde_json() {
706        let report = CheckReport {
707            findings: vec![finding(Category::Outdated, Severity::Hint)],
708        };
709        let rendered = render(&report).expect("render must succeed");
710        let parsed: Sarif = serde_json::from_str(&rendered).expect("must round-trip");
711        assert_eq!(parsed.version, to_sarif(&report).version);
712    }
713
714    /// Snapshot test (mirrors T022/T023's own coverage) pinning the exact SARIF document
715    /// shape so a field rename or nesting change shows up as a snapshot diff.
716    ///
717    /// `automationDetails.id` is redacted: [`automation_id`] reads the real `GITHUB_RUN_ID`
718    /// environment variable, which is set (to a different value every time) whenever this
719    /// test itself runs inside GitHub Actions CI — an unredacted snapshot would then never
720    /// match the value accepted locally.
721    #[test]
722    fn test_to_sarif_multi_category_snapshot() {
723        let report = CheckReport {
724            findings: vec![
725                finding(Category::Outdated, Severity::Hint),
726                finding(Category::Vulnerable, Severity::Error),
727            ],
728        };
729        insta::assert_json_snapshot!(to_sarif(&report), {
730            ".runs[0].automationDetails.id" => "[automation_id]",
731        });
732    }
733
734    #[test]
735    fn test_to_sarif_rule_id_uses_code_when_present() {
736        let report = CheckReport {
737            findings: vec![finding_with_code(
738                Category::Vulnerable,
739                "RUSTSEC-2020-0071",
740                "RUSTSEC-2020-0071: Potential segfault in the time crate",
741            )],
742        };
743        let sarif = to_sarif(&report);
744        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
745        assert_eq!(rules.len(), 1);
746        assert_eq!(rules[0].id, "RUSTSEC-2020-0071");
747
748        let results = sarif.runs[0].results.as_ref().unwrap();
749        assert_eq!(results[0].rule_id.as_deref(), Some("RUSTSEC-2020-0071"));
750    }
751
752    #[test]
753    fn test_to_sarif_distinct_advisory_codes_produce_distinct_rules() {
754        let report = CheckReport {
755            findings: vec![
756                finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "advisory A"),
757                finding_with_code(Category::Vulnerable, "GHSA-xxxx-yyyy-zzzz", "advisory B"),
758            ],
759        };
760        let sarif = to_sarif(&report);
761        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
762        assert_eq!(
763            rules.len(),
764            2,
765            "two distinct advisories must not collapse into one rule"
766        );
767    }
768
769    #[test]
770    fn test_to_sarif_advisory_overflow_line_falls_back_to_category_rule() {
771        // The "+N more advisories" summary line is `Category::Vulnerable` with no diagnostic
772        // code, so it must fall back to the category-token rule, not panic or emit an empty id.
773        let report = CheckReport {
774            findings: vec![finding(Category::Vulnerable, Severity::Information)],
775        };
776        let sarif = to_sarif(&report);
777        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
778        assert_eq!(rules[0].id, "vulnerable");
779    }
780
781    /// Regression test for issue #1077 S1 review: a coded finding in one of the four
782    /// non-`Vulnerable` coded categories must still use the plain category-token rule id, not
783    /// its (redundant, 1:1-with-category) diagnostic code — `code` is only meaningful rule-id
784    /// material for `Vulnerable`.
785    #[test]
786    fn test_to_sarif_coded_non_vulnerable_finding_still_uses_category_rule_id() {
787        let report = CheckReport {
788            findings: vec![finding_with_code(
789                Category::Unsatisfiable,
790                "unsatisfiable-requirement",
791                "no matching version",
792            )],
793        };
794        let sarif = to_sarif(&report);
795        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
796        assert_eq!(rules.len(), 1);
797        assert_eq!(rules[0].id, "unsatisfiable");
798        assert!(rules[0].help_uri.is_none());
799        assert!(rules[0].full_description.is_none());
800
801        let results = sarif.runs[0].results.as_ref().unwrap();
802        assert_eq!(results[0].rule_id.as_deref(), Some("unsatisfiable"));
803    }
804
805    /// Regression test for issue #1077 S1 review: `MutableRefPin` findings carry one of two
806    /// distinct internal code constants (GitHub Actions' vs GitLab CI's) depending on their
807    /// source ecosystem — both must still collapse into the one `mutable-ref` rule.
808    #[test]
809    fn test_to_sarif_mutable_ref_pin_does_not_split_on_differing_codes() {
810        let report = CheckReport {
811            findings: vec![
812                finding_with_code(
813                    Category::MutableRefPin,
814                    "mutable-ref-pin",
815                    "pinned to a tag",
816                ),
817                finding_with_code(
818                    Category::MutableRefPin,
819                    "gitlab-ci-mutable-ref-pin",
820                    "pinned to a tag",
821                ),
822            ],
823        };
824        let sarif = to_sarif(&report);
825        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
826        assert_eq!(
827            rules.len(),
828            1,
829            "MutableRefPin's two internal code constants must not fragment one category into two rules"
830        );
831        assert_eq!(rules[0].id, "mutable-ref");
832    }
833
834    #[test]
835    fn test_build_rule_descriptor_advisory_rule_has_help_uri_and_full_description() {
836        let report = CheckReport {
837            findings: vec![finding_with_code(
838                Category::Vulnerable,
839                "RUSTSEC-2020-0071",
840                "RUSTSEC-2020-0071: Potential segfault in the time crate",
841            )],
842        };
843        let sarif = to_sarif(&report);
844        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
845        assert_eq!(
846            rule.help_uri.as_deref(),
847            Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071")
848        );
849        assert_eq!(
850            rule.full_description.as_ref().unwrap().text,
851            "RUSTSEC-2020-0071: Potential segfault in the time crate"
852        );
853        assert_eq!(
854            rule.short_description.as_ref().unwrap().text,
855            Category::Vulnerable.description()
856        );
857        assert_eq!(rule.name.as_deref(), Some("RUSTSEC-2020-0071"));
858    }
859
860    #[test]
861    fn test_build_rule_descriptor_category_only_rule_has_no_help_uri_or_full_description() {
862        let report = CheckReport {
863            findings: vec![finding(Category::Outdated, Severity::Hint)],
864        };
865        let sarif = to_sarif(&report);
866        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
867        assert!(
868            rule.help_uri.is_none(),
869            "a category-only rule has no natural URL to fabricate one for"
870        );
871        assert!(rule.full_description.is_none());
872        assert!(rule.properties.is_none());
873        assert_eq!(
874            rule.short_description.as_ref().unwrap().text,
875            Category::Outdated.description()
876        );
877    }
878
879    #[test]
880    fn test_build_rule_descriptor_prefers_advisory_url_over_derived_formula() {
881        let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
882        finding.advisory_url =
883            Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x".to_string());
884        let report = CheckReport {
885            findings: vec![finding],
886        };
887        let sarif = to_sarif(&report);
888        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
889        assert_eq!(
890            rule.help_uri.as_deref(),
891            Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x"),
892            "the authoritative OSV-provided href must win over the derived formula"
893        );
894    }
895
896    /// Regression test for issue #1077 MEDIUM security review: an advisory id that would
897    /// produce an invalid URI (a literal space here) must never reach `helpUri` unvalidated.
898    #[test]
899    fn test_build_rule_descriptor_omits_help_uri_for_a_malformed_advisory_id() {
900        let report = CheckReport {
901            findings: vec![finding_with_code(
902                Category::Vulnerable,
903                "RUSTSEC with a space",
904                "msg",
905            )],
906        };
907        let sarif = to_sarif(&report);
908        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
909        assert_eq!(
910            rules.len(),
911            1,
912            "a code failing the allowlist must fall back to the category-token rule, not \
913             become its own rule id (issue #1077 review #2)"
914        );
915        assert_eq!(rules[0].id, "vulnerable");
916        assert!(
917            rules[0].help_uri.is_none(),
918            "a malformed advisory id must not become an unvalidated helpUri"
919        );
920
921        let results = sarif.runs[0].results.as_ref().unwrap();
922        assert_eq!(results[0].rule_id.as_deref(), Some("vulnerable"));
923    }
924
925    /// Regression test for issue #1077 MEDIUM/S3 security review: a `.`/`..` advisory id is a
926    /// syntactically valid URI path segment but would retarget the link away from
927    /// `/vulnerability/`. Also fails [`is_valid_osv_id`] (issue #1077 review #1/#2), so it
928    /// falls back to the category-token rule entirely, not just to a bare `helpUri` omission.
929    #[test]
930    fn test_build_rule_descriptor_omits_help_uri_for_a_traversal_id() {
931        let report = CheckReport {
932            findings: vec![finding_with_code(Category::Vulnerable, "..", "msg")],
933        };
934        let sarif = to_sarif(&report);
935        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
936        assert_eq!(rules[0].id, "vulnerable");
937        assert!(rules[0].help_uri.is_none());
938    }
939
940    /// Regression test for issue #1077 review #1: a multi-segment traversal embedded in the
941    /// code (a `/` is not in the allowlist) must be rejected the same way a bare `..` is.
942    #[test]
943    fn test_build_rule_descriptor_omits_help_uri_for_an_embedded_slash_traversal() {
944        let report = CheckReport {
945            findings: vec![finding_with_code(Category::Vulnerable, "../evil", "msg")],
946        };
947        let sarif = to_sarif(&report);
948        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
949        assert_eq!(rules[0].id, "vulnerable");
950        assert!(rules[0].help_uri.is_none());
951    }
952
953    /// Regression test for issue #1077 review #3: a *valid* advisory id that happens to equal
954    /// a category token verbatim must still be treated as a genuine advisory rule (getting
955    /// `helpUri`/`fullDescription`), not misclassified as category-only by a string comparison
956    /// against its own id.
957    #[test]
958    fn test_build_rule_descriptor_advisory_id_equal_to_a_category_token_is_still_advisory() {
959        let report = CheckReport {
960            findings: vec![finding_with_code(Category::Vulnerable, "vulnerable", "msg")],
961        };
962        let sarif = to_sarif(&report);
963        let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
964        assert_eq!(rules.len(), 1);
965        assert_eq!(rules[0].id, "vulnerable");
966        assert_eq!(rules[0].name.as_deref(), Some("vulnerable"));
967        assert_eq!(
968            rules[0].help_uri.as_deref(),
969            Some("https://osv.dev/vulnerability/vulnerable"),
970            "a valid code equal to the category token must still be trusted as an advisory id"
971        );
972        assert_eq!(rules[0].full_description.as_ref().unwrap().text, "msg");
973    }
974
975    #[test]
976    fn test_build_rule_descriptor_sets_security_severity_from_advisory_bucket() {
977        let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
978        finding.advisory_severity = Some(VulnSeverity::Critical);
979        let report = CheckReport {
980            findings: vec![finding],
981        };
982        let sarif = to_sarif(&report);
983        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
984        let properties = rule.properties.as_ref().expect("properties must be set");
985        assert_eq!(
986            properties.additional_properties.get("security-severity"),
987            Some(&serde_json::Value::String("9.5".to_string()))
988        );
989    }
990
991    #[test]
992    fn test_build_rule_descriptor_omits_security_severity_for_an_ungraded_bucket() {
993        let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
994        finding.advisory_severity = Some(VulnSeverity::Unknown);
995        let report = CheckReport {
996            findings: vec![finding],
997        };
998        let sarif = to_sarif(&report);
999        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
1000        assert!(rule.properties.is_none());
1001    }
1002
1003    #[test]
1004    fn test_build_rule_descriptor_omits_security_severity_without_advisory_severity_data() {
1005        let report = CheckReport {
1006            findings: vec![finding_with_code(
1007                Category::Vulnerable,
1008                "RUSTSEC-2020-0071",
1009                "msg",
1010            )],
1011        };
1012        let sarif = to_sarif(&report);
1013        let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
1014        assert!(rule.properties.is_none());
1015    }
1016
1017    #[test]
1018    fn test_to_sarif_partial_fingerprint_is_stable_across_a_line_shift() {
1019        let mut moved = finding(Category::Outdated, Severity::Hint);
1020        moved.range = Range::new(Position::new(40, 0), Position::new(40, 10));
1021        let report_before = CheckReport {
1022            findings: vec![finding(Category::Outdated, Severity::Hint)],
1023        };
1024        let report_after = CheckReport {
1025            findings: vec![moved],
1026        };
1027
1028        let fingerprint_before = sarif_fingerprint(&report_before);
1029        let fingerprint_after = sarif_fingerprint(&report_after);
1030        assert_eq!(
1031            fingerprint_before, fingerprint_after,
1032            "an unrelated line shift must not change the fingerprint"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_to_sarif_partial_fingerprint_differs_across_dependency_and_category() {
1038        let base = sarif_fingerprint(&CheckReport {
1039            findings: vec![finding(Category::Outdated, Severity::Hint)],
1040        });
1041
1042        let mut other_dependency = finding(Category::Outdated, Severity::Hint);
1043        other_dependency.dependency_name = Some("tokio".to_string());
1044        let other_dependency_fp = sarif_fingerprint(&CheckReport {
1045            findings: vec![other_dependency],
1046        });
1047        assert_ne!(base, other_dependency_fp);
1048
1049        let other_category_fp = sarif_fingerprint(&CheckReport {
1050            findings: vec![finding(Category::Vulnerable, Severity::Error)],
1051        });
1052        assert_ne!(base, other_category_fp);
1053    }
1054
1055    /// Regression test for issue #1077 S2 review (independently corroborated by both the
1056    /// critic and security reviews): the same dependency declared under two sections (e.g.
1057    /// `[dependencies]` and `[dev-dependencies]`) produces two distinct findings that must not
1058    /// collapse onto one fingerprint (`deps-core`'s #394 S2 keeps them distinct upstream).
1059    #[test]
1060    fn test_to_sarif_partial_fingerprint_disambiguates_duplicate_occurrences_by_ordinal() {
1061        let report = CheckReport {
1062            findings: vec![
1063                finding(Category::Outdated, Severity::Hint),
1064                finding(Category::Outdated, Severity::Hint),
1065            ],
1066        };
1067        let sarif = to_sarif(&report);
1068        let results = sarif.runs[0].results.as_ref().unwrap();
1069        let fp0 = result_fingerprint(&results[0]);
1070        let fp1 = result_fingerprint(&results[1]);
1071        assert_ne!(
1072            fp0, fp1,
1073            "two occurrences of the same (manifest, dependency, rule) must not collapse"
1074        );
1075    }
1076
1077    /// Regression test for issue #1077 S2 review: two document-level notices with no
1078    /// `dependency_name` (e.g. an offline notice and a truncation notice) must not collapse
1079    /// onto one fingerprint either.
1080    #[test]
1081    fn test_to_sarif_partial_fingerprint_disambiguates_two_document_level_other_notices() {
1082        let mut first = finding(Category::Other, Severity::Information);
1083        first.dependency_name = None;
1084        let mut second = finding(Category::Other, Severity::Information);
1085        second.dependency_name = None;
1086        let report = CheckReport {
1087            findings: vec![first, second],
1088        };
1089        let sarif = to_sarif(&report);
1090        let results = sarif.runs[0].results.as_ref().unwrap();
1091        assert_ne!(
1092            result_fingerprint(&results[0]),
1093            result_fingerprint(&results[1])
1094        );
1095    }
1096
1097    /// Regression test for issue #1077 security review: a raw `|` join is not delimiter-safe
1098    /// — a dependency literally named `serde|outdated` must not be able to collide with a
1099    /// different (dependency, rule) pair.
1100    #[test]
1101    fn test_to_sarif_partial_fingerprint_percent_encodes_a_pipe_in_the_dependency_name() {
1102        let mut finding = finding(Category::Outdated, Severity::Hint);
1103        finding.dependency_name = Some("serde|outdated".to_string());
1104        let report = CheckReport {
1105            findings: vec![finding],
1106        };
1107        let sarif = to_sarif(&report);
1108        let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
1109        assert!(
1110            !fp.contains("serde|outdated"),
1111            "a literal delimiter inside a component must be percent-encoded, not passed through raw"
1112        );
1113        assert!(fp.contains("serde%7Coutdated"));
1114    }
1115
1116    #[test]
1117    fn test_to_sarif_partial_fingerprint_handles_missing_dependency_name() {
1118        let mut finding = finding(Category::Other, Severity::Information);
1119        finding.dependency_name = None;
1120        let report = CheckReport {
1121            findings: vec![finding],
1122        };
1123        let sarif = to_sarif(&report);
1124        let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
1125        assert!(!fp.is_empty());
1126    }
1127
1128    fn sarif_fingerprint(report: &CheckReport) -> String {
1129        result_fingerprint(&to_sarif(report).runs[0].results.as_ref().unwrap()[0])
1130    }
1131
1132    fn result_fingerprint(result: &SarifResult) -> String {
1133        result
1134            .partial_fingerprints
1135            .as_ref()
1136            .unwrap()
1137            .get("depsCli/v1")
1138            .unwrap()
1139            .clone()
1140    }
1141
1142    #[test]
1143    fn test_automation_id_category_is_stable_and_run_id_is_last_segment() {
1144        let id = automation_id_with_env(|name| match name {
1145            "GITHUB_RUN_ID" => Some("12345".to_string()),
1146            "GITHUB_RUN_ATTEMPT" => Some("2".to_string()),
1147            "GITHUB_WORKFLOW" => Some("CI".to_string()),
1148            "GITHUB_JOB" => Some("test".to_string()),
1149            _ => None,
1150        });
1151        assert_eq!(id, "deps-cli/CI/test/12345-2");
1152        let (category, run) = id.rsplit_once('/').expect("id must contain a separator");
1153        assert_eq!(category, "deps-cli/CI/test");
1154        assert_eq!(run, "12345-2");
1155    }
1156
1157    /// Regression test for issue #1077 C1 review: the category (everything before the last
1158    /// `/`) must stay identical across two different runs of the same workflow+job, so a
1159    /// later upload supersedes an earlier one instead of GitHub treating each run as a new,
1160    /// never-closed analysis line.
1161    #[test]
1162    fn test_automation_id_category_is_stable_across_two_runs_of_the_same_workflow_and_job() {
1163        let env_for = |run_id: &'static str| {
1164            move |name: &str| match name {
1165                "GITHUB_RUN_ID" => Some(run_id.to_string()),
1166                "GITHUB_WORKFLOW" => Some("CI".to_string()),
1167                "GITHUB_JOB" => Some("test".to_string()),
1168                _ => None,
1169            }
1170        };
1171        let first = automation_id_with_env(env_for("111"));
1172        let second = automation_id_with_env(env_for("222"));
1173        let (first_category, _) = first.rsplit_once('/').unwrap();
1174        let (second_category, _) = second.rsplit_once('/').unwrap();
1175        assert_eq!(
1176            first_category, second_category,
1177            "category must stay stable across runs so a later upload supersedes an earlier one"
1178        );
1179        assert_ne!(first, second, "the run id itself must still vary");
1180    }
1181
1182    #[test]
1183    fn test_automation_id_run_attempt_unset_still_uses_run_id_alone() {
1184        let id = automation_id_with_env(|name| match name {
1185            "GITHUB_RUN_ID" => Some("12345".to_string()),
1186            "GITHUB_WORKFLOW" => Some("CI".to_string()),
1187            "GITHUB_JOB" => Some("test".to_string()),
1188            _ => None,
1189        });
1190        assert_eq!(id, "deps-cli/CI/test/12345");
1191    }
1192
1193    #[test]
1194    fn test_automation_id_falls_back_without_github_run_id() {
1195        let id = automation_id_with_env(|_| None);
1196        assert_eq!(id, "deps-cli/local");
1197    }
1198
1199    #[test]
1200    fn test_to_sarif_sets_automation_details_id() {
1201        let sarif = to_sarif(&CheckReport::default());
1202        assert!(
1203            sarif.runs[0]
1204                .automation_details
1205                .as_ref()
1206                .and_then(|details| details.id.as_deref())
1207                .is_some_and(|id| !id.is_empty())
1208        );
1209    }
1210}