Skip to main content

deps_cli/
report.rs

1//! `CheckReport`/`CheckFinding`/`Category`/`FailOnPolicy` and the classification
2//! orchestrator that assembles one manifest's [`deps_core::VersionData`].
3//!
4//! It then calls its ecosystem's [`deps_core::Ecosystem::generate_diagnostics`] — the
5//! identical call `deps-lsp`'s `handlers/diagnostics.rs` makes.
6//!
7//! Every outdated/yanked/vulnerable/unsatisfiable/deprecated verdict is decided by
8//! [`deps_engine::classify`] or by `generate_diagnostics` itself; nothing in this module
9//! re-derives a verdict from raw registry/lockfile/OSV data (spec 062 FR-005). `Category`
10//! stays in this crate rather than `deps_core` per `specs/062-cli-check-mode/plan.md`'s
11//! `[NEEDS CLARIFICATION: O-4]` marker — see that doc before moving it.
12
13use deps_core::diagnostic::{Diagnostic, Severity};
14use deps_core::lsp_helpers::{
15    DEPRECATED_DIAGNOSTIC_CODE, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16    UNSATISFIABLE_DIAGNOSTIC_CODE, redact_name_for_diagnostic, redact_requirement_for_diagnostic,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnKeys, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache};
22use std::collections::{BTreeMap, HashMap};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26/// Every non-`deps-core` diagnostic code constant in the workspace, mirrored here as
27/// literals rather than importing `deps-github-actions`/`deps-gitlab-ci` directly (both are
28/// optional, feature-gated dependencies of `deps-engine`; importing their constants
29/// unconditionally would break a `--no-default-features --features cargo`-style build, and
30/// `#[cfg]`-gating each match arm was judged not worth the complexity for three stable
31/// strings). **This list must stay exhaustive** — issue C3 (spec 062 review) was exactly one
32/// ecosystem-owned code (`UNRESOLVED_HOST_DIAGNOSTIC_CODE`) missing from it, which silently
33/// misclassified an informational notice as `Category::Vulnerable`. Before trusting
34/// [`classify`]'s fallback again, re-run
35/// `grep -rn 'pub const.*_DIAGNOSTIC_CODE.*: &str' crates/*/src/lib.rs crates/*/src/ecosystem.rs`
36/// across the workspace and add anything new here.
37const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
38/// See [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc.
39const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
40/// `deps_gitlab_ci::UNRESOLVED_HOST_DIAGNOSTIC_CODE` — an informational notice (INFORMATION
41/// severity, never a vulnerability), emitted unconditionally whenever GitLab CI's
42/// `registries.gitlab_instance_host` is unset/invalid. See
43/// [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc for why this is a literal.
44const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
45/// `deps_github_actions::SHA_COMMENT_MISMATCH_DIAGNOSTIC_CODE` — a supply-chain hygiene
46/// warning, not a vulnerability. See [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc for why this
47/// is a literal.
48const GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE: &str = "sha-comment-mismatch";
49
50/// A category a [`CheckFinding`] can be classified into — the seven `--fail-on` tokens FR-009
51/// defines, plus [`Category::Other`].
52///
53/// `Other` covers a `generate_diagnostics` finding that matches none of the seven (an
54/// "Unknown package", a collapsed registry-lookup failure, or a workspace-registry/
55/// offline/dependency-count notice). It is never selectable via `--fail-on`
56/// (`#[value(skip)]`) and never matched by [`FailOnPolicy`] — it exists purely so
57/// [`CheckFinding`] stays a 1:1 mapping of every diagnostic `generate_diagnostics` produced,
58/// per spec 062 `tasks.md` T020, rather than silently dropping findings this crate cannot
59/// classify.
60///
61/// `Serialize`/`Deserialize` (#1626, manual impls below [`Self::as_str`]) produce/accept the
62/// exact same tokens as [`Self::as_str`] — used directly as
63/// `crate::format::json::FindingDocument::category` and as the
64/// `crate::format::json::ReportDocument::summary` map key, so the JSON wire format never
65/// stringly-types this closed set.
66///
67/// # Examples
68///
69/// ```
70/// use deps_cli::report::Category;
71///
72/// assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
73/// assert_eq!(Category::License.as_str(), "license");
74/// ```
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
76pub enum Category {
77    /// A newer version is published for a dependency's declared requirement.
78    Outdated,
79    /// The in-use (or only-satisfying) version has been yanked/retracted.
80    Yanked,
81    /// A known OSV advisory affects the in-use version.
82    Vulnerable,
83    /// No published version satisfies the declared requirement.
84    Unsatisfiable,
85    /// Pinned to a mutable ref (tag/branch) instead of a commit SHA (GitHub Actions/GitLab
86    /// CI).
87    #[value(name = "mutable-ref")]
88    MutableRefPin,
89    /// The resolved license violates the configured allow/deny policy.
90    License,
91    /// The registry reports the package itself as deprecated/abandoned.
92    Deprecated,
93    /// A `generate_diagnostics` finding that does not map to any category above.
94    #[value(skip)]
95    Other,
96}
97
98impl Category {
99    /// The FR-009 wire token for this category (`table`/`json` output and `--fail-on`).
100    #[must_use]
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Self::Outdated => "outdated",
104            Self::Yanked => "yanked",
105            Self::Vulnerable => "vulnerable",
106            Self::Unsatisfiable => "unsatisfiable",
107            Self::MutableRefPin => "mutable-ref",
108            Self::License => "license",
109            Self::Deprecated => "deprecated",
110            Self::Other => "other",
111        }
112    }
113
114    /// A one-line human-readable description, longer than [`Self::as_str`]'s wire token —
115    /// used as SARIF rule metadata (`crate::format::sarif`) for a rule that only has
116    /// category-level granularity (no finer diagnostic code). Deliberately close in wording
117    /// to each variant's own doc comment above (both describe the same category, and SARIF's
118    /// `shortDescription` is meant to read like ordinary documentation prose) rather than a
119    /// genuinely distinct text written just to avoid the overlap.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use deps_cli::report::Category;
125    ///
126    /// assert_eq!(
127    ///     Category::Vulnerable.description(),
128    ///     "A known security advisory affects the in-use version."
129    /// );
130    /// assert_eq!(
131    ///     Category::License.description(),
132    ///     "The resolved license violates the configured allow/deny policy."
133    /// );
134    /// ```
135    #[must_use]
136    pub const fn description(self) -> &'static str {
137        match self {
138            Self::Outdated => {
139                "A newer version is published for the dependency's declared requirement."
140            }
141            Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
142            Self::Vulnerable => "A known security advisory affects the in-use version.",
143            Self::Unsatisfiable => "No published version satisfies the declared requirement.",
144            Self::MutableRefPin => {
145                "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
146            }
147            Self::License => "The resolved license violates the configured allow/deny policy.",
148            Self::Deprecated => {
149                "The registry reports the package itself as deprecated or abandoned."
150            }
151            Self::Other => "A finding that does not map to any of the other categories.",
152        }
153    }
154}
155
156impl std::fmt::Display for Category {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.write_str(self.as_str())
159    }
160}
161
162// Manual `Serialize`/`Deserialize` (#1626), not `#[derive(Serialize)]` +
163// `#[serde(rename_all = ...)]`: the derived unit-variant `Serialize` impl
164// (`serialize_unit_variant`) was empirically reproduced to make
165// `insta::assert_json_snapshot!` panic ("cannot serialize maps without string keys to JSON")
166// when this type was used as a raw `BTreeMap` key. `ReportDocument::summary` now serializes
167// through its own `serialize_with` (`serialize_summary_lexicographically`) instead of relying
168// on this impl for its keys, but this `serialize_str` impl is kept so `Category` still reads
169// as a plain string in every other context (the `FindingDocument::category` field, and
170// `summary`'s `Deserialize` side).
171impl serde::Serialize for Category {
172    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
173        serializer.serialize_str(self.as_str())
174    }
175}
176
177impl<'de> serde::Deserialize<'de> for Category {
178    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
179        let token = String::deserialize(deserializer)?;
180        match token.as_str() {
181            "outdated" => Ok(Self::Outdated),
182            "yanked" => Ok(Self::Yanked),
183            "vulnerable" => Ok(Self::Vulnerable),
184            "unsatisfiable" => Ok(Self::Unsatisfiable),
185            "mutable-ref" => Ok(Self::MutableRefPin),
186            "license" => Ok(Self::License),
187            "deprecated" => Ok(Self::Deprecated),
188            "other" => Ok(Self::Other),
189            other => Err(serde::de::Error::unknown_variant(
190                other,
191                &[
192                    "outdated",
193                    "yanked",
194                    "vulnerable",
195                    "unsatisfiable",
196                    "mutable-ref",
197                    "license",
198                    "deprecated",
199                    "other",
200                ],
201            )),
202        }
203    }
204}
205
206/// One reported issue, derived 1:1 from a [`Diagnostic`] `generate_diagnostics` produced.
207#[derive(Debug, Clone)]
208pub struct CheckFinding {
209    /// Which ecosystem's manifest this finding came from.
210    pub ecosystem: EcosystemId,
211    /// Path to the manifest, relative to the walked root when discovered by [`crate::walk`].
212    /// Passed through `crate::sanitize::sanitize_path_for_display` (#1299) at `to_finding`
213    /// construction time, so a raw ANSI escape byte or bidi-override character embedded in
214    /// the walked path never reaches the table or JSON sink unescaped. Every other
215    /// `deps-cli` warning sink sanitizes at its own construction boundary the same way — see
216    /// that module's doc.
217    pub manifest_path: PathBuf,
218    /// The dependency's declared name, when a manifest occurrence's range matched the
219    /// diagnostic's own range. `None` for a document-level finding not anchored to one
220    /// dependency (e.g. an offline/dependency-count notice). Passed through
221    /// [`deps_core::lsp_helpers::redact_name_for_diagnostic`] (#1242, #1246), so this is
222    /// never the raw manifest value.
223    pub dependency_name: Option<String>,
224    /// The dependency's declared version requirement, when known. Passed through
225    /// [`deps_core::lsp_helpers::redact_requirement_for_diagnostic`] (#1258, #1300), so this
226    /// is never the raw manifest value either.
227    pub requirement: Option<String>,
228    /// The classified category (see [`Category`]).
229    pub category: Category,
230    /// The diagnostic's own `code`, when it carried a string one (spec 062 review S3,
231    /// issue #1075): a vulnerability diagnostic's code is an OSV advisory id
232    /// (`RUSTSEC-...`/`GHSA-...`), finer-grained than [`Self::category`]; the workspace's
233    /// other stable diagnostic-code constants (`UNSATISFIABLE_DIAGNOSTIC_CODE`, etc.) are
234    /// 1:1 with a category, so carrying them here changes nothing beyond echoing
235    /// `category`. `None` when `classify` fell back to matching the diagnostic's message
236    /// text instead of its code (`Outdated`/`Yanked`/`Other`).
237    pub code: Option<String>,
238    /// The advisory's own `https://osv.dev/vulnerability/{id}` page, when [`Self::code`] is
239    /// an OSV advisory id — sourced from the diagnostic's `code_description.href` (already
240    /// `Uri`-parsed and validated by `deps_core::lsp_helpers::diagnostics::
241    /// push_vulnerability_diagnostics` before it ever reaches a `Diagnostic`), not
242    /// re-derived from `code` — the authoritative URL OSV itself gave us, rather than a
243    /// second, redundant formula that could drift from it. `None` whenever `code_description`
244    /// is absent (every non-advisory finding, and the rare case where OSV's own `url` failed
245    /// `Uri` parsing upstream).
246    pub advisory_url: Option<String>,
247    /// The OSV-derived severity bucket for [`Self::code`], when it names an advisory this
248    /// manifest's OSV scan actually fetched (issue #1077 C2) — looked up by advisory id from
249    /// the same scan results `generate_diagnostics` consumed, not re-derived from
250    /// [`Self::severity`] (the three-bucket [`Severity`] `code` already collapsed into is too
251    /// coarse to recover a CVSS-style grade from). `None` for every non-advisory finding, and
252    /// for an advisory `code` this run's scan did not itself fetch (e.g. a stale `code` from a
253    /// formatter that does not source it from a live scan).
254    pub advisory_severity: Option<VulnSeverity>,
255    /// The diagnostic's severity.
256    pub severity: Severity,
257    /// The diagnostic's range within the manifest.
258    pub range: Range,
259    /// The diagnostic's human-readable message.
260    pub message: String,
261}
262
263/// The full result of one `check` invocation.
264#[derive(Debug, Clone, Default)]
265pub struct CheckReport {
266    /// Every finding produced across every walked manifest.
267    pub findings: Vec<CheckFinding>,
268}
269
270impl CheckReport {
271    /// Per-category finding counts, derived from [`Self::findings`] rather than kept as
272    /// separately mutated state (spec 062 tasks.md T020).
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use deps_cli::report::{Category, CheckFinding, CheckReport};
278    /// use deps_core::EcosystemId;
279    /// use deps_core::diagnostic::Severity;
280    /// use deps_core::position::Range;
281    /// use std::path::PathBuf;
282    ///
283    /// let report = CheckReport {
284    ///     findings: vec![CheckFinding {
285    ///         ecosystem: EcosystemId::Cargo,
286    ///         manifest_path: PathBuf::from("Cargo.toml"),
287    ///         dependency_name: Some("serde".to_string()),
288    ///         requirement: Some("1.0".to_string()),
289    ///         category: Category::Outdated,
290    ///         code: None,
291    ///         advisory_url: None,
292    ///         advisory_severity: None,
293    ///         severity: Severity::Hint,
294    ///         range: Range::default(),
295    ///         message: "Newer version available: 1.1".to_string(),
296    ///     }],
297    /// };
298    /// assert_eq!(report.summary().get(&Category::Outdated), Some(&1));
299    /// ```
300    #[must_use]
301    pub fn summary(&self) -> BTreeMap<Category, usize> {
302        let mut counts = BTreeMap::new();
303        for finding in &self.findings {
304            *counts.entry(finding.category).or_insert(0_usize) += 1;
305        }
306        counts
307    }
308}
309
310/// The set of categories that turn at least one matching [`CheckFinding`] into a
311/// process-exit-1 policy violation (FR-009/FR-010).
312#[derive(Debug, Clone)]
313pub struct FailOnPolicy {
314    categories: Vec<Category>,
315}
316
317impl FailOnPolicy {
318    /// Builds a policy from an explicit category list.
319    #[must_use]
320    pub const fn new(categories: Vec<Category>) -> Self {
321        Self { categories }
322    }
323
324    /// The default policy (FR-010): `vulnerable,yanked,unsatisfiable` — the categories that
325    /// represent a broken or unsafe build, as opposed to advisory-only categories.
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use deps_cli::report::{Category, FailOnPolicy};
331    ///
332    /// let policy = FailOnPolicy::default_categories();
333    /// assert!(policy.categories().contains(&Category::Vulnerable));
334    /// assert!(!policy.categories().contains(&Category::Outdated));
335    /// ```
336    #[must_use]
337    pub fn default_categories() -> Self {
338        Self::new(vec![
339            Category::Vulnerable,
340            Category::Yanked,
341            Category::Unsatisfiable,
342        ])
343    }
344
345    /// The categories this policy fails on.
346    #[must_use]
347    pub fn categories(&self) -> &[Category] {
348        &self.categories
349    }
350
351    /// Whether any finding in `findings` matches this policy.
352    #[must_use]
353    pub fn matches(&self, findings: &[CheckFinding]) -> bool {
354        findings
355            .iter()
356            .any(|finding| self.categories.contains(&finding.category))
357    }
358}
359
360impl Default for FailOnPolicy {
361    fn default() -> Self {
362        Self::default_categories()
363    }
364}
365
366/// Runtime handles and resolved policy shared across every manifest a `check` run classifies.
367///
368/// Built once in `main.rs` (or by an integration test) and passed by reference to
369/// [`check_manifest`] for every discovered manifest.
370#[derive(Clone)]
371pub struct CheckContext {
372    /// Shared HTTP cache for registry requests.
373    pub cache: Arc<HttpCache>,
374    /// Shared OSV.dev vulnerability scan client.
375    pub osv: Arc<OsvClient>,
376    /// Shared deps.dev client for GOSSIP cooldown findings (spec 074) — always constructed,
377    /// regardless of `policy.gossip.enabled`; see `deps-cli::main::RuntimeHandles::deps_dev`'s
378    /// doc for why. [`crate::analyze::analyze_manifest`] passes it through as
379    /// `Option<&Arc<DepsDevClient>>`, `None` when GOSSIP is disabled.
380    pub deps_dev: Arc<deps_core::DepsDevClient>,
381    /// Shared lock-file cache, keyed by resolved lockfile path.
382    pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
383    /// The resolved policy configuration for this run.
384    pub policy: PolicyConfig,
385}
386
387/// Error running [`check_manifest`] for one manifest.
388#[derive(Debug, thiserror::Error)]
389pub enum CheckError {
390    /// The manifest content could not be parsed by its ecosystem's parser.
391    #[error("failed to parse {path}: {source}")]
392    Parse {
393        /// The manifest path that failed to parse.
394        path: PathBuf,
395        /// The underlying parse error.
396        #[source]
397        source: deps_core::DepsError,
398    },
399    /// `manifest_path` could not be represented as a file URI (e.g. a malformed or
400    /// non-representable path on this platform).
401    #[error("could not build a file URI for {path}")]
402    InvalidPath {
403        /// The manifest path that could not be converted.
404        path: PathBuf,
405    },
406}
407
408/// The result of classifying one manifest.
409#[derive(Debug, Clone)]
410pub struct ManifestCheckResult {
411    /// Every finding produced for this manifest.
412    pub findings: Vec<CheckFinding>,
413    /// Whether at least one dependency's *version* registry fetch failed while not offline
414    /// (FR-012) — the caller uses this to decide between exit 1 (policy violation over an
415    /// otherwise complete report) and exit 2 (an incomplete report because a registry was
416    /// unreachable).
417    ///
418    /// Deliberately does **not** cover a tier-3 license-source timeout (Dart/Swift/Gradle/
419    /// Deno) — see [`Self::license_fetch_incomplete`] — a Maven Central outage while the
420    /// version registry itself is fully reachable is a different failure with a different
421    /// remediation (issue #1133 code-review finding #1), and conflating the two would mislead
422    /// a caller inspecting this field into diagnosing the wrong system.
423    pub registry_unreachable: bool,
424    /// Whether at least one dependency's tier-3 license fetch (Dart/Swift/Gradle/Deno) timed
425    /// out while not offline (issue #1133 code-review finding #1) — kept separate from
426    /// [`Self::registry_unreachable`] since the two name genuinely different subsystems (the
427    /// license source vs. the version registry), even though both feed the same exit-2
428    /// "incomplete report" signal in `main.rs`. See
429    /// `deps_engine::classify::license::TierThreeLicenseFetch::timed_out`'s doc for exactly
430    /// which failure modes this can (and cannot) detect.
431    pub license_fetch_incomplete: bool,
432}
433
434/// Classifies one already-routed manifest.
435///
436/// Parses it, resolves in-use/lockfile versions, fetches latest registry versions, runs an
437/// OSV scan (when enabled and not offline), and calls the ecosystem's own
438/// `generate_diagnostics` — then converts each returned [`Diagnostic`] into a
439/// [`CheckFinding`].
440///
441/// Every verdict decision (outdated/yanked/vulnerable/unsatisfiable/deprecated/license) is
442/// made by [`deps_engine::classify`] or by `generate_diagnostics` itself; this function only
443/// assembles their inputs and converts their output (spec 062 FR-005).
444///
445/// # Errors
446///
447/// Returns [`CheckError::InvalidPath`] if `manifest_path` cannot be represented as a file
448/// URI, or [`CheckError::Parse`] if `content` fails to parse as this ecosystem's manifest
449/// format.
450pub async fn check_manifest(
451    ecosystem: &Arc<dyn Ecosystem>,
452    manifest_path: &Path,
453    display_path: &Path,
454    content: &str,
455    ctx: &CheckContext,
456) -> Result<ManifestCheckResult, CheckError> {
457    let analysis = crate::analyze::analyze_manifest(
458        ecosystem,
459        manifest_path,
460        content,
461        ctx,
462        crate::analyze::AnalysisScope::all(),
463    )
464    .await?;
465    let formatter = ecosystem.formatter();
466
467    let severities = ctx.policy.diagnostics.to_severities();
468    let diagnostics = ecosystem
469        .generate_diagnostics(
470            analysis.parse_result.as_ref(),
471            analysis.version_data(),
472            &analysis.uri,
473            ctx.policy.freshness.to_freshness(),
474            severities,
475        )
476        .await;
477
478    let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
479    // TODO(#1726): mark advisories matched only through a sibling release tag in the report.
480    let advisory_severities = advisory_severity_index(analysis.vulnerabilities.as_ref());
481    // Same per-occurrence key `vulnerabilities` was built under, so a shared advisory id
482    // across two dependencies can never resolve to the wrong one's severity (issue #1077 review #4).
483    let vuln_keys = deps_core::osv::vulnerability_keys(
484        analysis.parse_result.as_ref(),
485        &analysis.resolved_versions,
486        Some(&analysis.resolved_version_candidates),
487        formatter,
488        analysis.ecosystem_id,
489    );
490    let findings = diagnostics
491        .into_iter()
492        .map(|diagnostic| {
493            to_finding(
494                analysis.ecosystem_id,
495                display_path,
496                &dep_index,
497                formatter,
498                diagnostic,
499                &advisory_severities,
500                &vuln_keys,
501                &analysis.cached_versions,
502            )
503        })
504        .collect();
505    Ok(ManifestCheckResult {
506        findings,
507        registry_unreachable: analysis.registry_unreachable,
508        license_fetch_incomplete: analysis.license_fetch_incomplete,
509    })
510}
511
512/// Indexes every dependency occurrence by its name range and (separately) its version
513/// range, so a diagnostic anchored at either can be traced back to the declaration that
514/// produced it (see [`to_finding`]).
515///
516/// Two separate maps (M6, spec 062 review) rather than one shared `HashMap<Range, _>`: a
517/// single map risks one dependency's name-range entry silently overwriting a *different*
518/// dependency's version-range entry if the two ranges ever coincide, misattributing
519/// `dependency_name`. Keeping the two lookups apart makes that impossible regardless of
520/// range values, not just unlikely in practice.
521struct DependencyIndex<'a> {
522    by_name_range: HashMap<Range, &'a dyn Dependency>,
523    by_version_range: HashMap<Range, &'a dyn Dependency>,
524}
525
526impl<'a> DependencyIndex<'a> {
527    fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
528        let mut by_name_range = HashMap::new();
529        let mut by_version_range = HashMap::new();
530        for dep in parse_result.dependencies() {
531            if !dep.name_range_is_synthetic() {
532                by_name_range.insert(dep.name_range(), dep);
533            }
534            if let Some(version_range) = dep.version_range() {
535                by_version_range.insert(version_range, dep);
536            }
537        }
538        Self {
539            by_name_range,
540            by_version_range,
541        }
542    }
543
544    /// Looks up the dependency a diagnostic's `range` is anchored to, preferring a
545    /// name-range match (diagnostics anchored at `resolved.version_range` still resolve via
546    /// the version-range map when no name-range entry matches the same coordinates).
547    fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
548        self.by_name_range
549            .get(&range)
550            .or_else(|| self.by_version_range.get(&range))
551            .copied()
552    }
553}
554
555/// Indexes every advisory this manifest's OSV scan fetched, keyed by (the [`VulnerabilityMap`]
556/// key identifying the specific dependency occurrence, advisory id) rather than by advisory id
557/// alone (issue #1077 review #4), so [`to_finding`] can attach a
558/// [`CheckFinding::advisory_severity`] without re-deriving a grade from the coarser
559/// [`Severity`] a `Diagnostic` carries.
560///
561/// A bare `HashMap<String, VulnSeverity>` keyed only by advisory id would let one dependency's
562/// severity silently overwrite another's (unspecified `HashMap` iteration order) whenever two
563/// dependencies in this manifest shared an advisory id — OSV can legitimately report one id
564/// against several different packages in the same record. [`to_finding`] looks its own
565/// dependency occurrence's key up via [`deps_core::osv::vulnerability_keys`] — the same
566/// function `vulnerabilities` itself was keyed under — so this can never drift out of sync
567/// with how the scan actually mapped occurrences to results.
568///
569/// `None` `vulnerabilities` (scan disabled, or offline) yields an empty index, same as a
570/// (key, id) pair this index has no entry for — both resolve to
571/// `CheckFinding::advisory_severity: None`.
572fn advisory_severity_index(
573    vulnerabilities: Option<&VulnerabilityMap>,
574) -> HashMap<(deps_core::osv::VulnKey, String), VulnSeverity> {
575    let mut index = HashMap::new();
576    let Some(vulnerabilities) = vulnerabilities else {
577        return index;
578    };
579    for (dependency_key, outcome) in vulnerabilities {
580        if let ScanOutcome::Vulnerable(dv) = outcome {
581            for advisory in dv.advisories.items() {
582                index.insert(
583                    (dependency_key.clone(), advisory.id.clone()),
584                    advisory.severity,
585                );
586            }
587        }
588    }
589    index
590}
591
592/// Converts one `generate_diagnostics` [`Diagnostic`] into a [`CheckFinding`], classifying
593/// its [`Category`] (see [`classify`]) and, when its range matches a manifest occurrence
594/// (from [`DependencyIndex`]), its `dependency_name`/`requirement`. `advisory_severities` and
595/// `vuln_keys` together resolve [`CheckFinding::advisory_severity`] — see
596/// [`advisory_severity_index`].
597#[allow(
598    clippy::too_many_arguments,
599    reason = "internal (non-pub) call-site-controlled classification/attribution inputs; \
600              grouping into a struct would only move, not reduce, the single production \
601              call site's churn"
602)]
603fn to_finding(
604    ecosystem: EcosystemId,
605    display_path: &Path,
606    dep_index: &DependencyIndex<'_>,
607    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
608    diagnostic: Diagnostic,
609    advisory_severities: &HashMap<(deps_core::osv::VulnKey, String), VulnSeverity>,
610    vuln_keys: &VulnKeys,
611    cached_versions: &HashMap<deps_core::PackageName, deps_core::lsp_helpers::PackageVersions>,
612) -> CheckFinding {
613    let category = classify(&diagnostic, formatter);
614    let dep = dep_index.lookup(diagnostic.range);
615    let code = diagnostic.code().map(str::to_string);
616    let advisory_url = diagnostic
617        .code_description
618        .as_ref()
619        .map(|code_description| code_description.href.as_str().to_string());
620    let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
621        // Deliberately narrower than resolve_scan_outcome: None on synthetic ranges, no further fallback.
622        if dep.name_range_is_synthetic() {
623            return None;
624        }
625        let dependency_key = deps_core::osv::vuln_key_for(dep, Some(vuln_keys), formatter);
626        advisory_severities
627            .get(&(dependency_key, code.to_string()))
628            .copied()
629    });
630    // Spec 074 FR-005: attribute an `Outdated` finding to an active GOSSIP cooldown when it
631    // is the sole reason a newer version was excluded from being "latest" — matched by the
632    // occurrence's own (pre-redaction) `PackageName`, never `CheckFinding::dependency_name`
633    // (already redacted by this point, and not guaranteed to equal `cached_versions`'s raw
634    // key). No `deps-core` change: this only reads the additive
635    // `PackageVersions::gossip_excluded_version` field `deps-engine`'s fetch already set.
636    let mut message = diagnostic.message().to_string();
637    if category == Category::Outdated
638        && let Some(dep) = dep
639        && cached_versions
640            .get(dep.name())
641            .is_some_and(|v| v.gossip_excluded_version.is_some())
642    {
643        message.push_str(
644            " (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
645        );
646    }
647    CheckFinding {
648        ecosystem,
649        manifest_path: crate::sanitize::sanitize_path_for_display(display_path),
650        dependency_name: dep.map(|d| redact_name_for_diagnostic(d.name())),
651        requirement: dep
652            .and_then(Dependency::version_requirement)
653            .map(redact_requirement_for_diagnostic),
654        category,
655        code,
656        advisory_url,
657        advisory_severity,
658        severity: diagnostic.severity.unwrap_or(Severity::Warning),
659        range: diagnostic.range,
660        message,
661    }
662}
663
664/// Classifies a `generate_diagnostics` [`Diagnostic`] into a [`Category`].
665///
666/// Diagnostics that carry one of the workspace's known non-advisory codes (the three
667/// `deps-core` sentinels, the two mutable-ref-pin codes, or GitLab CI's
668/// [`GITLAB_CI_UNRESOLVED_HOST_CODE`] notice) classify directly from `code`. A vulnerability
669/// advisory id (an OSV id such as `RUSTSEC-...`/`GHSA-...`) is the only other free-form `code`
670/// value `generate_diagnostics_from_cache` ever sets, so any `Some(code)` that matches none of
671/// the known non-advisory codes classifies as [`Category::Vulnerable`] — this fallback is
672/// sound only as long as the known-code list above stays exhaustive (see that list's own doc
673/// for the regression this already caused once). An outdated diagnostic carries no code but
674/// always starts with the fixed prefix `apply_outdated_rule` uses; a yanked diagnostic carries
675/// no code either, but always contains `formatter.yanked_message()` verbatim — the same text
676/// it was built from. The advisory-overflow summary line ("+N more advisories") also carries
677/// no code but always ends with that fixed suffix, and is still [`Category::Vulnerable`].
678/// Anything else (unknown-package, collapsed fetch-failure, blocked-registry,
679/// offline/dependency-count notices) classifies as [`Category::Other`].
680fn classify(
681    diagnostic: &Diagnostic,
682    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
683) -> Category {
684    if let Some(code) = diagnostic.code() {
685        return match code {
686            UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
687            LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
688            DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
689            GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
690                Category::MutableRefPin
691            }
692            GITLAB_CI_UNRESOLVED_HOST_CODE | GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE => {
693                Category::Other
694            }
695            _ => Category::Vulnerable,
696        };
697    }
698    if diagnostic.message().starts_with("Newer version available") {
699        return Category::Outdated;
700    }
701    if diagnostic.message().contains(formatter.yanked_message()) {
702        return Category::Yanked;
703    }
704    // The advisory-overflow summary line carries no code but is still a vulnerability finding
705    // (M1, spec 062 review) — else a manifest over `ADVISORY_DISPLAY_CAP` reports it as `Other`.
706    if diagnostic.message().ends_with("more advisories") {
707        return Category::Vulnerable;
708    }
709    Category::Other
710}
711
712/// Builds a file URI from a filesystem path, without any path-existence check. Returns
713/// `None` when `path` cannot be represented as a file URI at all — `Url::from_file_path`
714/// requires an absolute path (this function already joins a relative one onto the current
715/// directory first) and, on Windows, a disk (`C:`) or UNC (`\\`) prefix; UNC paths
716/// themselves are supported, just not any other Windows path prefix shape. The caller
717/// surfaces a `None` here as [`CheckError::InvalidPath`] rather than fabricating a
718/// synthetic, unusable URI.
719pub(crate) fn path_to_uri(path: &Path) -> Option<url::Url> {
720    let absolute = if path.is_absolute() {
721        path.to_path_buf()
722    } else {
723        std::env::current_dir()
724            .map(|cwd| cwd.join(path))
725            .unwrap_or_else(|_| path.to_path_buf())
726    };
727    url::Url::from_file_path(&absolute).ok()
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use deps_core::PackageName;
734
735    fn finding(category: Category) -> CheckFinding {
736        CheckFinding {
737            ecosystem: EcosystemId::Cargo,
738            manifest_path: PathBuf::from("Cargo.toml"),
739            dependency_name: Some("serde".to_string()),
740            requirement: Some("1.0".to_string()),
741            category,
742            code: None,
743            advisory_url: None,
744            advisory_severity: None,
745            severity: Severity::Warning,
746            range: Range::default(),
747            message: "test".to_string(),
748        }
749    }
750
751    #[test]
752    fn test_category_as_str_matches_fr009_tokens() {
753        assert_eq!(Category::Outdated.as_str(), "outdated");
754        assert_eq!(Category::Yanked.as_str(), "yanked");
755        assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
756        assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
757        assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
758        assert_eq!(Category::License.as_str(), "license");
759        assert_eq!(Category::Deprecated.as_str(), "deprecated");
760        assert_eq!(Category::Other.as_str(), "other");
761    }
762
763    /// #1626: `Serialize` must emit the same tokens as [`Category::as_str`], byte-identical
764    /// (quoted JSON strings), for every variant — including `MutableRefPin`'s explicit rename.
765    #[test]
766    fn test_category_serialize_matches_as_str_tokens() {
767        for category in [
768            Category::Outdated,
769            Category::Yanked,
770            Category::Vulnerable,
771            Category::Unsatisfiable,
772            Category::MutableRefPin,
773            Category::License,
774            Category::Deprecated,
775            Category::Other,
776        ] {
777            let json = serde_json::to_string(&category).expect("Category must serialize");
778            assert_eq!(json, format!("\"{}\"", category.as_str()));
779            let parsed: Category = serde_json::from_str(&json).expect("must round-trip");
780            assert_eq!(parsed, category);
781        }
782    }
783
784    /// #1626 tester gap 1: an unrecognized `category` token must be a hard deserialize error
785    /// (the `unknown_variant` arm of `Category`'s manual `Deserialize` impl above), not
786    /// silently accepted or defaulted to [`Category::Other`].
787    #[test]
788    fn test_category_deserialize_rejects_unknown_token() {
789        let result: Result<Category, _> = serde_json::from_str("\"not-a-real-category\"");
790        assert!(result.is_err());
791    }
792
793    #[test]
794    fn test_fail_on_policy_default_categories() {
795        let policy = FailOnPolicy::default_categories();
796        assert!(policy.matches(&[finding(Category::Vulnerable)]));
797        assert!(policy.matches(&[finding(Category::Yanked)]));
798        assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
799        assert!(!policy.matches(&[finding(Category::Outdated)]));
800        assert!(!policy.matches(&[finding(Category::License)]));
801        assert!(!policy.matches(&[finding(Category::Deprecated)]));
802        assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
803        assert!(!policy.matches(&[finding(Category::Other)]));
804    }
805
806    #[test]
807    fn test_fail_on_policy_custom_categories() {
808        let policy = FailOnPolicy::new(vec![Category::License]);
809        assert!(policy.matches(&[finding(Category::License)]));
810        assert!(!policy.matches(&[finding(Category::Vulnerable)]));
811    }
812
813    #[test]
814    fn test_fail_on_policy_empty_findings_never_matches() {
815        assert!(!FailOnPolicy::default_categories().matches(&[]));
816    }
817
818    #[test]
819    fn test_check_report_summary_counts_per_category() {
820        let report = CheckReport {
821            findings: vec![
822                finding(Category::Outdated),
823                finding(Category::Outdated),
824                finding(Category::Vulnerable),
825            ],
826        };
827        let summary = report.summary();
828        assert_eq!(summary.get(&Category::Outdated), Some(&2));
829        assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
830        assert_eq!(summary.get(&Category::License), None);
831    }
832
833    #[test]
834    fn test_check_report_summary_empty_for_no_findings() {
835        let report = CheckReport::default();
836        assert!(report.summary().is_empty());
837    }
838
839    const STUB_FORMATTER: deps_core::test_util::StubFormatter =
840        deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
841
842    fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
843        let diagnostic =
844            Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
845        match code {
846            Some(code) => diagnostic.with_code(code),
847            None => diagnostic,
848        }
849    }
850
851    #[test]
852    fn test_classify_unsatisfiable_by_code() {
853        let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
854        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Unsatisfiable);
855    }
856
857    #[test]
858    fn test_classify_license_by_code() {
859        let d = diagnostic_with(
860            Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
861            "GPL-3.0 denied",
862        );
863        assert_eq!(classify(&d, &STUB_FORMATTER), Category::License);
864    }
865
866    #[test]
867    fn test_classify_deprecated_by_code() {
868        let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
869        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Deprecated);
870    }
871
872    #[test]
873    fn test_classify_mutable_ref_pin_by_code() {
874        let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
875        assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
876        let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
877        assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
878    }
879
880    #[test]
881    fn test_classify_sha_comment_mismatch_is_other() {
882        let d = diagnostic_with(
883            Some(GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE),
884            "SHA is not the commit of the tag in the comment",
885        );
886        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
887    }
888
889    #[test]
890    fn test_classify_advisory_code_is_vulnerable() {
891        let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
892        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
893    }
894
895    #[test]
896    fn test_classify_outdated_by_message_prefix() {
897        let d = diagnostic_with(None, "Newer version available: 2.0.0");
898        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Outdated);
899    }
900
901    #[test]
902    fn test_classify_yanked_by_formatter_message() {
903        let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
904        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Yanked);
905    }
906
907    #[test]
908    fn test_classify_unknown_package_is_other() {
909        let d = diagnostic_with(None, "Unknown package 'left-pad'");
910        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
911    }
912
913    /// Regression test for M1 (spec 062 review): the trailing "+N more advisories" overflow
914    /// summary carries no code but is still a vulnerability finding, not `Other`.
915    #[test]
916    fn test_classify_advisory_overflow_summary_is_vulnerable() {
917        let d = diagnostic_with(None, "+5 more advisories");
918        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
919    }
920
921    /// Regression test for C3 (spec 062 review): GitLab CI's `unresolved-gitlab-host` notice
922    /// is informational (INFORMATION severity, never a vulnerability) and must not fall
923    /// through to the advisory-id fallback.
924    #[test]
925    fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
926        let d = diagnostic_with(
927            Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
928            "registries.gitlab_instance_host is unset; skipping component/project host resolution",
929        );
930        assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
931    }
932
933    /// A single-dependency parse result whose one dependency ("dep-0") sits at
934    /// `Range::default()` (`deps_core::test_util::StubDependency::name_range` always
935    /// returns it) — matching `diagnostic_with`'s own hardcoded `range: Range::default()`
936    /// (both the same `deps_core::position::Range` `DependencyIndex` is keyed on), so
937    /// `DependencyIndex::lookup` resolves it for tests that need a real, non-`None`
938    /// dependency occurrence.
939    fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
940        deps_core::test_util::stub_parse_result_with_dependencies(1)
941    }
942
943    fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
944        deps_core::test_util::stub_parse_result_with_dependencies(0)
945    }
946
947    /// A single-dependency [`deps_core::Dependency`]/[`deps_core::ParseResult`] fixture whose
948    /// name (and, for the #1258/#1300 requirement-redaction tests, requirement) is
949    /// caller-controlled — unlike [`dep_index_with_one_dependency`]'s fixed `dep-0`, needed
950    /// to exercise `to_finding`'s `dependency_name`/`requirement` redaction (#1242, #1246,
951    /// #1258, #1300) with attacker-controlled manifest values.
952    struct NamedFixtureDep {
953        name: PackageName,
954        requirement: Option<deps_core::VersionReq>,
955    }
956
957    impl deps_core::Dependency for NamedFixtureDep {
958        fn name(&self) -> &PackageName {
959            &self.name
960        }
961        fn name_range(&self) -> Range {
962            Range::default()
963        }
964        fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
965            self.requirement.as_ref()
966        }
967        fn version_range(&self) -> Option<Range> {
968            None
969        }
970        fn source(&self) -> deps_core::parser::DependencySource {
971            deps_core::parser::DependencySource::Registry
972        }
973        fn as_any(&self) -> &dyn std::any::Any {
974            self
975        }
976    }
977
978    struct NamedFixtureParseResult {
979        dep: NamedFixtureDep,
980        uri: url::Url,
981    }
982
983    impl deps_core::ParseResult for NamedFixtureParseResult {
984        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
985            vec![&self.dep]
986        }
987        fn workspace_root(&self) -> Option<&Path> {
988            None
989        }
990        fn uri(&self) -> &url::Url {
991            &self.uri
992        }
993        fn as_any(&self) -> &dyn std::any::Any {
994            self
995        }
996    }
997
998    fn dep_index_with_named_dependency(name: &str) -> Box<dyn deps_core::ParseResult> {
999        Box::new(NamedFixtureParseResult {
1000            dep: NamedFixtureDep {
1001                name: PackageName::new(name),
1002                requirement: None,
1003            },
1004            uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1005        })
1006    }
1007
1008    /// Like [`dep_index_with_named_dependency`], but also setting the dependency's
1009    /// requirement — needed by the #1258/#1300 requirement-redaction regression tests.
1010    fn dep_index_with_named_dependency_and_requirement(
1011        name: &str,
1012        requirement: &str,
1013    ) -> Box<dyn deps_core::ParseResult> {
1014        Box::new(NamedFixtureParseResult {
1015            dep: NamedFixtureDep {
1016                name: PackageName::new(name),
1017                requirement: Some(deps_core::VersionReq::new(requirement)),
1018            },
1019            uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1020        })
1021    }
1022
1023    /// Regression test (issue #1077 tester must-fix #2): every other SARIF test hand-builds a
1024    /// `CheckFinding` with `code` pre-set, bypassing the real `Diagnostic.code ->
1025    /// CheckFinding.code` extraction this covers.
1026    #[test]
1027    fn test_to_finding_extracts_string_code_from_diagnostic() {
1028        let parse_result = empty_dep_index();
1029        let dep_index = DependencyIndex::build(parse_result.as_ref());
1030        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1031        let finding = to_finding(
1032            EcosystemId::Cargo,
1033            Path::new("Cargo.toml"),
1034            &dep_index,
1035            &STUB_FORMATTER,
1036            diagnostic,
1037            &HashMap::new(),
1038            &VulnKeys::default(),
1039            &HashMap::new(),
1040        );
1041        assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
1042    }
1043
1044    #[test]
1045    fn test_to_finding_code_is_none_without_a_diagnostic_code() {
1046        let parse_result = empty_dep_index();
1047        let dep_index = DependencyIndex::build(parse_result.as_ref());
1048        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1049        let finding = to_finding(
1050            EcosystemId::Cargo,
1051            Path::new("Cargo.toml"),
1052            &dep_index,
1053            &STUB_FORMATTER,
1054            diagnostic,
1055            &HashMap::new(),
1056            &VulnKeys::default(),
1057            &HashMap::new(),
1058        );
1059        assert!(finding.code.is_none());
1060    }
1061
1062    /// Spec 074 FR-005: an `Outdated` finding whose dependency has a
1063    /// `gossip_excluded_version` in `cached_versions` is attributed to GOSSIP in its message.
1064    #[test]
1065    fn test_to_finding_attributes_outdated_to_gossip_when_excluded() {
1066        let parse_result = dep_index_with_named_dependency("serde");
1067        let dep_index = DependencyIndex::build(parse_result.as_ref());
1068        let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
1069        let mut cached_versions = HashMap::new();
1070        cached_versions.insert(
1071            PackageName::new("serde"),
1072            deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0")
1073                .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1074        );
1075        let finding = to_finding(
1076            EcosystemId::Cargo,
1077            Path::new("Cargo.toml"),
1078            &dep_index,
1079            &STUB_FORMATTER,
1080            diagnostic,
1081            &HashMap::new(),
1082            &VulnKeys::default(),
1083            &cached_versions,
1084        );
1085        assert_eq!(finding.category, Category::Outdated);
1086        assert!(
1087            finding.message.contains("GOSSIP"),
1088            "message must attribute the exclusion to GOSSIP: {:?}",
1089            finding.message
1090        );
1091    }
1092
1093    /// The same dependency with no `gossip_excluded_version` set must not gain the
1094    /// attribution suffix.
1095    #[test]
1096    fn test_to_finding_does_not_attribute_when_no_gossip_exclusion() {
1097        let parse_result = dep_index_with_named_dependency("serde");
1098        let dep_index = DependencyIndex::build(parse_result.as_ref());
1099        let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
1100        let mut cached_versions = HashMap::new();
1101        cached_versions.insert(
1102            PackageName::new("serde"),
1103            deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0"),
1104        );
1105        let finding = to_finding(
1106            EcosystemId::Cargo,
1107            Path::new("Cargo.toml"),
1108            &dep_index,
1109            &STUB_FORMATTER,
1110            diagnostic,
1111            &HashMap::new(),
1112            &VulnKeys::default(),
1113            &cached_versions,
1114        );
1115        assert_eq!(finding.category, Category::Outdated);
1116        assert!(!finding.message.contains("GOSSIP"));
1117    }
1118
1119    #[test]
1120    fn test_to_finding_extracts_advisory_url_from_code_description() {
1121        let parse_result = empty_dep_index();
1122        let dep_index = DependencyIndex::build(parse_result.as_ref());
1123        let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
1124            .parse()
1125            .expect("valid URL");
1126        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
1127            .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
1128        let finding = to_finding(
1129            EcosystemId::Cargo,
1130            Path::new("Cargo.toml"),
1131            &dep_index,
1132            &STUB_FORMATTER,
1133            diagnostic,
1134            &HashMap::new(),
1135            &VulnKeys::default(),
1136            &HashMap::new(),
1137        );
1138        assert_eq!(
1139            finding.advisory_url.as_deref(),
1140            Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
1141        );
1142    }
1143
1144    #[test]
1145    fn test_to_finding_advisory_url_is_none_without_code_description() {
1146        let parse_result = empty_dep_index();
1147        let dep_index = DependencyIndex::build(parse_result.as_ref());
1148        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1149        let finding = to_finding(
1150            EcosystemId::Cargo,
1151            Path::new("Cargo.toml"),
1152            &dep_index,
1153            &STUB_FORMATTER,
1154            diagnostic,
1155            &HashMap::new(),
1156            &VulnKeys::default(),
1157            &HashMap::new(),
1158        );
1159        assert!(finding.advisory_url.is_none());
1160    }
1161
1162    /// #1242/#1246: `to_finding`'s `dependency_name` field is a second leak path,
1163    /// independent of the diagnostic's own `message` — the fix at `report.rs`'s
1164    /// `dependency_name` construction must redact it too.
1165    #[test]
1166    fn test_to_finding_redacts_credential_shaped_dependency_name() {
1167        let parse_result = dep_index_with_named_dependency(
1168            "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1169        );
1170        let dep_index = DependencyIndex::build(parse_result.as_ref());
1171        let diagnostic = diagnostic_with(None, "Unknown package");
1172
1173        let finding = to_finding(
1174            EcosystemId::Cargo,
1175            Path::new("Cargo.toml"),
1176            &dep_index,
1177            &STUB_FORMATTER,
1178            diagnostic,
1179            &HashMap::new(),
1180            &VulnKeys::default(),
1181            &HashMap::new(),
1182        );
1183
1184        let name = finding
1185            .dependency_name
1186            .expect("range matched the dependency");
1187        assert!(name.contains("***@"));
1188        assert!(!name.contains("glpat-AAAABBBBCCCCDDDD"));
1189    }
1190
1191    /// #1242/#1246 (critic S3 follow-up): unlike a test that builds a [`CheckFinding`] by
1192    /// hand and assigns an already-redacted `dependency_name`, this drives the real
1193    /// `to_finding` -> [`crate::format::sarif::to_sarif`] pipeline end to end, so it actually
1194    /// fails if `to_finding`'s redaction at `dependency_name` construction is ever reverted —
1195    /// the SARIF fingerprint's percent-encoding otherwise preserves a credential verbatim
1196    /// (trivially reversible), and GitHub code scanning persists it in alert history beyond
1197    /// the manifest's own lifetime.
1198    #[test]
1199    fn test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding() {
1200        let parse_result = dep_index_with_named_dependency(
1201            "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1202        );
1203        let dep_index = DependencyIndex::build(parse_result.as_ref());
1204        let diagnostic = diagnostic_with(None, "Unknown package");
1205
1206        let finding = to_finding(
1207            EcosystemId::Cargo,
1208            Path::new("Cargo.toml"),
1209            &dep_index,
1210            &STUB_FORMATTER,
1211            diagnostic,
1212            &HashMap::new(),
1213            &VulnKeys::default(),
1214            &HashMap::new(),
1215        );
1216
1217        let sarif = crate::format::sarif::to_sarif(&CheckReport {
1218            findings: vec![finding],
1219        });
1220        let fp = sarif.runs[0].results.as_ref().expect("one result")[0]
1221            .partial_fingerprints
1222            .as_ref()
1223            .expect("fingerprint set")
1224            .get("depsCli/v1")
1225            .expect("depsCli/v1 fingerprint key")
1226            .clone();
1227
1228        assert!(
1229            !fp.contains("glpat-AAAABBBBCCCCDDDD"),
1230            "token leaked (raw or percent-encoded, since `-` is never percent-escaped): {fp}"
1231        );
1232        let decoded = urlencoding::decode(&fp).expect("fingerprint is valid percent-encoding");
1233        assert!(
1234            decoded.contains("***@gitlab.corp"),
1235            "expected the redacted '***@host' marker, got: {decoded}"
1236        );
1237    }
1238
1239    /// Regression test for #1299: `to_finding`'s `manifest_path` construction must strip a
1240    /// raw ANSI escape byte and a bidi-override character before the finding is built, not
1241    /// leave that to each sink. Drives the real `to_finding` -> `format::table::render` /
1242    /// `format::json::to_document` pipelines end to end (mirrors
1243    /// `test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding`'s rationale
1244    /// for #1242/#1246) so it actually fails if the sanitization at construction time is ever
1245    /// reverted, while asserting the legitimate `src`/`Cargo.toml` path segments still show
1246    /// up in both sinks.
1247    #[test]
1248    fn test_to_finding_sanitizes_manifest_path_ansi_and_bidi() {
1249        let malicious_path = Path::new("src/\u{202E}\x1Bsneaky/Cargo.toml");
1250        let parse_result = empty_dep_index();
1251        let dep_index = DependencyIndex::build(parse_result.as_ref());
1252        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1253
1254        let finding = to_finding(
1255            EcosystemId::Cargo,
1256            malicious_path,
1257            &dep_index,
1258            &STUB_FORMATTER,
1259            diagnostic,
1260            &HashMap::new(),
1261            &VulnKeys::default(),
1262            &HashMap::new(),
1263        );
1264
1265        let sanitized = finding.manifest_path.to_string_lossy().into_owned();
1266        assert!(
1267            !sanitized.contains('\u{202E}'),
1268            "bidi override survived sanitization: {sanitized:?}"
1269        );
1270        assert!(
1271            !sanitized.contains('\x1B'),
1272            "raw ANSI escape byte survived sanitization: {sanitized:?}"
1273        );
1274        assert!(sanitized.contains("src"), "legitimate path info lost");
1275        assert!(
1276            sanitized.contains("Cargo.toml"),
1277            "legitimate path info lost"
1278        );
1279
1280        let report = CheckReport {
1281            findings: vec![finding],
1282        };
1283
1284        let table = crate::format::table::render(&report);
1285        assert!(
1286            !table.contains('\u{202E}'),
1287            "bidi override reached table output"
1288        );
1289        assert!(
1290            !table.contains('\x1B'),
1291            "raw ANSI escape byte reached table output"
1292        );
1293
1294        let json = crate::format::json::to_document(&report);
1295        let manifest_path_json = &json.findings[0].manifest_path;
1296        assert!(
1297            !manifest_path_json.contains('\u{202E}'),
1298            "bidi override reached JSON output"
1299        );
1300        assert!(
1301            !manifest_path_json.contains('\x1B'),
1302            "raw ANSI escape byte reached JSON output"
1303        );
1304        assert!(manifest_path_json.contains("Cargo.toml"));
1305    }
1306
1307    /// Regression test for #1258/#1300: `to_finding`'s `requirement` construction must redact
1308    /// a credential-shaped requirement string, mirroring
1309    /// `test_to_finding_redacts_credential_shaped_dependency_name`'s coverage of the sibling
1310    /// `dependency_name` field.
1311    #[test]
1312    fn test_to_finding_redacts_credential_shaped_requirement() {
1313        let parse_result = dep_index_with_named_dependency_and_requirement(
1314            "some-pkg",
1315            "https://svcacct:hunter2@gitlab.corp/g/p.git",
1316        );
1317        let dep_index = DependencyIndex::build(parse_result.as_ref());
1318        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1319
1320        let finding = to_finding(
1321            EcosystemId::Cargo,
1322            Path::new("Cargo.toml"),
1323            &dep_index,
1324            &STUB_FORMATTER,
1325            diagnostic,
1326            &HashMap::new(),
1327            &VulnKeys::default(),
1328            &HashMap::new(),
1329        );
1330
1331        let requirement = finding.requirement.expect("range matched the dependency");
1332        assert!(requirement.contains("***@"));
1333        assert!(!requirement.contains("hunter2"));
1334    }
1335
1336    /// Regression test for #1258: the requirement's own leak class (raw ANSI escape / bidi
1337    /// override in `requirement`, reaching `format::json::to_document` — `requirement` has no
1338    /// table or SARIF sink) must come out sanitized. Mirrors
1339    /// `test_to_finding_sanitizes_manifest_path_ansi_and_bidi`'s payload and rationale for
1340    /// the sibling `manifest_path` field. Supersedes #1301's near-identical
1341    /// `test_json_output_never_carries_raw_bidi_or_ansi_through_requirement` /
1342    /// `test_to_finding_sanitizes_bidi_and_ansi_in_requirement` (dropped at the #1299/#1300
1343    /// rebase to avoid shipping two tests for the same regression): this one additionally
1344    /// targets the exact `requirement` field rather than the whole serialized JSON blob, and
1345    /// asserts the legitimate `^1.0` prefix survives, not just that the bad chars are gone.
1346    #[test]
1347    fn test_to_finding_sanitizes_ansi_and_bidi_in_requirement_json_sink() {
1348        let parse_result =
1349            dep_index_with_named_dependency_and_requirement("some-pkg", "^1.0\u{202E}\x1B[31m");
1350        let dep_index = DependencyIndex::build(parse_result.as_ref());
1351        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1352
1353        let finding = to_finding(
1354            EcosystemId::Cargo,
1355            Path::new("Cargo.toml"),
1356            &dep_index,
1357            &STUB_FORMATTER,
1358            diagnostic,
1359            &HashMap::new(),
1360            &VulnKeys::default(),
1361            &HashMap::new(),
1362        );
1363
1364        let report = CheckReport {
1365            findings: vec![finding],
1366        };
1367        let json = crate::format::json::to_document(&report);
1368        let requirement_json = json.findings[0]
1369            .requirement
1370            .as_deref()
1371            .expect("range matched the dependency");
1372
1373        assert!(
1374            !requirement_json.contains('\u{202E}'),
1375            "bidi override reached JSON output"
1376        );
1377        assert!(
1378            !requirement_json.contains('\x1B'),
1379            "raw ANSI escape byte reached JSON output"
1380        );
1381        assert!(
1382            requirement_json.starts_with("^1.0"),
1383            "legitimate requirement info lost"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_to_finding_resolves_advisory_severity_from_index() {
1389        let parse_result = dep_index_with_one_dependency();
1390        let dep_index = DependencyIndex::build(parse_result.as_ref());
1391        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1392
1393        let vuln_keys = deps_core::osv::vulnerability_keys(
1394            parse_result.as_ref(),
1395            &HashMap::new(),
1396            None,
1397            &STUB_FORMATTER,
1398            EcosystemId::Cargo,
1399        );
1400        let mut severities = HashMap::new();
1401        severities.insert(
1402            (
1403                deps_core::test_util::vuln_key("dep-0"),
1404                "RUSTSEC-2024-0001".to_string(),
1405            ),
1406            VulnSeverity::Critical,
1407        );
1408
1409        let finding = to_finding(
1410            EcosystemId::Cargo,
1411            Path::new("Cargo.toml"),
1412            &dep_index,
1413            &STUB_FORMATTER,
1414            diagnostic,
1415            &severities,
1416            &vuln_keys,
1417            &HashMap::new(),
1418        );
1419        assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1420    }
1421
1422    #[test]
1423    fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1424        let parse_result = dep_index_with_one_dependency();
1425        let dep_index = DependencyIndex::build(parse_result.as_ref());
1426        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1427        let vuln_keys = deps_core::osv::vulnerability_keys(
1428            parse_result.as_ref(),
1429            &HashMap::new(),
1430            None,
1431            &STUB_FORMATTER,
1432            EcosystemId::Cargo,
1433        );
1434
1435        let finding = to_finding(
1436            EcosystemId::Cargo,
1437            Path::new("Cargo.toml"),
1438            &dep_index,
1439            &STUB_FORMATTER,
1440            diagnostic,
1441            &HashMap::new(),
1442            &vuln_keys,
1443            &HashMap::new(),
1444        );
1445        assert!(finding.advisory_severity.is_none());
1446    }
1447
1448    /// Regression test for issue #1077 review #4: no matching dependency occurrence at all
1449    /// (an empty `dep_index`/`vuln_keys`, e.g. a document-level diagnostic) must resolve to
1450    /// `None`, not panic.
1451    #[test]
1452    fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1453        let parse_result = empty_dep_index();
1454        let dep_index = DependencyIndex::build(parse_result.as_ref());
1455        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1456        let mut severities = HashMap::new();
1457        severities.insert(
1458            (
1459                deps_core::test_util::vuln_key("dep-0"),
1460                "RUSTSEC-2024-0001".to_string(),
1461            ),
1462            VulnSeverity::Critical,
1463        );
1464
1465        let finding = to_finding(
1466            EcosystemId::Cargo,
1467            Path::new("Cargo.toml"),
1468            &dep_index,
1469            &STUB_FORMATTER,
1470            diagnostic,
1471            &severities,
1472            &VulnKeys::default(),
1473            &HashMap::new(),
1474        );
1475        assert!(finding.advisory_severity.is_none());
1476    }
1477
1478    #[test]
1479    fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1480        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1481        use std::sync::Arc;
1482
1483        let advisory = Advisory::new(
1484            "RUSTSEC-2024-0001".to_string(),
1485            "2024-01-01T00:00:00Z".to_string(),
1486            VulnSeverity::High,
1487        )
1488        .expect("valid osv id");
1489        let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1490        let mut map: VulnerabilityMap = HashMap::new();
1491        map.insert(
1492            deps_core::test_util::vuln_key("serde"),
1493            ScanOutcome::Vulnerable(dv),
1494        );
1495
1496        let index = advisory_severity_index(Some(&map));
1497        assert_eq!(
1498            index.get(&(
1499                deps_core::test_util::vuln_key("serde"),
1500                "RUSTSEC-2024-0001".to_string()
1501            )),
1502            Some(&VulnSeverity::High)
1503        );
1504    }
1505
1506    /// Regression test for issue #1077 review #4: two different dependencies whose OSV
1507    /// records legitimately share one advisory id must not let one silently overwrite the
1508    /// other's severity bucket.
1509    #[test]
1510    fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1511        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1512        use std::sync::Arc;
1513
1514        let advisory_for = |severity: VulnSeverity| {
1515            Arc::new(
1516                Advisory::new(
1517                    "GHSA-shared-id".to_string(),
1518                    "2024-01-01T00:00:00Z".to_string(),
1519                    severity,
1520                )
1521                .expect("valid osv id"),
1522            )
1523        };
1524        let mut map: VulnerabilityMap = HashMap::new();
1525        map.insert(
1526            deps_core::test_util::vuln_key("package-a"),
1527            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1528                vec![advisory_for(VulnSeverity::Critical)],
1529                1,
1530            ))),
1531        );
1532        map.insert(
1533            deps_core::test_util::vuln_key("package-b"),
1534            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1535                vec![advisory_for(VulnSeverity::Low)],
1536                1,
1537            ))),
1538        );
1539
1540        let index = advisory_severity_index(Some(&map));
1541        assert_eq!(
1542            index.get(&(
1543                deps_core::test_util::vuln_key("package-a"),
1544                "GHSA-shared-id".to_string()
1545            )),
1546            Some(&VulnSeverity::Critical)
1547        );
1548        assert_eq!(
1549            index.get(&(
1550                deps_core::test_util::vuln_key("package-b"),
1551                "GHSA-shared-id".to_string()
1552            )),
1553            Some(&VulnSeverity::Low)
1554        );
1555    }
1556
1557    #[test]
1558    fn test_advisory_severity_index_empty_without_vulnerabilities() {
1559        assert!(advisory_severity_index(None).is_empty());
1560    }
1561
1562    /// Spec 075 SC-007/FR-006: `ManifestAnalysis::version_data()` finally wires live GOSSIP
1563    /// prefetch data into `check`'s diagnostic generation (`version_data()` never called
1564    /// `with_gossip_prefetch` before this field existed) — an `Outdated` dependency with an
1565    /// active GOSSIP cooldown finding for its exact `latest` renders the GOSSIP-attributed
1566    /// message, not the unattributed local-heuristic one, through the same
1567    /// `ecosystem.generate_diagnostics`/`generate_diagnostics_from_cache` path `check_manifest`
1568    /// uses — and that message survives unchanged into the `to_finding`-produced
1569    /// [`CheckFinding`].
1570    #[test]
1571    fn test_check_pipeline_surfaces_gossip_cooldown_wording_via_version_data() {
1572        use crate::analyze::ManifestAnalysis;
1573        use deps_core::position::{Position, Range as PosRange};
1574        use deps_core::test_util::stub_gossip_findings;
1575        use deps_core::{Dependency, GossipCooldown, GossipRiskLevel, PublishTime};
1576        use std::any::Any;
1577        use std::collections::HashSet;
1578
1579        struct FixtureDep {
1580            name: PackageName,
1581            version_req: deps_core::VersionReq,
1582            version_range: PosRange,
1583        }
1584        impl Dependency for FixtureDep {
1585            fn name(&self) -> &PackageName {
1586                &self.name
1587            }
1588            fn name_range(&self) -> PosRange {
1589                PosRange::new(Position::new(0, 0), Position::new(0, 5))
1590            }
1591            fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1592                Some(&self.version_req)
1593            }
1594            fn version_range(&self) -> Option<PosRange> {
1595                Some(self.version_range)
1596            }
1597            fn source(&self) -> deps_core::parser::DependencySource {
1598                deps_core::parser::DependencySource::Registry
1599            }
1600            fn as_any(&self) -> &dyn Any {
1601                self
1602            }
1603        }
1604
1605        struct FixtureParseResult {
1606            dep: FixtureDep,
1607            uri: url::Url,
1608        }
1609        impl deps_core::ParseResult for FixtureParseResult {
1610            fn dependencies(&self) -> Vec<&dyn Dependency> {
1611                vec![&self.dep]
1612            }
1613            fn workspace_root(&self) -> Option<&Path> {
1614                None
1615            }
1616            fn uri(&self) -> &url::Url {
1617                &self.uri
1618            }
1619            fn as_any(&self) -> &dyn Any {
1620                self
1621            }
1622        }
1623
1624        let uri: url::Url = "file:///project/Cargo.toml".parse().expect("valid URI");
1625        let parse_result: Box<dyn deps_core::ParseResult> = Box::new(FixtureParseResult {
1626            dep: FixtureDep {
1627                name: PackageName::new("serde"),
1628                version_req: deps_core::VersionReq::new("1.0"),
1629                version_range: PosRange::new(Position::new(0, 10), Position::new(0, 20)),
1630            },
1631            uri: uri.clone(),
1632        });
1633
1634        let mut cached_versions = HashMap::new();
1635        cached_versions.insert(
1636            PackageName::new("serde"),
1637            deps_core::lsp_helpers::PackageVersions::latest_only("2.0.0"),
1638        );
1639
1640        let mut gossip_findings = HashMap::new();
1641        gossip_findings.insert(
1642            PackageName::new("serde"),
1643            stub_gossip_findings(
1644                "2.0.0",
1645                Some(GossipCooldown::new(
1646                    PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
1647                    GossipRiskLevel::High,
1648                )),
1649            ),
1650        );
1651
1652        let analysis = ManifestAnalysis {
1653            parse_result,
1654            uri: uri.clone(),
1655            now: PublishTime::now(),
1656            ecosystem_id: EcosystemId::Cargo,
1657            cached_versions: cached_versions.clone(),
1658            resolved_versions: HashMap::new(),
1659            resolved_version_candidates: HashMap::new(),
1660            outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
1661            vulnerabilities: None,
1662            latest_status: None,
1663            fallback_status: None,
1664            cooldown_fallback_view: None,
1665            gossip_findings,
1666            licenses: HashMap::new(),
1667            license_policy: deps_core::licenses::LicensePolicy::default(),
1668            license_source: deps_core::LicenseSource::default(),
1669            network: deps_core::NetworkMode::Online,
1670            fetch_failed: HashSet::new(),
1671            registry_unreachable: false,
1672            license_fetch_incomplete: false,
1673        };
1674
1675        let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
1676            analysis.parse_result.as_ref(),
1677            analysis.version_data(),
1678            &STUB_FORMATTER,
1679            &uri,
1680            deps_core::FreshnessSettings::default(),
1681            deps_core::lsp_helpers::DiagnosticSeverities::default(),
1682            PublishTime::now(),
1683        );
1684
1685        assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
1686        assert!(
1687            diagnostics[0].message().contains("deps.dev/GOSSIP"),
1688            "check's diagnostic generation must consult live GOSSIP prefetch data: {}",
1689            diagnostics[0].message()
1690        );
1691
1692        let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
1693        let finding = to_finding(
1694            analysis.ecosystem_id,
1695            Path::new("Cargo.toml"),
1696            &dep_index,
1697            &STUB_FORMATTER,
1698            diagnostics.into_iter().next().expect("one diagnostic"),
1699            &HashMap::new(),
1700            &VulnKeys::default(),
1701            &cached_versions,
1702        );
1703        assert_eq!(finding.category, Category::Outdated);
1704        assert!(
1705            finding.message.contains("deps.dev/GOSSIP"),
1706            "the GOSSIP wording must survive into the SARIF finding: {}",
1707            finding.message
1708        );
1709    }
1710}