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, DependencyOutcomes, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16    UNSATISFIABLE_DIAGNOSTIC_CODE, redact_name_for_diagnostic,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache, PackageName, VersionData};
22use deps_engine::classify::diff::{
23    merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
24};
25use deps_engine::classify::fetch::{
26    apply_fetch_outcomes, composer_minimum_stability, dedup_dependencies_by_source,
27    fetch_latest_versions_parallel,
28};
29use deps_engine::classify::license::prefetch_tier3_licenses;
30use deps_engine::classify::osv::build_scan_targets;
31use deps_engine::classify::resolved::{collect_in_use_versions, load_resolved_versions};
32use std::collections::{BTreeMap, HashMap};
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use std::time::Duration;
36
37/// Every non-`deps-core` diagnostic code constant in the workspace, mirrored here as
38/// literals rather than importing `deps-github-actions`/`deps-gitlab-ci` directly (both are
39/// optional, feature-gated dependencies of `deps-engine`; importing their constants
40/// unconditionally would break a `--no-default-features --features cargo`-style build, and
41/// `#[cfg]`-gating each match arm was judged not worth the complexity for three stable
42/// strings). **This list must stay exhaustive** — issue C3 (spec 062 review) was exactly one
43/// ecosystem-owned code (`UNRESOLVED_HOST_DIAGNOSTIC_CODE`) missing from it, which silently
44/// misclassified an informational notice as `Category::Vulnerable`. Before trusting
45/// [`classify`]'s fallback again, re-run
46/// `grep -rn 'pub const.*_DIAGNOSTIC_CODE.*: &str' crates/*/src/lib.rs crates/*/src/ecosystem.rs`
47/// across the workspace and add anything new here.
48const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
49/// See [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc.
50const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
51/// `deps_gitlab_ci::UNRESOLVED_HOST_DIAGNOSTIC_CODE` — an informational notice (INFORMATION
52/// severity, never a vulnerability), emitted unconditionally whenever GitLab CI's
53/// `registries.gitlab_instance_host` is unset/invalid. See
54/// [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc for why this is a literal.
55const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
56
57/// Ceiling on the OSV scan timeout, independent of the configured `fetch_timeout_secs` —
58/// mirrors `deps-lsp`'s `document::osv_scan::OSV_SCAN_TIMEOUT_CEILING_SECS`: the shared
59/// `reqwest` client behind [`HttpCache`] already imposes its own client-wide 30s timeout, so
60/// a longer per-phase timeout would never actually bind.
61const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
62
63/// A category a [`CheckFinding`] can be classified into — the seven `--fail-on` tokens FR-009
64/// defines, plus [`Category::Other`].
65///
66/// `Other` covers a `generate_diagnostics` finding that matches none of the seven (an
67/// "Unknown package", a collapsed registry-lookup failure, or a workspace-registry/
68/// offline/dependency-count notice). It is never selectable via `--fail-on`
69/// (`#[value(skip)]`) and never matched by [`FailOnPolicy`] — it exists purely so
70/// [`CheckFinding`] stays a 1:1 mapping of every diagnostic `generate_diagnostics` produced,
71/// per spec 062 `tasks.md` T020, rather than silently dropping findings this crate cannot
72/// classify.
73///
74/// # Examples
75///
76/// ```
77/// use deps_cli::report::Category;
78///
79/// assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
80/// assert_eq!(Category::License.as_str(), "license");
81/// ```
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
83pub enum Category {
84    /// A newer version is published for a dependency's declared requirement.
85    Outdated,
86    /// The in-use (or only-satisfying) version has been yanked/retracted.
87    Yanked,
88    /// A known OSV advisory affects the in-use version.
89    Vulnerable,
90    /// No published version satisfies the declared requirement.
91    Unsatisfiable,
92    /// Pinned to a mutable ref (tag/branch) instead of a commit SHA (GitHub Actions/GitLab
93    /// CI).
94    #[value(name = "mutable-ref")]
95    MutableRefPin,
96    /// The resolved license violates the configured allow/deny policy.
97    License,
98    /// The registry reports the package itself as deprecated/abandoned.
99    Deprecated,
100    /// A `generate_diagnostics` finding that does not map to any category above.
101    #[value(skip)]
102    Other,
103}
104
105impl Category {
106    /// The FR-009 wire token for this category (`table`/`json` output and `--fail-on`).
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::Outdated => "outdated",
111            Self::Yanked => "yanked",
112            Self::Vulnerable => "vulnerable",
113            Self::Unsatisfiable => "unsatisfiable",
114            Self::MutableRefPin => "mutable-ref",
115            Self::License => "license",
116            Self::Deprecated => "deprecated",
117            Self::Other => "other",
118        }
119    }
120
121    /// A one-line human-readable description, longer than [`Self::as_str`]'s wire token —
122    /// used as SARIF rule metadata (`crate::format::sarif`) for a rule that only has
123    /// category-level granularity (no finer diagnostic code). Deliberately close in wording
124    /// to each variant's own doc comment above (both describe the same category, and SARIF's
125    /// `shortDescription` is meant to read like ordinary documentation prose) rather than a
126    /// genuinely distinct text written just to avoid the overlap.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use deps_cli::report::Category;
132    ///
133    /// assert_eq!(
134    ///     Category::Vulnerable.description(),
135    ///     "A known security advisory affects the in-use version."
136    /// );
137    /// assert_eq!(
138    ///     Category::License.description(),
139    ///     "The resolved license violates the configured allow/deny policy."
140    /// );
141    /// ```
142    #[must_use]
143    pub const fn description(self) -> &'static str {
144        match self {
145            Self::Outdated => {
146                "A newer version is published for the dependency's declared requirement."
147            }
148            Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
149            Self::Vulnerable => "A known security advisory affects the in-use version.",
150            Self::Unsatisfiable => "No published version satisfies the declared requirement.",
151            Self::MutableRefPin => {
152                "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
153            }
154            Self::License => "The resolved license violates the configured allow/deny policy.",
155            Self::Deprecated => {
156                "The registry reports the package itself as deprecated or abandoned."
157            }
158            Self::Other => "A finding that does not map to any of the other categories.",
159        }
160    }
161}
162
163impl std::fmt::Display for Category {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169/// One reported issue, derived 1:1 from a [`Diagnostic`] `generate_diagnostics` produced.
170#[derive(Debug, Clone)]
171pub struct CheckFinding {
172    /// Which ecosystem's manifest this finding came from.
173    pub ecosystem: EcosystemId,
174    /// Path to the manifest, relative to the walked root when discovered by [`crate::walk`].
175    pub manifest_path: PathBuf,
176    /// The dependency's declared name, when a manifest occurrence's range matched the
177    /// diagnostic's own range. `None` for a document-level finding not anchored to one
178    /// dependency (e.g. an offline/dependency-count notice). Passed through
179    /// [`deps_core::lsp_helpers::redact_name_for_diagnostic`] (#1242, #1246), so this is
180    /// never the raw manifest value.
181    pub dependency_name: Option<String>,
182    /// The dependency's declared version requirement, when known.
183    pub requirement: Option<String>,
184    /// The classified category (see [`Category`]).
185    pub category: Category,
186    /// The diagnostic's own `code`, when it carried a string one (spec 062 review S3,
187    /// issue #1075): a vulnerability diagnostic's code is an OSV advisory id
188    /// (`RUSTSEC-...`/`GHSA-...`), finer-grained than [`Self::category`]; the workspace's
189    /// other stable diagnostic-code constants (`UNSATISFIABLE_DIAGNOSTIC_CODE`, etc.) are
190    /// 1:1 with a category, so carrying them here changes nothing beyond echoing
191    /// `category`. `None` when `classify` fell back to matching the diagnostic's message
192    /// text instead of its code (`Outdated`/`Yanked`/`Other`).
193    pub code: Option<String>,
194    /// The advisory's own `https://osv.dev/vulnerability/{id}` page, when [`Self::code`] is
195    /// an OSV advisory id — sourced from the diagnostic's `code_description.href` (already
196    /// `Uri`-parsed and validated by `deps_core::lsp_helpers::diagnostics::
197    /// push_vulnerability_diagnostics` before it ever reaches a `Diagnostic`), not
198    /// re-derived from `code` — the authoritative URL OSV itself gave us, rather than a
199    /// second, redundant formula that could drift from it. `None` whenever `code_description`
200    /// is absent (every non-advisory finding, and the rare case where OSV's own `url` failed
201    /// `Uri` parsing upstream).
202    pub advisory_url: Option<String>,
203    /// The OSV-derived severity bucket for [`Self::code`], when it names an advisory this
204    /// manifest's OSV scan actually fetched (issue #1077 C2) — looked up by advisory id from
205    /// the same scan results `generate_diagnostics` consumed, not re-derived from
206    /// [`Self::severity`] (the three-bucket [`Severity`] `code` already collapsed into is too
207    /// coarse to recover a CVSS-style grade from). `None` for every non-advisory finding, and
208    /// for an advisory `code` this run's scan did not itself fetch (e.g. a stale `code` from a
209    /// formatter that does not source it from a live scan).
210    pub advisory_severity: Option<VulnSeverity>,
211    /// The diagnostic's severity.
212    pub severity: Severity,
213    /// The diagnostic's range within the manifest.
214    pub range: Range,
215    /// The diagnostic's human-readable message.
216    pub message: String,
217}
218
219/// The full result of one `check` invocation.
220#[derive(Debug, Clone, Default)]
221pub struct CheckReport {
222    /// Every finding produced across every walked manifest.
223    pub findings: Vec<CheckFinding>,
224}
225
226impl CheckReport {
227    /// Per-category finding counts, derived from [`Self::findings`] rather than kept as
228    /// separately mutated state (spec 062 tasks.md T020).
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use deps_cli::report::{Category, CheckFinding, CheckReport};
234    /// use deps_core::EcosystemId;
235    /// use deps_core::diagnostic::Severity;
236    /// use deps_core::position::Range;
237    /// use std::path::PathBuf;
238    ///
239    /// let report = CheckReport {
240    ///     findings: vec![CheckFinding {
241    ///         ecosystem: EcosystemId::Cargo,
242    ///         manifest_path: PathBuf::from("Cargo.toml"),
243    ///         dependency_name: Some("serde".to_string()),
244    ///         requirement: Some("1.0".to_string()),
245    ///         category: Category::Outdated,
246    ///         code: None,
247    ///         advisory_url: None,
248    ///         advisory_severity: None,
249    ///         severity: Severity::Hint,
250    ///         range: Range::default(),
251    ///         message: "Newer version available: 1.1".to_string(),
252    ///     }],
253    /// };
254    /// assert_eq!(report.summary().get(&Category::Outdated), Some(&1));
255    /// ```
256    #[must_use]
257    pub fn summary(&self) -> BTreeMap<Category, usize> {
258        let mut counts = BTreeMap::new();
259        for finding in &self.findings {
260            *counts.entry(finding.category).or_insert(0_usize) += 1;
261        }
262        counts
263    }
264}
265
266/// The set of categories that turn at least one matching [`CheckFinding`] into a
267/// process-exit-1 policy violation (FR-009/FR-010).
268#[derive(Debug, Clone)]
269pub struct FailOnPolicy {
270    categories: Vec<Category>,
271}
272
273impl FailOnPolicy {
274    /// Builds a policy from an explicit category list.
275    #[must_use]
276    pub const fn new(categories: Vec<Category>) -> Self {
277        Self { categories }
278    }
279
280    /// The default policy (FR-010): `vulnerable,yanked,unsatisfiable` — the categories that
281    /// represent a broken or unsafe build, as opposed to advisory-only categories.
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use deps_cli::report::{Category, FailOnPolicy};
287    ///
288    /// let policy = FailOnPolicy::default_categories();
289    /// assert!(policy.categories().contains(&Category::Vulnerable));
290    /// assert!(!policy.categories().contains(&Category::Outdated));
291    /// ```
292    #[must_use]
293    pub fn default_categories() -> Self {
294        Self::new(vec![
295            Category::Vulnerable,
296            Category::Yanked,
297            Category::Unsatisfiable,
298        ])
299    }
300
301    /// The categories this policy fails on.
302    #[must_use]
303    pub fn categories(&self) -> &[Category] {
304        &self.categories
305    }
306
307    /// Whether any finding in `findings` matches this policy.
308    #[must_use]
309    pub fn matches(&self, findings: &[CheckFinding]) -> bool {
310        findings
311            .iter()
312            .any(|finding| self.categories.contains(&finding.category))
313    }
314}
315
316impl Default for FailOnPolicy {
317    fn default() -> Self {
318        Self::default_categories()
319    }
320}
321
322/// Runtime handles and resolved policy shared across every manifest a `check` run classifies.
323///
324/// Built once in `main.rs` (or by an integration test) and passed by reference to
325/// [`check_manifest`] for every discovered manifest.
326#[derive(Clone)]
327pub struct CheckContext {
328    /// Shared HTTP cache for registry requests.
329    pub cache: Arc<HttpCache>,
330    /// Shared OSV.dev vulnerability scan client.
331    pub osv: Arc<OsvClient>,
332    /// Shared lock-file cache, keyed by resolved lockfile path.
333    pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
334    /// The resolved policy configuration for this run.
335    pub policy: PolicyConfig,
336}
337
338/// Error running [`check_manifest`] for one manifest.
339#[derive(Debug, thiserror::Error)]
340pub enum CheckError {
341    /// The manifest content could not be parsed by its ecosystem's parser.
342    #[error("failed to parse {path}: {source}")]
343    Parse {
344        /// The manifest path that failed to parse.
345        path: PathBuf,
346        /// The underlying parse error.
347        #[source]
348        source: deps_core::DepsError,
349    },
350    /// `manifest_path` could not be represented as a file URI (e.g. a malformed or
351    /// non-representable path on this platform).
352    #[error("could not build a file URI for {path}")]
353    InvalidPath {
354        /// The manifest path that could not be converted.
355        path: PathBuf,
356    },
357}
358
359/// The result of classifying one manifest.
360#[derive(Debug, Clone)]
361pub struct ManifestCheckResult {
362    /// Every finding produced for this manifest.
363    pub findings: Vec<CheckFinding>,
364    /// Whether at least one dependency's *version* registry fetch failed while not offline
365    /// (FR-012) — the caller uses this to decide between exit 1 (policy violation over an
366    /// otherwise complete report) and exit 2 (an incomplete report because a registry was
367    /// unreachable).
368    ///
369    /// Deliberately does **not** cover a tier-3 license-source timeout (Dart/Swift/Gradle/
370    /// Deno) — see [`Self::license_fetch_incomplete`] — a Maven Central outage while the
371    /// version registry itself is fully reachable is a different failure with a different
372    /// remediation (issue #1133 code-review finding #1), and conflating the two would mislead
373    /// a caller inspecting this field into diagnosing the wrong system.
374    pub registry_unreachable: bool,
375    /// Whether at least one dependency's tier-3 license fetch (Dart/Swift/Gradle/Deno) timed
376    /// out while not offline (issue #1133 code-review finding #1) — kept separate from
377    /// [`Self::registry_unreachable`] since the two name genuinely different subsystems (the
378    /// license source vs. the version registry), even though both feed the same exit-2
379    /// "incomplete report" signal in `main.rs`. See
380    /// `deps_engine::classify::license::TierThreeLicenseFetch::timed_out`'s doc for exactly
381    /// which failure modes this can (and cannot) detect.
382    pub license_fetch_incomplete: bool,
383}
384
385/// Classifies one already-routed manifest.
386///
387/// Parses it, resolves in-use/lockfile versions, fetches latest registry versions, runs an
388/// OSV scan (when enabled and not offline), and calls the ecosystem's own
389/// `generate_diagnostics` — then converts each returned [`Diagnostic`] into a
390/// [`CheckFinding`].
391///
392/// Every verdict decision (outdated/yanked/vulnerable/unsatisfiable/deprecated/license) is
393/// made by [`deps_engine::classify`] or by `generate_diagnostics` itself; this function only
394/// assembles their inputs and converts their output (spec 062 FR-005).
395///
396/// # Errors
397///
398/// Returns [`CheckError::InvalidPath`] if `manifest_path` cannot be represented as a file
399/// URI, or [`CheckError::Parse`] if `content` fails to parse as this ecosystem's manifest
400/// format.
401pub async fn check_manifest(
402    ecosystem: &Arc<dyn Ecosystem>,
403    manifest_path: &Path,
404    display_path: &Path,
405    content: &str,
406    ctx: &CheckContext,
407) -> Result<ManifestCheckResult, CheckError> {
408    let uri = path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
409        path: manifest_path.to_path_buf(),
410    })?;
411    let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
412        .await
413        .map_err(|source| CheckError::Parse {
414            path: manifest_path.to_path_buf(),
415            source,
416        })?;
417    let formatter = ecosystem.formatter();
418    let ecosystem_id = ecosystem.ecosystem_id();
419
420    let (resolved_versions, resolved_version_candidates) =
421        load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref()).await;
422
423    let (dep_sources, collided_names) =
424        dedup_dependencies_by_source(parse_result.as_ref(), formatter);
425    let in_use = collect_in_use_versions(
426        parse_result.as_ref(),
427        &resolved_versions,
428        &resolved_version_candidates,
429        formatter,
430        ecosystem_id,
431    );
432    let minimum_stability = composer_minimum_stability(parse_result.as_ref());
433    let attempted_names: Vec<PackageName> = dep_sources.keys().cloned().collect();
434
435    let fetch_result = fetch_latest_versions_parallel(
436        ecosystem.registry(),
437        dep_sources.into_iter().collect(),
438        &in_use,
439        None,
440        ctx.policy.freshness.to_settings(),
441        ctx.policy.cache.fetch_timeout_secs,
442        ctx.policy.cache.max_concurrent_fetches,
443        minimum_stability.as_deref(),
444    )
445    .await;
446    // `failed_count` also counts not-found lookups, which aren't evidence of an unreachable
447    // registry — using it here made any repo with one typo'd dependency exit 2 every run.
448    let registry_unreachable = !ctx.policy.network.offline && !fetch_result.fetch_failed.is_empty();
449
450    let mut outcomes = DependencyOutcomes::new();
451    let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
452    apply_fetch_outcomes(
453        &mut outcomes,
454        fetch_result.yanked_versions,
455        fetch_result.fetch_failed,
456        collided_names,
457        formatter,
458    );
459    merge_deprecations_after_fetch(
460        &mut outcomes,
461        &fetched_names,
462        fetch_result.deprecations,
463        formatter,
464    );
465    merge_no_comparable_versions_after_fetch(
466        &mut outcomes,
467        &attempted_names,
468        fetch_result.no_comparable_versions,
469        formatter,
470    );
471    let cached_versions = fetch_result.versions;
472    // Tier-1 license backfill (issue #660/#661 precedent): populated for native-list
473    // ecosystems (PyPI, Composer) whose registry response carries a license field.
474    let mut licenses = fetch_result.licenses;
475
476    // Hoisted so both the tier-3 gate below and `.with_license_policy(&license_policy)`
477    // further down share one computed policy (issue #1133 critic M1).
478    let license_policy = ctx.policy.license_policy.to_policy();
479
480    // Tier-3 license prefetch (issue #1133, populated for Dart/Swift/Gradle/Deno, a no-op
481    // for every other ecosystem — see `deps_engine::classify::license`'s doc) and the OSV
482    // scan are independent (OSV never reads licenses) and both make network round trips, so
483    // they run concurrently via `tokio::join!` rather than sequentially (critic M2) —
484    // mirrors `deps-lsp`, which spawns both as separate concurrent tasks.
485    //
486    // Only `!offline` is gated here — the non-empty-`license_policy` check (critic M1) is
487    // enforced *inside* `prefetch_tier3_licenses` itself (code-review finding #3), not
488    // re-derived at this call site, so it can't be silently forgotten by a future caller:
489    // `licenses`' only consumer in this crate is `apply_license_policy_rule`, a no-op when
490    // no policy is configured, so with the default (empty) policy this call would otherwise
491    // issue N network round trips for a result nothing reads — burning Swift's
492    // unauthenticated 60 req/h GitHub budget among others for nothing.
493    let run_tier3_prefetch = !ctx.policy.network.offline;
494    let tier3_license_fetch = async {
495        if run_tier3_prefetch {
496            prefetch_tier3_licenses(
497                ecosystem.as_ref(),
498                parse_result.as_ref(),
499                &resolved_versions,
500                &resolved_version_candidates,
501                &license_policy,
502                ctx.policy.cache.fetch_timeout_secs,
503                ctx.policy.cache.max_concurrent_fetches,
504            )
505            .await
506        } else {
507            deps_engine::classify::license::TierThreeLicenseFetch::default()
508        }
509    };
510
511    let run_osv_scan =
512        ctx.policy.diagnostics.vulnerabilities_enabled && !ctx.policy.network.offline;
513    let osv_scan = async {
514        if run_osv_scan {
515            let (targets, skipped) = build_scan_targets(
516                parse_result.as_ref(),
517                &resolved_versions,
518                &resolved_version_candidates,
519                formatter,
520                ecosystem_id,
521            );
522            let mut vulns = skipped;
523            if !targets.is_empty() {
524                let timeout = Duration::from_secs(
525                    ctx.policy
526                        .cache
527                        .fetch_timeout_secs
528                        .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
529                );
530                let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
531                vulns.extend(scanned);
532            }
533            Some(vulns)
534        } else {
535            None
536        }
537    };
538
539    let (tier3_result, vulnerabilities): (_, Option<VulnerabilityMap>) =
540        tokio::join!(tier3_license_fetch, osv_scan);
541
542    // Merged (not replaced) alongside the tier-1 backfill above via `entry().or_insert()`,
543    // not `extend` (critic nit): the two sources are disjoint today (only Composer
544    // populates `FetchResult::licenses`, and it's `RegistryDeclaredSpdx`, never a tier-3
545    // ecosystem), but `or_insert` makes the intended precedence explicit — an
546    // author-declared tier-1 license must win over a heuristic tier-3 one if that ever
547    // stops holding, rather than whichever call happened to run last.
548    for (name, license) in tier3_result.licenses {
549        licenses.entry(name).or_insert(license);
550    }
551    // A confirmed tier-3 timeout feeds `main.rs`'s same exit-2 "incomplete report" signal a
552    // registry-unreachable manifest does, but through its own field (issue #1133 code-review
553    // finding #1) — not `registry_unreachable` itself: a Maven Central outage while the
554    // version registry is fully reachable is a different failure than an unreachable
555    // registry, and a caller inspecting `registry_unreachable` must not be misled into
556    // diagnosing the wrong system. Does not catch every tier-3 failure mode:
557    // `Ecosystem::fetch_license` returns a bare `Vec<String>` with no error channel, so a
558    // network error/404/rate-limit a tier-3 implementation swallows internally (all four
559    // documented implementations do) never reaches here — see
560    // `TierThreeLicenseFetch::timed_out`'s doc for the full caveat.
561    let license_fetch_incomplete = tier3_result.timed_out > 0;
562
563    let mut version_data = VersionData::new(&cached_versions, &resolved_versions)
564        .with_resolved_version_candidates(&resolved_version_candidates)
565        .with_outcomes(&outcomes)
566        .with_ecosystem(ecosystem_id)
567        .with_offline(ctx.policy.network.offline)
568        .with_license_source(ecosystem.license_source())
569        .with_license_policy(&license_policy)
570        .with_license_prefetch(&licenses);
571    if let Some(vulnerabilities) = vulnerabilities.as_ref() {
572        version_data = version_data.with_vulnerabilities(vulnerabilities);
573    }
574
575    let severities = ctx.policy.diagnostics.to_severities();
576    let diagnostics = ecosystem
577        .generate_diagnostics(
578            parse_result.as_ref(),
579            version_data,
580            &uri,
581            ctx.policy.freshness.to_settings(),
582            severities,
583        )
584        .await;
585
586    let dep_index = DependencyIndex::build(parse_result.as_ref());
587    let advisory_severities = advisory_severity_index(vulnerabilities.as_ref());
588    // Same per-occurrence key `vulnerabilities` was built under, so a shared advisory id
589    // across two dependencies can never resolve to the wrong one's severity (issue #1077 review #4).
590    let vuln_keys = deps_core::osv::vulnerability_keys(
591        parse_result.as_ref(),
592        &resolved_versions,
593        Some(&resolved_version_candidates),
594        formatter,
595        ecosystem_id,
596    );
597    let findings = diagnostics
598        .into_iter()
599        .map(|diagnostic| {
600            to_finding(
601                ecosystem_id,
602                display_path,
603                &dep_index,
604                formatter,
605                diagnostic,
606                &advisory_severities,
607                &vuln_keys,
608            )
609        })
610        .collect();
611    Ok(ManifestCheckResult {
612        findings,
613        registry_unreachable,
614        license_fetch_incomplete,
615    })
616}
617
618/// Indexes every dependency occurrence by its name range and (separately) its version
619/// range, so a diagnostic anchored at either can be traced back to the declaration that
620/// produced it (see [`to_finding`]).
621///
622/// Two separate maps (M6, spec 062 review) rather than one shared `HashMap<Range, _>`: a
623/// single map risks one dependency's name-range entry silently overwriting a *different*
624/// dependency's version-range entry if the two ranges ever coincide, misattributing
625/// `dependency_name`. Keeping the two lookups apart makes that impossible regardless of
626/// range values, not just unlikely in practice.
627struct DependencyIndex<'a> {
628    by_name_range: HashMap<Range, &'a dyn Dependency>,
629    by_version_range: HashMap<Range, &'a dyn Dependency>,
630}
631
632impl<'a> DependencyIndex<'a> {
633    fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
634        let mut by_name_range = HashMap::new();
635        let mut by_version_range = HashMap::new();
636        for dep in parse_result.dependencies() {
637            if !dep.name_range_is_synthetic() {
638                by_name_range.insert(dep.name_range(), dep);
639            }
640            if let Some(version_range) = dep.version_range() {
641                by_version_range.insert(version_range, dep);
642            }
643        }
644        Self {
645            by_name_range,
646            by_version_range,
647        }
648    }
649
650    /// Looks up the dependency a diagnostic's `range` is anchored to, preferring a
651    /// name-range match (diagnostics anchored at `resolved.version_range` still resolve via
652    /// the version-range map when no name-range entry matches the same coordinates).
653    fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
654        self.by_name_range
655            .get(&range)
656            .or_else(|| self.by_version_range.get(&range))
657            .copied()
658    }
659}
660
661/// Indexes every advisory this manifest's OSV scan fetched, keyed by (the [`VulnerabilityMap`]
662/// key identifying the specific dependency occurrence, advisory id) rather than by advisory id
663/// alone (issue #1077 review #4), so [`to_finding`] can attach a
664/// [`CheckFinding::advisory_severity`] without re-deriving a grade from the coarser
665/// [`Severity`] a `Diagnostic` carries.
666///
667/// A bare `HashMap<String, VulnSeverity>` keyed only by advisory id would let one dependency's
668/// severity silently overwrite another's (unspecified `HashMap` iteration order) whenever two
669/// dependencies in this manifest shared an advisory id — OSV can legitimately report one id
670/// against several different packages in the same record. [`to_finding`] looks its own
671/// dependency occurrence's key up via [`deps_core::osv::vulnerability_keys`] — the same
672/// function `vulnerabilities` itself was keyed under — so this can never drift out of sync
673/// with how the scan actually mapped occurrences to results.
674///
675/// `None` `vulnerabilities` (scan disabled, or offline) yields an empty index, same as a
676/// (key, id) pair this index has no entry for — both resolve to
677/// `CheckFinding::advisory_severity: None`.
678fn advisory_severity_index(
679    vulnerabilities: Option<&VulnerabilityMap>,
680) -> HashMap<(String, String), VulnSeverity> {
681    let mut index = HashMap::new();
682    let Some(vulnerabilities) = vulnerabilities else {
683        return index;
684    };
685    for (dependency_key, outcome) in vulnerabilities {
686        if let ScanOutcome::Vulnerable(dv) = outcome {
687            for advisory in dv.advisories.items() {
688                index.insert(
689                    (dependency_key.clone(), advisory.id.clone()),
690                    advisory.severity,
691                );
692            }
693        }
694    }
695    index
696}
697
698/// Converts one `generate_diagnostics` [`Diagnostic`] into a [`CheckFinding`], classifying
699/// its [`Category`] (see [`classify`]) and, when its range matches a manifest occurrence
700/// (from [`DependencyIndex`]), its `dependency_name`/`requirement`. `advisory_severities` and
701/// `vuln_keys` together resolve [`CheckFinding::advisory_severity`] — see
702/// [`advisory_severity_index`].
703fn to_finding(
704    ecosystem: EcosystemId,
705    display_path: &Path,
706    dep_index: &DependencyIndex<'_>,
707    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
708    diagnostic: Diagnostic,
709    advisory_severities: &HashMap<(String, String), VulnSeverity>,
710    vuln_keys: &HashMap<Range, String>,
711) -> CheckFinding {
712    let category = classify(&diagnostic, formatter);
713    let dep = dep_index.lookup(diagnostic.range);
714    let code = diagnostic.code().map(str::to_string);
715    let advisory_url = diagnostic
716        .code_description
717        .as_ref()
718        .map(|code_description| code_description.href.as_str().to_string());
719    let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
720        let dependency_key = vuln_keys.get(&dep.name_range())?;
721        advisory_severities
722            .get(&(dependency_key.clone(), code.to_string()))
723            .copied()
724    });
725    CheckFinding {
726        ecosystem,
727        manifest_path: display_path.to_path_buf(),
728        dependency_name: dep.map(|d| redact_name_for_diagnostic(d.name())),
729        requirement: dep
730            .and_then(Dependency::version_requirement)
731            .map(ToString::to_string),
732        category,
733        code,
734        advisory_url,
735        advisory_severity,
736        severity: diagnostic.severity.unwrap_or(Severity::Warning),
737        range: diagnostic.range,
738        message: diagnostic.message().to_string(),
739    }
740}
741
742/// Classifies a `generate_diagnostics` [`Diagnostic`] into a [`Category`].
743///
744/// Diagnostics that carry one of the workspace's known non-advisory codes (the three
745/// `deps-core` sentinels, the two mutable-ref-pin codes, or GitLab CI's
746/// [`GITLAB_CI_UNRESOLVED_HOST_CODE`] notice) classify directly from `code`. A vulnerability
747/// advisory id (an OSV id such as `RUSTSEC-...`/`GHSA-...`) is the only other free-form `code`
748/// value `generate_diagnostics_from_cache` ever sets, so any `Some(code)` that matches none of
749/// the known non-advisory codes classifies as [`Category::Vulnerable`] — this fallback is
750/// sound only as long as the known-code list above stays exhaustive (see that list's own doc
751/// for the regression this already caused once). An outdated diagnostic carries no code but
752/// always starts with the fixed prefix `apply_outdated_rule` uses; a yanked diagnostic carries
753/// no code either, but always contains `formatter.yanked_message()` verbatim — the same text
754/// it was built from. The advisory-overflow summary line ("+N more advisories") also carries
755/// no code but always ends with that fixed suffix, and is still [`Category::Vulnerable`].
756/// Anything else (unknown-package, collapsed fetch-failure, blocked-registry,
757/// offline/dependency-count notices) classifies as [`Category::Other`].
758fn classify(
759    diagnostic: &Diagnostic,
760    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
761) -> Category {
762    if let Some(code) = diagnostic.code() {
763        return match code {
764            UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
765            LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
766            DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
767            GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
768                Category::MutableRefPin
769            }
770            GITLAB_CI_UNRESOLVED_HOST_CODE => Category::Other,
771            _ => Category::Vulnerable,
772        };
773    }
774    if diagnostic.message().starts_with("Newer version available") {
775        return Category::Outdated;
776    }
777    if diagnostic.message().contains(formatter.yanked_message()) {
778        return Category::Yanked;
779    }
780    // The advisory-overflow summary line carries no code but is still a vulnerability finding
781    // (M1, spec 062 review) — else a manifest over `ADVISORY_DISPLAY_CAP` reports it as `Other`.
782    if diagnostic.message().ends_with("more advisories") {
783        return Category::Vulnerable;
784    }
785    Category::Other
786}
787
788/// Builds a file URI from a filesystem path, without any path-existence check. Returns
789/// `None` when `path` cannot be represented as a file URI at all — `Url::from_file_path`
790/// requires an absolute path (this function already joins a relative one onto the current
791/// directory first) and, on Windows, a disk (`C:`) or UNC (`\\`) prefix; UNC paths
792/// themselves are supported, just not any other Windows path prefix shape. The caller
793/// surfaces a `None` here as [`CheckError::InvalidPath`] rather than fabricating a
794/// synthetic, unusable URI.
795fn path_to_uri(path: &Path) -> Option<url::Url> {
796    let absolute = if path.is_absolute() {
797        path.to_path_buf()
798    } else {
799        std::env::current_dir()
800            .map(|cwd| cwd.join(path))
801            .unwrap_or_else(|_| path.to_path_buf())
802    };
803    url::Url::from_file_path(&absolute).ok()
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn finding(category: Category) -> CheckFinding {
811        CheckFinding {
812            ecosystem: EcosystemId::Cargo,
813            manifest_path: PathBuf::from("Cargo.toml"),
814            dependency_name: Some("serde".to_string()),
815            requirement: Some("1.0".to_string()),
816            category,
817            code: None,
818            advisory_url: None,
819            advisory_severity: None,
820            severity: Severity::Warning,
821            range: Range::default(),
822            message: "test".to_string(),
823        }
824    }
825
826    #[test]
827    fn test_category_as_str_matches_fr009_tokens() {
828        assert_eq!(Category::Outdated.as_str(), "outdated");
829        assert_eq!(Category::Yanked.as_str(), "yanked");
830        assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
831        assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
832        assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
833        assert_eq!(Category::License.as_str(), "license");
834        assert_eq!(Category::Deprecated.as_str(), "deprecated");
835        assert_eq!(Category::Other.as_str(), "other");
836    }
837
838    #[test]
839    fn test_fail_on_policy_default_categories() {
840        let policy = FailOnPolicy::default_categories();
841        assert!(policy.matches(&[finding(Category::Vulnerable)]));
842        assert!(policy.matches(&[finding(Category::Yanked)]));
843        assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
844        assert!(!policy.matches(&[finding(Category::Outdated)]));
845        assert!(!policy.matches(&[finding(Category::License)]));
846        assert!(!policy.matches(&[finding(Category::Deprecated)]));
847        assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
848        assert!(!policy.matches(&[finding(Category::Other)]));
849    }
850
851    #[test]
852    fn test_fail_on_policy_custom_categories() {
853        let policy = FailOnPolicy::new(vec![Category::License]);
854        assert!(policy.matches(&[finding(Category::License)]));
855        assert!(!policy.matches(&[finding(Category::Vulnerable)]));
856    }
857
858    #[test]
859    fn test_fail_on_policy_empty_findings_never_matches() {
860        assert!(!FailOnPolicy::default_categories().matches(&[]));
861    }
862
863    #[test]
864    fn test_check_report_summary_counts_per_category() {
865        let report = CheckReport {
866            findings: vec![
867                finding(Category::Outdated),
868                finding(Category::Outdated),
869                finding(Category::Vulnerable),
870            ],
871        };
872        let summary = report.summary();
873        assert_eq!(summary.get(&Category::Outdated), Some(&2));
874        assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
875        assert_eq!(summary.get(&Category::License), None);
876    }
877
878    #[test]
879    fn test_check_report_summary_empty_for_no_findings() {
880        let report = CheckReport::default();
881        assert!(report.summary().is_empty());
882    }
883
884    struct StubFormatter;
885    impl deps_core::lsp_helpers::PackageNaming for StubFormatter {}
886    impl deps_core::lsp_helpers::PackageRendering for StubFormatter {
887        fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
888            version.to_string()
889        }
890        fn package_url(&self, name: &PackageName) -> String {
891            name.as_str().to_string()
892        }
893    }
894    impl deps_core::lsp_helpers::RequirementResolution for StubFormatter {}
895    impl deps_core::lsp_helpers::DiagnosticMessages for StubFormatter {}
896    impl deps_core::lsp_helpers::DiagnosticPolicy for StubFormatter {}
897    impl deps_core::lsp_helpers::SourcePolicy for StubFormatter {}
898    impl deps_core::lsp_helpers::OsvNaming for StubFormatter {}
899
900    fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
901        let diagnostic =
902            Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
903        match code {
904            Some(code) => diagnostic.with_code(code),
905            None => diagnostic,
906        }
907    }
908
909    #[test]
910    fn test_classify_unsatisfiable_by_code() {
911        let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
912        assert_eq!(classify(&d, &StubFormatter), Category::Unsatisfiable);
913    }
914
915    #[test]
916    fn test_classify_license_by_code() {
917        let d = diagnostic_with(
918            Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
919            "GPL-3.0 denied",
920        );
921        assert_eq!(classify(&d, &StubFormatter), Category::License);
922    }
923
924    #[test]
925    fn test_classify_deprecated_by_code() {
926        let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
927        assert_eq!(classify(&d, &StubFormatter), Category::Deprecated);
928    }
929
930    #[test]
931    fn test_classify_mutable_ref_pin_by_code() {
932        let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
933        assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
934        let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
935        assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
936    }
937
938    #[test]
939    fn test_classify_advisory_code_is_vulnerable() {
940        let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
941        assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
942    }
943
944    #[test]
945    fn test_classify_outdated_by_message_prefix() {
946        let d = diagnostic_with(None, "Newer version available: 2.0.0");
947        assert_eq!(classify(&d, &StubFormatter), Category::Outdated);
948    }
949
950    #[test]
951    fn test_classify_yanked_by_formatter_message() {
952        let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
953        assert_eq!(classify(&d, &StubFormatter), Category::Yanked);
954    }
955
956    #[test]
957    fn test_classify_unknown_package_is_other() {
958        let d = diagnostic_with(None, "Unknown package 'left-pad'");
959        assert_eq!(classify(&d, &StubFormatter), Category::Other);
960    }
961
962    /// Regression test for M1 (spec 062 review): the trailing "+N more advisories" overflow
963    /// summary carries no code but is still a vulnerability finding, not `Other`.
964    #[test]
965    fn test_classify_advisory_overflow_summary_is_vulnerable() {
966        let d = diagnostic_with(None, "+5 more advisories");
967        assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
968    }
969
970    /// Regression test for C3 (spec 062 review): GitLab CI's `unresolved-gitlab-host` notice
971    /// is informational (INFORMATION severity, never a vulnerability) and must not fall
972    /// through to the advisory-id fallback.
973    #[test]
974    fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
975        let d = diagnostic_with(
976            Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
977            "registries.gitlab_instance_host is unset; skipping component/project host resolution",
978        );
979        assert_eq!(classify(&d, &StubFormatter), Category::Other);
980    }
981
982    /// A single-dependency parse result whose one dependency ("dep-0") sits at
983    /// `Range::default()` (`deps_core::test_util::StubDependency::name_range` always
984    /// returns it) — matching `diagnostic_with`'s own hardcoded `range: Range::default()`
985    /// (both the same `deps_core::position::Range` `DependencyIndex` is keyed on), so
986    /// `DependencyIndex::lookup` resolves it for tests that need a real, non-`None`
987    /// dependency occurrence.
988    fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
989        deps_core::test_util::stub_parse_result_with_dependencies(1)
990    }
991
992    fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
993        deps_core::test_util::stub_parse_result_with_dependencies(0)
994    }
995
996    /// A single-dependency [`deps_core::Dependency`]/[`deps_core::ParseResult`] fixture whose
997    /// name is caller-controlled — unlike [`dep_index_with_one_dependency`]'s fixed `dep-0`,
998    /// needed to exercise `to_finding`'s `dependency_name` redaction (#1242, #1246) with an
999    /// attacker-controlled manifest key.
1000    struct NamedFixtureDep {
1001        name: PackageName,
1002    }
1003
1004    impl deps_core::Dependency for NamedFixtureDep {
1005        fn name(&self) -> &PackageName {
1006            &self.name
1007        }
1008        fn name_range(&self) -> Range {
1009            Range::default()
1010        }
1011        fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1012            None
1013        }
1014        fn version_range(&self) -> Option<Range> {
1015            None
1016        }
1017        fn source(&self) -> deps_core::parser::DependencySource {
1018            deps_core::parser::DependencySource::Registry
1019        }
1020        fn as_any(&self) -> &dyn std::any::Any {
1021            self
1022        }
1023    }
1024
1025    struct NamedFixtureParseResult {
1026        dep: NamedFixtureDep,
1027        uri: url::Url,
1028    }
1029
1030    impl deps_core::ParseResult for NamedFixtureParseResult {
1031        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
1032            vec![&self.dep]
1033        }
1034        fn workspace_root(&self) -> Option<&Path> {
1035            None
1036        }
1037        fn uri(&self) -> &url::Url {
1038            &self.uri
1039        }
1040        fn as_any(&self) -> &dyn std::any::Any {
1041            self
1042        }
1043    }
1044
1045    fn dep_index_with_named_dependency(name: &str) -> Box<dyn deps_core::ParseResult> {
1046        Box::new(NamedFixtureParseResult {
1047            dep: NamedFixtureDep {
1048                name: PackageName::new(name),
1049            },
1050            uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1051        })
1052    }
1053
1054    /// Regression test (issue #1077 tester must-fix #2): every other SARIF test hand-builds a
1055    /// `CheckFinding` with `code` pre-set, bypassing the real `Diagnostic.code ->
1056    /// CheckFinding.code` extraction this covers.
1057    #[test]
1058    fn test_to_finding_extracts_string_code_from_diagnostic() {
1059        let parse_result = empty_dep_index();
1060        let dep_index = DependencyIndex::build(parse_result.as_ref());
1061        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1062        let finding = to_finding(
1063            EcosystemId::Cargo,
1064            Path::new("Cargo.toml"),
1065            &dep_index,
1066            &StubFormatter,
1067            diagnostic,
1068            &HashMap::new(),
1069            &HashMap::new(),
1070        );
1071        assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
1072    }
1073
1074    #[test]
1075    fn test_to_finding_code_is_none_without_a_diagnostic_code() {
1076        let parse_result = empty_dep_index();
1077        let dep_index = DependencyIndex::build(parse_result.as_ref());
1078        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1079        let finding = to_finding(
1080            EcosystemId::Cargo,
1081            Path::new("Cargo.toml"),
1082            &dep_index,
1083            &StubFormatter,
1084            diagnostic,
1085            &HashMap::new(),
1086            &HashMap::new(),
1087        );
1088        assert!(finding.code.is_none());
1089    }
1090
1091    #[test]
1092    fn test_to_finding_extracts_advisory_url_from_code_description() {
1093        let parse_result = empty_dep_index();
1094        let dep_index = DependencyIndex::build(parse_result.as_ref());
1095        let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
1096            .parse()
1097            .expect("valid URL");
1098        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
1099            .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
1100        let finding = to_finding(
1101            EcosystemId::Cargo,
1102            Path::new("Cargo.toml"),
1103            &dep_index,
1104            &StubFormatter,
1105            diagnostic,
1106            &HashMap::new(),
1107            &HashMap::new(),
1108        );
1109        assert_eq!(
1110            finding.advisory_url.as_deref(),
1111            Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
1112        );
1113    }
1114
1115    #[test]
1116    fn test_to_finding_advisory_url_is_none_without_code_description() {
1117        let parse_result = empty_dep_index();
1118        let dep_index = DependencyIndex::build(parse_result.as_ref());
1119        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1120        let finding = to_finding(
1121            EcosystemId::Cargo,
1122            Path::new("Cargo.toml"),
1123            &dep_index,
1124            &StubFormatter,
1125            diagnostic,
1126            &HashMap::new(),
1127            &HashMap::new(),
1128        );
1129        assert!(finding.advisory_url.is_none());
1130    }
1131
1132    /// #1242/#1246: `to_finding`'s `dependency_name` field is a second leak path,
1133    /// independent of the diagnostic's own `message` — the fix at `report.rs`'s
1134    /// `dependency_name` construction must redact it too.
1135    #[test]
1136    fn test_to_finding_redacts_credential_shaped_dependency_name() {
1137        let parse_result = dep_index_with_named_dependency(
1138            "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1139        );
1140        let dep_index = DependencyIndex::build(parse_result.as_ref());
1141        let diagnostic = diagnostic_with(None, "Unknown package");
1142
1143        let finding = to_finding(
1144            EcosystemId::Cargo,
1145            Path::new("Cargo.toml"),
1146            &dep_index,
1147            &StubFormatter,
1148            diagnostic,
1149            &HashMap::new(),
1150            &HashMap::new(),
1151        );
1152
1153        let name = finding
1154            .dependency_name
1155            .expect("range matched the dependency");
1156        assert!(name.contains("***@"));
1157        assert!(!name.contains("glpat-AAAABBBBCCCCDDDD"));
1158    }
1159
1160    /// #1242/#1246 (critic S3 follow-up): unlike a test that builds a [`CheckFinding`] by
1161    /// hand and assigns an already-redacted `dependency_name`, this drives the real
1162    /// `to_finding` -> [`crate::format::sarif::to_sarif`] pipeline end to end, so it actually
1163    /// fails if `to_finding`'s redaction at `dependency_name` construction is ever reverted —
1164    /// the SARIF fingerprint's percent-encoding otherwise preserves a credential verbatim
1165    /// (trivially reversible), and GitHub code scanning persists it in alert history beyond
1166    /// the manifest's own lifetime.
1167    #[test]
1168    fn test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding() {
1169        let parse_result = dep_index_with_named_dependency(
1170            "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1171        );
1172        let dep_index = DependencyIndex::build(parse_result.as_ref());
1173        let diagnostic = diagnostic_with(None, "Unknown package");
1174
1175        let finding = to_finding(
1176            EcosystemId::Cargo,
1177            Path::new("Cargo.toml"),
1178            &dep_index,
1179            &StubFormatter,
1180            diagnostic,
1181            &HashMap::new(),
1182            &HashMap::new(),
1183        );
1184
1185        let sarif = crate::format::sarif::to_sarif(&CheckReport {
1186            findings: vec![finding],
1187        });
1188        let fp = sarif.runs[0].results.as_ref().expect("one result")[0]
1189            .partial_fingerprints
1190            .as_ref()
1191            .expect("fingerprint set")
1192            .get("depsCli/v1")
1193            .expect("depsCli/v1 fingerprint key")
1194            .clone();
1195
1196        assert!(
1197            !fp.contains("glpat-AAAABBBBCCCCDDDD"),
1198            "token leaked (raw or percent-encoded, since `-` is never percent-escaped): {fp}"
1199        );
1200        let decoded = urlencoding::decode(&fp).expect("fingerprint is valid percent-encoding");
1201        assert!(
1202            decoded.contains("***@gitlab.corp"),
1203            "expected the redacted '***@host' marker, got: {decoded}"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_to_finding_resolves_advisory_severity_from_index() {
1209        let parse_result = dep_index_with_one_dependency();
1210        let dep_index = DependencyIndex::build(parse_result.as_ref());
1211        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1212
1213        let mut vuln_keys = HashMap::new();
1214        vuln_keys.insert(Range::default(), "dep-0".to_string());
1215        let mut severities = HashMap::new();
1216        severities.insert(
1217            ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1218            VulnSeverity::Critical,
1219        );
1220
1221        let finding = to_finding(
1222            EcosystemId::Cargo,
1223            Path::new("Cargo.toml"),
1224            &dep_index,
1225            &StubFormatter,
1226            diagnostic,
1227            &severities,
1228            &vuln_keys,
1229        );
1230        assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1231    }
1232
1233    #[test]
1234    fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1235        let parse_result = dep_index_with_one_dependency();
1236        let dep_index = DependencyIndex::build(parse_result.as_ref());
1237        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1238        let mut vuln_keys = HashMap::new();
1239        vuln_keys.insert(Range::default(), "dep-0".to_string());
1240
1241        let finding = to_finding(
1242            EcosystemId::Cargo,
1243            Path::new("Cargo.toml"),
1244            &dep_index,
1245            &StubFormatter,
1246            diagnostic,
1247            &HashMap::new(),
1248            &vuln_keys,
1249        );
1250        assert!(finding.advisory_severity.is_none());
1251    }
1252
1253    /// Regression test for issue #1077 review #4: no matching dependency occurrence at all
1254    /// (an empty `dep_index`/`vuln_keys`, e.g. a document-level diagnostic) must resolve to
1255    /// `None`, not panic.
1256    #[test]
1257    fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1258        let parse_result = empty_dep_index();
1259        let dep_index = DependencyIndex::build(parse_result.as_ref());
1260        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1261        let mut severities = HashMap::new();
1262        severities.insert(
1263            ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1264            VulnSeverity::Critical,
1265        );
1266
1267        let finding = to_finding(
1268            EcosystemId::Cargo,
1269            Path::new("Cargo.toml"),
1270            &dep_index,
1271            &StubFormatter,
1272            diagnostic,
1273            &severities,
1274            &HashMap::new(),
1275        );
1276        assert!(finding.advisory_severity.is_none());
1277    }
1278
1279    #[test]
1280    fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1281        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1282        use std::sync::Arc;
1283
1284        let advisory = Advisory::new(
1285            "RUSTSEC-2024-0001".to_string(),
1286            "2024-01-01T00:00:00Z".to_string(),
1287            VulnSeverity::High,
1288            "https://osv.dev/vulnerability/RUSTSEC-2024-0001".to_string(),
1289        );
1290        let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1291        let mut map: VulnerabilityMap = HashMap::new();
1292        map.insert("serde".to_string(), ScanOutcome::Vulnerable(dv));
1293
1294        let index = advisory_severity_index(Some(&map));
1295        assert_eq!(
1296            index.get(&("serde".to_string(), "RUSTSEC-2024-0001".to_string())),
1297            Some(&VulnSeverity::High)
1298        );
1299    }
1300
1301    /// Regression test for issue #1077 review #4: two different dependencies whose OSV
1302    /// records legitimately share one advisory id must not let one silently overwrite the
1303    /// other's severity bucket.
1304    #[test]
1305    fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1306        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1307        use std::sync::Arc;
1308
1309        let advisory_for = |severity: VulnSeverity| {
1310            Arc::new(Advisory::new(
1311                "GHSA-shared-id".to_string(),
1312                "2024-01-01T00:00:00Z".to_string(),
1313                severity,
1314                "https://osv.dev/vulnerability/GHSA-shared-id".to_string(),
1315            ))
1316        };
1317        let mut map: VulnerabilityMap = HashMap::new();
1318        map.insert(
1319            "package-a".to_string(),
1320            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1321                vec![advisory_for(VulnSeverity::Critical)],
1322                1,
1323            ))),
1324        );
1325        map.insert(
1326            "package-b".to_string(),
1327            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1328                vec![advisory_for(VulnSeverity::Low)],
1329                1,
1330            ))),
1331        );
1332
1333        let index = advisory_severity_index(Some(&map));
1334        assert_eq!(
1335            index.get(&("package-a".to_string(), "GHSA-shared-id".to_string())),
1336            Some(&VulnSeverity::Critical)
1337        );
1338        assert_eq!(
1339            index.get(&("package-b".to_string(), "GHSA-shared-id".to_string())),
1340            Some(&VulnSeverity::Low)
1341        );
1342    }
1343
1344    #[test]
1345    fn test_advisory_severity_index_empty_without_vulnerabilities() {
1346        assert!(advisory_severity_index(None).is_empty());
1347    }
1348}