use deps_core::licenses::LicensePolicy;
use deps_core::lsp_helpers::{
CooldownDisposition, DependencyOutcomes, LatestVerdict, PackageVersions, RequirementGate,
cooldown_disposition, latest_verdict,
};
use deps_core::osv::{LatestStatusMap, VulnerabilityMap};
use deps_core::{
ConcreteVersion, Ecosystem, EcosystemId, FreshnessSettings, GossipFindings, LicenseSource,
PackageName, PublishTime, VersionData,
};
use deps_engine::classify::diff::{
merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
};
use deps_engine::classify::fetch::{
apply_fetch_outcomes, fetch_latest_versions_parallel, prepare_fetch,
};
use deps_engine::classify::license::prefetch_tier3_licenses;
use deps_engine::classify::osv::{build_latest_check_targets, build_scan_targets};
use deps_engine::classify::resolved::load_resolved_versions;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use crate::report::{CheckContext, CheckError};
const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
#[expect(
clippy::struct_excessive_bools,
reason = "each field is an independent phase-gate knob (license/vulnerability/gossip/\
cooldown-fallback), not overlapping state a two-variant enum could express \
more clearly — mirrors StubFormatter's identical rationale"
)]
#[derive(Debug, Clone, Copy)]
pub struct AnalysisScope {
pub licenses: bool,
pub vulnerabilities: bool,
pub gossip: bool,
pub cooldown_fallback: bool,
}
impl AnalysisScope {
#[must_use]
pub const fn all() -> Self {
Self {
licenses: true,
vulnerabilities: true,
gossip: true,
cooldown_fallback: false,
}
}
#[must_use]
pub const fn vulnerabilities_only() -> Self {
Self {
licenses: false,
vulnerabilities: true,
gossip: false,
cooldown_fallback: false,
}
}
#[must_use]
pub const fn update_default() -> Self {
Self {
licenses: false,
vulnerabilities: false,
gossip: true,
cooldown_fallback: true,
}
}
}
pub struct ManifestAnalysis {
pub parse_result: Box<dyn deps_core::ParseResult>,
pub uri: url::Url,
pub now: PublishTime,
pub ecosystem_id: EcosystemId,
pub cached_versions: HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
pub resolved_versions: HashMap<PackageName, ConcreteVersion>,
pub resolved_version_candidates: HashMap<PackageName, Vec<ConcreteVersion>>,
pub outcomes: DependencyOutcomes,
pub vulnerabilities: Option<VulnerabilityMap>,
pub latest_status: Option<LatestStatusMap>,
pub fallback_status: Option<LatestStatusMap>,
pub cooldown_fallback_view: Option<HashMap<PackageName, PackageVersions>>,
pub gossip_findings: HashMap<PackageName, deps_core::GossipFindings>,
pub licenses: HashMap<PackageName, Vec<String>>,
pub license_policy: LicensePolicy,
pub license_source: LicenseSource,
pub network: deps_core::NetworkMode,
pub fetch_failed: HashSet<PackageName>,
pub registry_unreachable: bool,
pub license_fetch_incomplete: bool,
}
impl ManifestAnalysis {
#[must_use]
pub fn version_data(&self) -> VersionData<'_> {
let mut version_data = VersionData::new(&self.cached_versions, &self.resolved_versions)
.with_resolved_version_candidates(&self.resolved_version_candidates)
.with_outcomes(&self.outcomes)
.with_ecosystem(self.ecosystem_id)
.with_network(self.network)
.with_license_source(self.license_source)
.with_license_policy(&self.license_policy)
.with_license_prefetch(&self.licenses)
.with_gossip_prefetch(&self.gossip_findings);
if let Some(vulnerabilities) = self.vulnerabilities.as_ref() {
version_data = version_data.with_vulnerabilities(vulnerabilities);
}
if let Some(latest_status) = self.latest_status.as_ref() {
version_data = version_data.with_latest_status(latest_status);
}
version_data
}
#[must_use]
pub fn has_unverified_latest_check(
&self,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
package_filter: &[String],
ignore_rules: &crate::update::ignore::IgnoreRules,
) -> bool {
let vuln_keys = deps_core::osv::vulnerability_keys(
self.parse_result.as_ref(),
&self.resolved_versions,
Some(&self.resolved_version_candidates),
formatter,
self.ecosystem_id,
);
self.parse_result.dependencies().into_iter().any(|dep| {
let normalized_name = formatter.normalize_package_name(dep.name());
if !crate::update::is_requested(package_filter, &normalized_name, formatter)
|| ignore_rules.matches_name(&normalized_name)
{
return false;
}
let Some(latest) = self
.cached_versions
.get(normalized_name.as_str())
.or_else(|| self.cached_versions.get(dep.name()))
.map(|v| &v.latest)
else {
return false;
};
let Some(version_req) = dep.version_requirement() else {
return false;
};
if formatter.requirement_status_for(dep, version_req, latest)
!= deps_core::lsp_helpers::RequirementStatus::Outdated
{
return false;
}
matches!(
latest_verdict(
self.latest_status.as_ref(),
dep,
Some(&vuln_keys),
&normalized_name,
latest.as_str(),
formatter,
),
LatestVerdict::Unverified
)
})
}
}
pub(crate) fn cooldown_fallback_view(
cached_versions: &HashMap<PackageName, PackageVersions>,
gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
freshness: FreshnessSettings,
now: PublishTime,
) -> HashMap<PackageName, PackageVersions> {
cached_versions
.iter()
.map(|(name, versions)| {
let mut substituted = versions.clone();
if let CooldownDisposition::Blocked {
fallback: Some(fallback),
..
} = cooldown_disposition(versions, name, freshness, gossip_prefetch, now)
{
substituted.latest = fallback.version.clone();
}
(name.clone(), substituted)
})
.collect()
}
pub async fn analyze_manifest(
ecosystem: &Arc<dyn Ecosystem>,
manifest_path: &Path,
content: &str,
ctx: &CheckContext,
scope: AnalysisScope,
) -> Result<ManifestAnalysis, CheckError> {
let now = PublishTime::now();
let uri = crate::report::path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
path: manifest_path.to_path_buf(),
})?;
let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
.await
.map_err(|source| CheckError::Parse {
path: manifest_path.to_path_buf(),
source,
})?;
let formatter = ecosystem.formatter();
let ecosystem_id = ecosystem.ecosystem_id();
let (resolved_versions, resolved_version_candidates) =
load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref())
.await
.into_maps();
let prep = prepare_fetch(
parse_result.as_ref(),
formatter,
ecosystem_id,
&resolved_versions,
&resolved_version_candidates,
);
let collided_names = prep.collided_names;
let attempted_names: Vec<PackageName> = prep
.dep_sources
.iter()
.map(|(name, _)| name.clone())
.collect();
let network = ctx.policy.network.mode();
let gossip_client = (scope.gossip && ctx.policy.gossip.enabled).then_some(&ctx.deps_dev);
let gossip_findings = deps_core::lsp_helpers::fetch_gossip_findings_batch(
ecosystem_id,
parse_result.as_ref(),
formatter,
network,
gossip_client,
)
.await;
let fetch_result = fetch_latest_versions_parallel(
ecosystem.registry(),
prep.dep_sources,
&prep.in_use,
None,
ctx.policy.freshness.to_freshness(),
ctx.policy.cache.fetch_timeout_secs,
ctx.policy.cache.max_concurrent_fetches,
&prep.selection_context,
Some(&gossip_findings),
)
.await;
let registry_unreachable = network.is_online() && !fetch_result.fetch_failed.is_empty();
let fetch_failed: HashSet<PackageName> = fetch_result.fetch_failed.keys().cloned().collect();
let mut outcomes = DependencyOutcomes::new();
let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
apply_fetch_outcomes(
&mut outcomes,
fetch_result.yanked_versions,
fetch_result.fetch_failed,
collided_names,
formatter,
);
merge_deprecations_after_fetch(
&mut outcomes,
&fetched_names,
fetch_result.deprecations,
formatter,
);
merge_no_comparable_versions_after_fetch(
&mut outcomes,
&attempted_names,
fetch_result.no_comparable_versions,
formatter,
);
let cached_versions = fetch_result.versions;
let mut licenses = fetch_result.licenses;
let license_policy = ctx.policy.license_policy.to_policy();
let run_tier3_prefetch = scope.licenses && network.is_online();
let tier3_license_fetch = async {
if run_tier3_prefetch {
prefetch_tier3_licenses(
ecosystem.as_ref(),
parse_result.as_ref(),
&resolved_versions,
&resolved_version_candidates,
&license_policy,
ctx.policy.cache.fetch_timeout_secs,
ctx.policy.cache.max_concurrent_fetches,
)
.await
} else {
deps_engine::classify::license::TierThreeLicenseFetch::default()
}
};
let run_osv_scan = scope.vulnerabilities
&& ctx.policy.diagnostics.vulnerabilities_enabled
&& network.is_online();
let osv_scan = async {
if run_osv_scan {
let (targets, skipped) = build_scan_targets(
parse_result.as_ref(),
&resolved_versions,
&resolved_version_candidates,
formatter,
ecosystem_id,
);
let mut vulns = skipped;
if !targets.is_empty() {
let timeout = Duration::from_secs(
ctx.policy
.cache
.fetch_timeout_secs
.min(OSV_SCAN_TIMEOUT_CEILING_SECS),
);
let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
vulns.extend(scanned);
}
Some(vulns)
} else {
None
}
};
let run_latest_check = ctx.policy.diagnostics.vulnerabilities_enabled && network.is_online();
let cooldown_fallback_view_map = scope
.cooldown_fallback
.then(|| {
cooldown_fallback_view(
&cached_versions,
Some(&gossip_findings),
ctx.policy.freshness.to_freshness(),
now,
)
})
.filter(|view| {
view.iter().any(|(name, v)| {
cached_versions
.get(name)
.is_none_or(|c| c.latest != v.latest)
})
});
let run_fallback_check = run_latest_check && cooldown_fallback_view_map.is_some();
let vuln_keys = (run_latest_check || run_fallback_check).then(|| {
deps_core::osv::vulnerability_keys(
parse_result.as_ref(),
&resolved_versions,
Some(&resolved_version_candidates),
formatter,
ecosystem_id,
)
});
let latest_check = async {
let (true, Some(vuln_keys)) = (run_latest_check, vuln_keys.as_ref()) else {
return None;
};
let (targets, mut latest_status) = build_latest_check_targets(
parse_result.as_ref(),
&cached_versions,
vuln_keys,
formatter,
);
if !targets.is_empty() {
let timeout = Duration::from_secs(
ctx.policy
.cache
.fetch_timeout_secs
.min(OSV_SCAN_TIMEOUT_CEILING_SECS),
);
let checked = ctx
.osv
.check_candidates(ecosystem_id, &targets, timeout)
.await;
latest_status.extend(checked);
}
Some(latest_status)
};
let fallback_check = async {
let (true, Some(vuln_keys), Some(view)) = (
run_fallback_check,
vuln_keys.as_ref(),
cooldown_fallback_view_map.as_ref(),
) else {
return None;
};
let (targets, mut fallback_status) =
build_latest_check_targets(parse_result.as_ref(), view, vuln_keys, formatter);
if !targets.is_empty() {
let timeout = Duration::from_secs(
ctx.policy
.cache
.fetch_timeout_secs
.min(OSV_SCAN_TIMEOUT_CEILING_SECS),
);
let checked = ctx
.osv
.check_candidates(ecosystem_id, &targets, timeout)
.await;
fallback_status.extend(checked);
}
Some(fallback_status)
};
let (tier3_result, vulnerabilities, latest_status, fallback_status): (
_,
Option<VulnerabilityMap>,
_,
_,
) = tokio::join!(tier3_license_fetch, osv_scan, latest_check, fallback_check);
for (name, license) in tier3_result.licenses {
licenses.entry(name).or_insert(license);
}
let license_fetch_incomplete = tier3_result.timed_out > 0;
Ok(ManifestAnalysis {
parse_result,
uri,
now,
ecosystem_id,
cached_versions,
resolved_versions,
resolved_version_candidates,
outcomes,
vulnerabilities,
latest_status,
fallback_status,
cooldown_fallback_view: cooldown_fallback_view_map,
gossip_findings,
licenses,
license_policy,
license_source: ecosystem.license_source(),
network,
fetch_failed,
registry_unreachable,
license_fetch_incomplete,
})
}
#[cfg(test)]
mod has_unverified_latest_check_tests {
use super::ManifestAnalysis;
use crate::update::ignore::IgnoreRules;
use deps_core::licenses::LicensePolicy;
use deps_core::lsp_helpers::{DependencyOutcomes, PackageVersions};
use deps_core::osv::LatestStatusMap;
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::test_util::StubFormatter;
use deps_core::{Dependency, EcosystemId, PackageName, ParseResult, VersionReq};
use std::any::Any;
use std::collections::{HashMap, HashSet};
struct MockDep {
name: PackageName,
version_req: VersionReq,
version_range: Range,
}
impl Dependency for MockDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
Some(&self.version_req)
}
fn version_range(&self) -> Option<Range> {
Some(self.version_range)
}
fn source(&self) -> DependencySource {
DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct MockParseResult {
deps: Vec<MockDep>,
uri: url::Url,
}
impl ParseResult for MockParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &url::Url {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn analysis_with_one_outdated_unverified_dep() -> ManifestAnalysis {
let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0"),
);
ManifestAnalysis {
parse_result: Box::new(MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
}],
uri: uri.clone(),
}),
uri,
now: deps_core::PublishTime::now(),
ecosystem_id: EcosystemId::Cargo,
cached_versions,
resolved_versions: HashMap::new(),
resolved_version_candidates: HashMap::new(),
outcomes: DependencyOutcomes::new(),
vulnerabilities: None,
latest_status: Some(LatestStatusMap::new()),
fallback_status: None,
cooldown_fallback_view: None,
gossip_findings: HashMap::new(),
licenses: HashMap::new(),
license_policy: LicensePolicy::default(),
license_source: deps_core::LicenseSource::default(),
network: deps_core::NetworkMode::Online,
fetch_failed: HashSet::new(),
registry_unreachable: false,
license_fetch_incomplete: false,
}
}
#[test]
fn true_for_outdated_unverified_dependency_in_scope() {
let analysis = analysis_with_one_outdated_unverified_dep();
assert!(analysis.has_unverified_latest_check(
&StubFormatter::DEFAULT,
&[],
&IgnoreRules::empty(),
));
}
#[test]
fn false_when_package_filter_excludes_the_dependency() {
let analysis = analysis_with_one_outdated_unverified_dep();
assert!(!analysis.has_unverified_latest_check(
&StubFormatter::DEFAULT,
&["some-other-package".to_string()],
&IgnoreRules::empty(),
));
}
#[test]
fn true_when_package_filter_names_the_dependency() {
let analysis = analysis_with_one_outdated_unverified_dep();
assert!(analysis.has_unverified_latest_check(
&StubFormatter::DEFAULT,
&["serde".to_string()],
&IgnoreRules::empty(),
));
}
#[test]
fn false_when_an_ignore_rule_names_the_dependency() {
let analysis = analysis_with_one_outdated_unverified_dep();
let rules = IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "serde".to_string(),
update_types: None,
}],
&StubFormatter::DEFAULT,
);
assert!(!analysis.has_unverified_latest_check(&StubFormatter::DEFAULT, &[], &rules));
}
}